From 31074bd511d00248465c879ae80515a464334b20 Mon Sep 17 00:00:00 2001 From: nan Date: Fri, 11 Sep 2026 20:37:20 +0800 Subject: [PATCH] Add tests and implement boundary smoothing and cutout functionality - Introduced tests for boundary smoothing in `test_boundary_smoothing.py`, verifying that smoothing reduces stair-step artifacts while preserving boundary integrity. - Added tests for cutout boundary behavior in `test_cutout_boundary.py`, ensuring proper handling of boundary triangles and normal blending. - Created `test_temporary_attributes.py` to check the cleanup of temporary attributes across depth modifier branches, ensuring no warnings are raised. - Implemented point culling functionality in `test_validity.py`, confirming that invalid points are removed without affecting adjacent geometry. - Developed boundary smoothing logic in `boundary_smoothing.py`, allowing for depth smoothing while maintaining edge integrity. - Added cutout boundary management in `cutout_boundary.py`, including functions for removing boundary triangles and tapering thickness profiles. - Implemented validity checks in `validity.py` to ensure proper deletion of invalid vertices and associated wire edges. --- AGENTS.md | 5 +- docs/internals/node-assets.md | 25 +- experiments/panorama_surface/.gitignore | 3 - experiments/panorama_surface/ATTRIBUTION.md | 5 - experiments/panorama_surface/blender_scene.py | 185 ---------- experiments/panorama_surface/geometry.py | 147 -------- experiments/panorama_surface/prototype.py | 147 -------- experiments/panorama_surface/quad_surface.py | 151 -------- experiments/panorama_surface/test_geometry.py | 93 ----- experiments/panorama_surface/validation.py | 147 -------- .../.openspec.yaml | 2 + .../refine-cutout-split-boundaries/design.md | 61 ++++ .../proposal.md | 25 ++ .../cutout-split-boundary-shaping/spec.md | 69 ++++ .../refine-cutout-split-boundaries/tasks.md | 23 ++ .../.openspec.yaml | 2 + .../design.md | 70 ++++ .../proposal.md | 30 ++ .../specs/geometry-field-flow/spec.md | 57 +++ .../tasks.md | 29 ++ .../validation.md | 52 +++ .../.openspec.yaml | 2 + .../unify-boundary-depth-smoothing/design.md | 72 ++++ .../proposal.md | 27 ++ .../specs/boundary-depth-smoothing/spec.md | 90 +++++ .../unify-boundary-depth-smoothing/tasks.md | 28 ++ .../.openspec.yaml | 2 + .../update-pasted-brush-preview/design.md | 38 ++ .../update-pasted-brush-preview/proposal.md | 24 ++ .../specs/pasted-brush-preview/spec.md | 41 +++ .../update-pasted-brush-preview/tasks.md | 13 + src/anyimage/assets/O_AnyImage.blend | 4 +- .../operators/clipboard_image/actions.py | 29 +- .../operators/clipboard_image/operators.py | 4 + .../operators/convert_to_panorama/object.py | 2 +- .../operators/convert_to_plane/object.py | 2 +- src/anyimage/operators/cutout_tool/object.py | 2 +- src/anyimage/preferences.py | 8 +- src/anyimage/properties.py | 4 +- src/anyimage/server/geometry/edge_depth.py | 79 ----- .../server/geometry/prediction_artifacts.py | 6 +- src/anyimage/server/model_catalog.py | 18 +- tests/anyimage/operators/test_ai_setup.py | 6 +- .../test_brush_preview_integration.py | 37 ++ .../operators/test_clipboard_image.py | 83 +++++ tests/anyimage/operators/test_cutout_depth.py | 11 + .../operators/test_cutout_geometry.py | 3 +- .../anyimage/operators/test_cutout_objects.py | 14 +- .../operators/test_panorama_conversion.py | 5 + .../operators/test_plane_conversion.py | 4 +- .../anyimage/operators/test_plane_objects.py | 6 +- tests/anyimage/server/test_edge_depth.py | 236 ------------ .../server/test_prediction_artifacts.py | 104 ++++++ tests/anyimage/test_preferences.py | 15 + tests/support/depth_surface.py | 1 + tests/support/planes.py | 2 + tests/tools/nodes/test_boundary_depth.py | 186 ++++++++++ tests/tools/nodes/test_boundary_smoothing.py | 335 ++++++++++++++++++ tests/tools/nodes/test_cutout_boundary.py | 128 +++++++ tests/tools/nodes/test_cutouts.py | 5 +- tests/tools/nodes/test_depth_cutout.py | 13 +- tests/tools/nodes/test_depth_relief_planes.py | 15 +- tests/tools/nodes/test_depth_strip_cleanup.py | 24 +- tests/tools/nodes/test_depth_surface_split.py | 47 ++- .../tools/nodes/test_image_depth_panorama.py | 2 + tests/tools/nodes/test_image_plane.py | 41 +++ tests/tools/nodes/test_inventory.py | 40 ++- .../tools/nodes/test_temporary_attributes.py | 43 +++ tests/tools/nodes/test_validity.py | 40 +++ tests/tools/test_pack.py | 1 - tools/nodes/arrangement/arrange.py | 38 +- tools/nodes/arrangement/dimensions.json | 2 +- tools/nodes/arrangement/dimensions.py | 2 +- tools/nodes/check.py | 22 +- tools/nodes/common/boundary_smoothing.py | 79 +++++ tools/nodes/common/cutout_boundary.py | 45 +++ tools/nodes/common/depth_surface.py | 129 +++---- tools/nodes/common/material.py | 19 +- tools/nodes/common/nodes.py | 50 ++- tools/nodes/common/smoothing.py | 81 +++-- tools/nodes/common/validity.py | 21 ++ tools/nodes/groups/image_depth_cutout.py | 122 +++++-- tools/nodes/groups/image_depth_panorama.py | 38 +- tools/nodes/groups/image_depth_plane.py | 32 +- uv.lock | 2 +- 85 files changed, 2501 insertions(+), 1451 deletions(-) delete mode 100644 experiments/panorama_surface/.gitignore delete mode 100644 experiments/panorama_surface/ATTRIBUTION.md delete mode 100644 experiments/panorama_surface/blender_scene.py delete mode 100644 experiments/panorama_surface/geometry.py delete mode 100644 experiments/panorama_surface/prototype.py delete mode 100644 experiments/panorama_surface/quad_surface.py delete mode 100644 experiments/panorama_surface/test_geometry.py delete mode 100644 experiments/panorama_surface/validation.py create mode 100644 openspec/changes/refine-cutout-split-boundaries/.openspec.yaml create mode 100644 openspec/changes/refine-cutout-split-boundaries/design.md create mode 100644 openspec/changes/refine-cutout-split-boundaries/proposal.md create mode 100644 openspec/changes/refine-cutout-split-boundaries/specs/cutout-split-boundary-shaping/spec.md create mode 100644 openspec/changes/refine-cutout-split-boundaries/tasks.md create mode 100644 openspec/changes/remove-geometry-capture-attributes/.openspec.yaml create mode 100644 openspec/changes/remove-geometry-capture-attributes/design.md create mode 100644 openspec/changes/remove-geometry-capture-attributes/proposal.md create mode 100644 openspec/changes/remove-geometry-capture-attributes/specs/geometry-field-flow/spec.md create mode 100644 openspec/changes/remove-geometry-capture-attributes/tasks.md create mode 100644 openspec/changes/remove-geometry-capture-attributes/validation.md create mode 100644 openspec/changes/unify-boundary-depth-smoothing/.openspec.yaml create mode 100644 openspec/changes/unify-boundary-depth-smoothing/design.md create mode 100644 openspec/changes/unify-boundary-depth-smoothing/proposal.md create mode 100644 openspec/changes/unify-boundary-depth-smoothing/specs/boundary-depth-smoothing/spec.md create mode 100644 openspec/changes/unify-boundary-depth-smoothing/tasks.md create mode 100644 openspec/changes/update-pasted-brush-preview/.openspec.yaml create mode 100644 openspec/changes/update-pasted-brush-preview/design.md create mode 100644 openspec/changes/update-pasted-brush-preview/proposal.md create mode 100644 openspec/changes/update-pasted-brush-preview/specs/pasted-brush-preview/spec.md create mode 100644 openspec/changes/update-pasted-brush-preview/tasks.md delete mode 100644 src/anyimage/server/geometry/edge_depth.py create mode 100644 tests/anyimage/operators/test_brush_preview_integration.py delete mode 100644 tests/anyimage/server/test_edge_depth.py create mode 100644 tests/anyimage/server/test_prediction_artifacts.py create mode 100644 tests/tools/nodes/test_boundary_depth.py create mode 100644 tests/tools/nodes/test_boundary_smoothing.py create mode 100644 tests/tools/nodes/test_cutout_boundary.py create mode 100644 tests/tools/nodes/test_temporary_attributes.py create mode 100644 tests/tools/nodes/test_validity.py create mode 100644 tools/nodes/common/boundary_smoothing.py create mode 100644 tools/nodes/common/cutout_boundary.py create mode 100644 tools/nodes/common/validity.py diff --git a/AGENTS.md b/AGENTS.md index 7df250f..40c10f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,8 +91,9 @@ uv run sync-models - 插件运行时必须使用 `load_node_group(group_name)` 从资产加载,禁止调用 `bpy.data.node_groups.new()` 创建节点组。 - `tools/nodes` 构建脚本和测试允许通过代码创建节点组。 - 字段默认直接连接,按消费位置的几何、域、类型及输入属性版本验证求值等价性;多个消费者可复用同一输出。 -- 域转换显式表达;跨变形、拓扑或分支变化需要保留源值时,使用 Capture Attribute 并确保捕获值随几何传递。 -- 命名属性用于需要按名称访问的数据协议,明确名称、类型、域与消费者;内部临时命名属性按专用前缀在最后消费者之后清理。 +- 所有几何节点、嵌套组及测试辅助图禁止使用 Capture Attribute。域转换显式表达;跨几何变化优先调整消费顺序或在明确源几何上采样。 +- Store Named Attribute 仅用于实际命名协议或经论证仍需跨几何变化保存的最少数据;明确名称、类型、域、Selection、写入阶段及消费者。多个消费者或跨区域连线不构成存值理由。 +- 内部临时命名属性使用专用名称,在最后消费者之后精确清理,保留用户属性;源码和保存资产均检查 Capture 为零。 - 节点布局通过区域内输入、字段排列和必要的 Reroute 保持可读;存储须有数据生命周期依据,构建阶段通过明确的节点或 socket 引用接线。 - 修改节点组的逻辑、接口、布局或共用构建函数后,必须运行 `uv run node-group build`,无需另行确认;源码、测试与 `.blend` 资产均更新且验证通过,才算完成。 - 具体构建规范与操作步骤见[节点资产](docs/internals/node-assets.md)。 diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md index 192c82d..dc5e28f 100644 --- a/docs/internals/node-assets.md +++ b/docs/internals/node-assets.md @@ -9,8 +9,9 @@ 节点组规则: - 字段默认直接连接,多个消费者可复用同一输出;按消费位置的几何、域、类型和输入属性版本验证求值等价性 -- 域转换使用 Evaluate on Domain 或明确域的采样节点表达;跨变形、拓扑或分支变化需要固定源值时,使用 Capture Attribute,明确捕获时机并验证几何传播路径 -- 命名属性用于需要按名称访问的数据协议,明确名称、类型、域、写入选择和消费者;内部临时属性使用专用 `o_*` 前缀,在最后消费者之后按前缀清理,保留 UV、用户属性和外部协议属性 +- 所有几何节点、嵌套组及测试辅助图禁止使用 Capture Attribute;构建定义和保存资产递归检查 Capture 为零 +- 域转换使用 Evaluate on Domain 或明确域的采样节点表达;跨几何变化优先调整消费顺序或在源几何上采样,明确索引对应关系 +- Store Named Attribute 用于实际命名协议或仍需跨几何变化保存的最少数据,逐项明确名称、类型、域、Selection、写入阶段、消费者及必要性;内部临时属性使用专用名称,在最后消费者之后精确清理,保留 UV、用户属性和外部协议属性 - Group Input 按区域重复放置,通过字段排列和必要的 Reroute 保持连线可读,隐藏未使用输出;存储须有数据生命周期依据 - 构建阶段通过明确的节点或 socket 引用接线 - 保留节点默认 `name` 和标题,不用 `label` 覆盖,不用 String 节点注解。Frame 仅用于确有包裹语义的区域 @@ -25,10 +26,26 @@ flowchart TD B -->|是| C[命名属性] B -->|否| D{目标上下文重新求值是否等价} D -->|是| E[字段直连并显式转换域] - D -->|否| F[在源几何与源域捕获] - F --> G[随几何传递匿名属性] + D -->|否| F[调整消费顺序或在源几何采样] + F --> G{仍需保存源数据} + G -->|否| E + G -->|是| H[仅存储必要命名数据] + H --> I[最后消费者后精确清理] ``` +## 字段与临时属性 + +平滑循环按固定源位置查询原边界索引,并在当前边界网格上消费位置;拓扑不变的边界与影响权重直接求值。Depth Plane 由 UV 矩形判断原边界,Panorama 的闭合源球面以 False 表示原边界。 + +厚度构建使用源顶点索引排列构造网格,完成挤出与合并后按索引从源表面采样位置、厚度和法线。Depth Cutout 的中心、圆化、结构及膨胀字段在投影源表面求值。 + +| 属性 | 域 / 类型 | 写入与最后消费 | +| --- | --- | --- | +| `.o_anyimage_depth_cut` | CORNER / BOOLEAN | 三个深度组在 Split Edges 前保存切分标记,供投影调整射线,投影后精确清理 | +| `.o_anyimage_original_boundary` | POINT / BOOLEAN | Depth Cutout 在切分前保存任意输入轮廓,供切口平滑保护原边界,平滑后精确清理 | +| `UVMap` | CORNER / FLOAT2 | Image Plane 与 Panorama 的输出 UV,供材质读取 | +| `o_normal_scale` | POINT 或 FACE / FLOAT | Cutout 输出法线掩码,Depth Cutout 合并背面与侧壁后统一写入,供形状和材质消费 | + ## 资产清单 diff --git a/experiments/panorama_surface/.gitignore b/experiments/panorama_surface/.gitignore deleted file mode 100644 index 639c90f..0000000 --- a/experiments/panorama_surface/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -/output/ -/analytic/ -/photo/ diff --git a/experiments/panorama_surface/ATTRIBUTION.md b/experiments/panorama_surface/ATTRIBUTION.md deleted file mode 100644 index 95b9e56..0000000 --- a/experiments/panorama_surface/ATTRIBUTION.md +++ /dev/null @@ -1,5 +0,0 @@ -# 素材来源 - -真实照片试验使用 DerMische 的 [Braunschweig Sankt-Ägidien Panorama 02](https://commons.wikimedia.org/wiki/File:Braunschweig_Sankt-%C3%84gidien_Panorama_02.jpg),许可为 [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)。使用 Wikimedia 的 1280×640 缩略图作为输入,并通过 MoGe2 估计距离、重投影和生成网格。照片及相应衍生预览、带照片纹理的网格遵循该许可,作者不为本试验背书。 - -多视图投影与梯度域距离融合参考 [microsoft/MoGe 的 panorama.py](https://github.com/microsoft/MoGe/blob/main/moge/utils/panorama.py);固定视角几何恢复参考 [MoGe v2](https://github.com/microsoft/MoGe/blob/main/moge/model/v2.py)。试验独立实现这些计算。 diff --git a/experiments/panorama_surface/blender_scene.py b/experiments/panorama_surface/blender_scene.py deleted file mode 100644 index 8950fdf..0000000 --- a/experiments/panorama_surface/blender_scene.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Build or verify a standalone panorama Geometry Nodes demonstration offline.""" - -import argparse -from pathlib import Path -import sys - -import bpy -import numpy as np -from mathutils import Vector - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from quad_surface import build_node_group -from validation import evaluated_points, socket_id, verify - - -def create_distance_image(name, path, output_path): - with np.load(path) as data: - distance, alpha = data["distance"], data["alpha"] - height, width = distance.shape - image = bpy.data.images.new(name, width=width, height=height, alpha=True, float_buffer=True) - image.colorspace_settings.name = "Non-Color" - image.alpha_mode = "CHANNEL_PACKED" - rgba = np.concatenate((np.repeat(distance[..., None], 3, -1), alpha[..., None]), -1) - image.pixels.foreach_set(rgba[::-1].astype(np.float32).ravel()) - image.filepath_raw = str(output_path) - image.file_format = "OPEN_EXR" - image.save() - image.pack() - return image - - -def create_material(image): - material = bpy.data.materials.new("Panorama Color") - material.use_nodes = True - material.node_tree.nodes.clear() - nodes, links = material.node_tree.nodes, material.node_tree.links - uv = nodes.new("ShaderNodeUVMap") - uv.uv_map = "UVMap" - uv.location = (0, 0) - texture = nodes.new("ShaderNodeTexImage") - texture.image = image - texture.extension = "REPEAT" - texture.location = (200, 0) - emission = nodes.new("ShaderNodeEmission") - emission.location = (500, 0) - output = nodes.new("ShaderNodeOutputMaterial") - output.location = (700, 0) - links.new(uv.outputs[0], texture.inputs[0]) - links.new(texture.outputs[0], emission.inputs[0]) - links.new(emission.outputs[0], output.inputs[0]) - return material - - -def build_scene(output): - bpy.ops.wm.read_factory_settings(use_empty=True) - group = build_node_group() - color = bpy.data.images.load(str(output / "panorama.png")) - color.pack() - material = create_material(color) - objects = [] - for name, filename, offset in (("Reference Room", "reference.npz", -4), - ("MoGe2 Panorama", "prediction.npz", 4)): - if not (output / filename).exists(): - continue - depth_path = output / ("reference_depth.exr" if filename == "reference.npz" else "depth.exr") - distance = create_distance_image(depth_path.name, output / filename, depth_path) - mesh = bpy.data.meshes.new("Panorama Host") - obj = bpy.data.objects.new(name, mesh) - bpy.context.collection.objects.link(obj) - obj.location.x = offset - obj.data.materials.append(material) - modifier = obj.modifiers.new("Panorama Surface", "NODES") - modifier.node_group = group - modifier[socket_id(group, "Distance")] = distance - modifier[socket_id(group, "Subdivide")] = 7 - modifier[socket_id(group, "Depth Scale")] = 1.0 - modifier[socket_id(group, "Validity Threshold")] = 0.9 - modifier[socket_id(group, "Material")] = material - objects.append(obj) - for obj in objects: - obj.select_set(False) - objects[-1].select_set(True) - bpy.context.view_layer.objects.active = objects[-1] - scene = bpy.context.scene - scene.render.engine = "CYCLES" - scene.cycles.samples = 16 - scene.render.resolution_x, scene.render.resolution_y = 1400, 750 - scene.render.resolution_percentage = 100 - scene.world = bpy.data.worlds.new("Trial World") - scene.world.color = (0.18, 0.18, 0.18) - scene.view_settings.view_transform = "Standard" - camera_data = bpy.data.cameras.new("Overview") - camera = bpy.data.objects.new("Overview", camera_data) - bpy.context.collection.objects.link(camera) - camera.location = (11, -17, 13) - camera.rotation_euler = (Vector((0, 0, 0)) - camera.location).to_track_quat("-Z", "Y").to_euler() - camera_data.type, camera_data.ortho_scale = "ORTHO", 20 - if len(objects) == 1: - points, _ = evaluated_points(objects[0]) - points += np.array(objects[0].location) - center = (points.min(0) + points.max(0)) / 2 - extent = float(np.max(np.ptp(points, axis=0))) - camera.location = Vector(center) + Vector((0.6, -1, 1)) * extent - camera.rotation_euler = (Vector(center) - camera.location).to_track_quat("-Z", "Y").to_euler() - camera_data.ortho_scale = extent * 1.8 - scene.camera = camera - capture_camera = None - if len(objects) == 1: - capture_data = bpy.data.cameras.new("Capture Center") - capture_data.lens = 22 - capture_camera = bpy.data.objects.new("Capture Center", capture_data) - bpy.context.collection.objects.link(capture_camera) - capture_camera.location = objects[0].location - capture_camera.rotation_euler = Vector((-1, 0, 0.15)).to_track_quat("-Z", "Y").to_euler() - bpy.data.texts.new("Attribution").write( - (Path(__file__).parent / "ATTRIBUTION.md").read_text(encoding="utf-8")) - bpy.data.texts.new("试验说明").write( - "Subdivide 控制 Quad Sphere 细分,全有效面数为 6 × 4^Subdivide。\n" - "Depth Scale:0 为单位球面,1 为全景距离重建。\n" - "深度 EXR 的 Alpha 已包含 Mask × 源图 Alpha,低于 Validity Threshold 的区域会被剔除。\n" - "Quad Sphere 来自 O_Essentials,节点组和依赖已打包。\n" - ) - for screen in bpy.data.screens: - for area in screen.areas: - if area.type == "VIEW_3D": - area.spaces.active.shading.type = "MATERIAL" - area.spaces.active.region_3d.view_distance = extent * 1.4 if len(objects) == 1 else 18 - if len(objects) == 1: - area.spaces.active.region_3d.view_location = Vector(center) - area.spaces.active.region_3d.view_rotation = camera.rotation_euler.to_quaternion() - verify(output) - bpy.ops.wm.save_as_mainfile(filepath=str(output / "panorama_trial.blend")) - # The render uses a temporary inspection cut; the saved asset retains the full shell. - for obj in objects: - if len(objects) == 1: - continue - modifier = obj.modifiers.new("Inspection Cut", "NODES") - cut_group = bpy.data.node_groups.new(f"{obj.name} Inspection", "GeometryNodeTree") - cut_group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") - cut_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - source = cut_group.nodes.new("NodeGroupInput") - position = cut_group.nodes.new("GeometryNodeInputPosition") - xyz = cut_group.nodes.new("ShaderNodeSeparateXYZ") - above = cut_group.nodes.new("ShaderNodeMath") - above.operation = "GREATER_THAN" - above.inputs[1].default_value = 0.4 - front = cut_group.nodes.new("ShaderNodeMath") - front.operation = "LESS_THAN" - front.inputs[1].default_value = -0.7 - either = cut_group.nodes.new("ShaderNodeMath") - either.operation = "MAXIMUM" - remove = cut_group.nodes.new("GeometryNodeDeleteGeometry") - remove.domain = "FACE" - result = cut_group.nodes.new("NodeGroupOutput") - for a, b in ((position.outputs[0], xyz.inputs[0]), - (xyz.outputs["Z"], above.inputs[0]), (xyz.outputs["Y"], front.inputs[0]), - (above.outputs[0], either.inputs[0]), (front.outputs[0], either.inputs[1]), - (source.outputs[0], remove.inputs["Geometry"]), - (either.outputs[0], remove.inputs["Selection"]), - (remove.outputs[0], result.inputs[0])): - cut_group.links.new(a, b) - modifier.node_group = cut_group - scene.render.filepath = str(output / "comparison.png") - bpy.ops.render.render(write_still=True) - if capture_camera is not None: - scene.camera = capture_camera - scene.render.filepath = str(output / "capture_view.png") - bpy.ops.render.render(write_still=True) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--verify", action="store_true") - args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []) - output = args.output.resolve() - if args.verify: - bpy.ops.wm.open_mainfile(filepath=str(output / "panorama_trial.blend")) - verify(output) - else: - build_scene(output) - - -if __name__ == "__main__": - main() diff --git a/experiments/panorama_surface/geometry.py b/experiments/panorama_surface/geometry.py deleted file mode 100644 index 7f18dde..0000000 --- a/experiments/panorama_surface/geometry.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Spherical projection and gradient-domain distance fusion for an isolated trial.""" - -import numpy as np -from scipy.ndimage import map_coordinates -from scipy.sparse import coo_matrix, diags -from scipy.sparse.linalg import spsolve - - -def panorama_directions(width, height): - u, v = np.meshgrid((np.arange(width) + 0.5) / width, - (np.arange(height) + 0.5) / height) - return uv_directions(u, v) - - -def uv_directions(u, v): - theta, phi = 2 * np.pi * u, np.pi * v - return np.stack((np.sin(phi) * np.cos(theta), - np.sin(phi) * np.sin(theta), np.cos(phi)), axis=-1) - - -def camera_bases(): - ratio = (1 + np.sqrt(5)) / 2 - vertices = [] - for a in (-1, 1): - for b in (-ratio, ratio): - vertices.extend(((0, a, b), (a, b, 0), (b, 0, a))) - bases = [] - for forward in np.asarray(vertices): - forward = forward / np.linalg.norm(forward) - right = np.cross(forward, (0, 0, 1)) - right /= np.linalg.norm(right) - down = np.cross(forward, right) - bases.append(np.stack((right, down, forward))) - return bases - - -def view_rays(size): - x, y = np.meshgrid(2 * (np.arange(size) + 0.5) / size - 1, - 2 * (np.arange(size) + 0.5) / size - 1) - return np.stack((x, y, np.ones_like(x)), axis=-1) - - -def sample_panorama(image, directions): - directions = directions / np.linalg.norm(directions, axis=-1, keepdims=True) - u = np.arctan2(directions[..., 1], directions[..., 0]) / (2 * np.pi) % 1 - v = np.arccos(np.clip(directions[..., 2], -1, 1)) / np.pi - height, width = image.shape[:2] - padded = np.concatenate((image[:, -1:], image, image[:, :1]), axis=1) - coords = (np.clip(v * height - 0.5, 0, height - 1), u * width + 0.5) - if image.ndim == 2: - return map_coordinates(padded, coords, order=1, mode="nearest") - return np.stack([map_coordinates(padded[..., c], coords, order=1, - mode="nearest") for c in range(image.shape[2])], -1) - - -def room_reference(directions): - """Trace a furnished rectangular room from a camera 1.6 m above its floor.""" - directions = directions / np.linalg.norm(directions, axis=-1, keepdims=True) - bounds = np.where(directions >= 0, (3.0, 4.0, 1.4), (-3.0, -4.0, -1.6)) - hits = bounds / np.where(np.abs(directions) > 1e-12, directions, 1e-12) - hits = np.where(hits > 0, hits, np.inf) - surface = np.argmin(hits, axis=-1) - distance = np.min(hits, axis=-1) - point = directions * distance[..., None] - colors = np.array(((0.68, 0.76, 0.80), (0.81, 0.73, 0.61), (0.9, 0.88, 0.82))) - color = colors[surface].copy() - floor = (surface == 2) & (directions[..., 2] < 0) - tiles = (np.floor(point[..., 0] * 2) + np.floor(point[..., 1] * 2)) % 2 - color[floor] = np.stack((0.40 + 0.08 * tiles, 0.27 + 0.06 * tiles, - 0.15 + 0.04 * tiles), -1)[floor] - lines = (np.abs(np.sin(point[..., 0] * np.pi * 2)) < 0.04) | ( - np.abs(np.sin(point[..., 1] * np.pi * 2)) < 0.04) - color[floor & lines] *= 0.6 - window = (surface == 1) & (directions[..., 1] > 0) & ( - np.abs(point[..., 0] + 0.4) < 1.05) & (np.abs(point[..., 2] - 0.25) < 0.7) - color[window] = (0.48, 0.72, 0.88) - frame = window & ((np.abs(point[..., 0] + 0.4) < 0.045) | - (np.abs(point[..., 2] - 0.25) < 0.04)) - color[frame] = 0.93 - for lower, upper, base_color in ( - ((0.8, 1.0, -1.6), (2.0, 2.2, -0.6), (0.28, 0.43, 0.34)), - ((-2.8, -1.8, -1.6), (-2.1, 0.5, 0.7), (0.43, 0.26, 0.15)), - ): - safe = np.where(np.abs(directions) > 1e-12, directions, 1e-12) - a, b = np.asarray(lower) / safe, np.asarray(upper) / safe - near, far = np.max(np.minimum(a, b), axis=-1), np.min(np.maximum(a, b), axis=-1) - visible = (near > 0) & (near < far) & (near < distance) - distance[visible] = near[visible] - color[visible] = base_color - color *= (0.75 + 0.25 * np.clip(directions[..., 2] + 0.6, 0, 1))[..., None] - return distance, color - - -def warp_distances(distances, masks, bases, width, height): - directions = panorama_directions(width, height) - logs, validities = [], [] - for distance, mask, basis in zip(distances, masks, bases): - rays = directions @ basis.T - projected = rays[..., :2] / np.maximum(rays[..., 2:], 1e-8) - valid = (rays[..., 2] > 0) & (np.abs(projected) < 0.98).all(axis=-1) - size = distance.shape[0] - coords = ((projected[..., 1] + 1) * size / 2 - 0.5, - (projected[..., 0] + 1) * size / 2 - 0.5) - valid &= map_coordinates(mask.astype(float), coords, order=0, mode="nearest") > 0.5 - logs.append(map_coordinates(np.log(np.maximum(distance, 1e-6)), coords, - order=1, mode="nearest")) - validities.append(valid) - return np.asarray(logs), np.asarray(validities) - - -def merge_distances(distances, masks, bases, width, height): - """Fuse log-distance derivatives with a cyclic longitude and a scale anchor. - - Algorithm reference: microsoft/MoGe, moge/utils/panorama.py. - This independent implementation solves normal equations on a small trial grid. - """ - logs, valid = warp_distances(distances, masks, bases, width, height) - count = width * height - index = np.arange(count).reshape(height, width) - start = np.concatenate((index.ravel(), index[:-1].ravel())) - end = np.concatenate((np.roll(index, -1, axis=1).ravel(), index[1:].ravel())) - rows = np.arange(len(start)) - gradient = coo_matrix((np.concatenate((-np.ones(len(start)), np.ones(len(start)))), - (np.tile(rows, 2), np.concatenate((start, end)))), - shape=(len(start), count)).tocsr() - laplacian = -(gradient.T @ gradient).tocsr() - edge_valid = valid.reshape(12, -1)[:, start] & valid.reshape(12, -1)[:, end] - grad_samples = logs.reshape(12, -1)[:, end] - logs.reshape(12, -1)[:, start] - grad_target = np.sum(grad_samples * edge_valid, axis=0) / np.maximum(edge_valid.sum(0), 1) - lap_valid = valid.copy() - lap_valid &= np.roll(valid, 1, axis=2) & np.roll(valid, -1, axis=2) - lap_valid[:, 1:] &= valid[:, :-1] - lap_valid[:, :-1] &= valid[:, 1:] - lap_valid = lap_valid.reshape(12, -1) - lap_samples = np.asarray((laplacian @ logs.reshape(12, -1).T).T) - lap_target = np.sum(lap_samples * lap_valid, axis=0) / np.maximum(lap_valid.sum(0), 1) - g = gradient[edge_valid.any(0)] - l = laplacian[lap_valid.any(0)] - coverage = valid.any(0) - if not coverage.any(): - raise ValueError("No valid panorama predictions") - baseline = np.sum(logs * valid, axis=0) / np.maximum(valid.sum(0), 1) - matrix = g.T @ g + l.T @ l + diags(np.full(count, 1e-7)) - target = g.T @ grad_target[edge_valid.any(0)] + l.T @ lap_target[lap_valid.any(0)] - solution = spsolve(matrix.tocsc(), target + 1e-7 * baseline.ravel()).reshape(height, width) - solution += np.median(baseline[coverage] - solution[coverage]) - return np.exp(solution).astype(np.float32), coverage diff --git a/experiments/panorama_surface/prototype.py b/experiments/panorama_surface/prototype.py deleted file mode 100644 index e2bc1cb..0000000 --- a/experiments/panorama_surface/prototype.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Run MoGe2 panorama inference without importing or changing the add-on.""" - -import argparse -import json -from pathlib import Path -import sys -import time - -import numpy as np -from PIL import Image -from scipy.optimize import least_squares - -from geometry import (camera_bases, merge_distances, panorama_directions, - room_reference, sample_panorama, view_rays) - - -def fixed_fov_distance(points, mask, metric_scale): - """Recover camera Z shift using the known 90-degree perspective rays.""" - size = points.shape[0] - rays = view_rays(size) - select = mask > 0.5 - select &= np.isfinite(points).all(-1) - if select.sum() < 16: - return np.ones((size, size), dtype=np.float32), np.zeros((size, size), dtype=bool) - stride = max(1, size // 64) - sampled = select[::stride, ::stride] - p = points[::stride, ::stride][sampled].astype(float) - q = rays[::stride, ::stride, :2][sampled] - initial = np.sum(q * (p[:, :2] - q * p[:, 2:])) / max(np.sum(q * q), 1e-8) - lower = -float(points[..., 2][select].min()) + 1e-5 - fitted = least_squares(lambda shift: (p[:, :2] / (p[:, 2:] + shift[0]) - q).ravel(), - [max(initial, lower + 1e-4)], bounds=([lower], [np.inf])) - depth = (points[..., 2] + fitted.x[0]) * float(metric_scale) - select &= (depth > 0) & np.isfinite(depth) - distance = np.maximum(depth, 1e-6) * np.linalg.norm(rays, axis=-1) - return distance.astype(np.float32), select - - -def depth_alpha(image, validity): - """Combine the model mask with nearest-resized source alpha once at export.""" - height, width = validity.shape - if image.shape[-1] == 4: - source = Image.fromarray(image[..., 3].astype(np.float32)) - alpha = np.asarray(source.resize((width, height), Image.Resampling.NEAREST)) - else: - alpha = np.ones((height, width), dtype=np.float32) - return np.asarray(validity, dtype=np.float32) * alpha - - -def remove_background(image): - """Use the project's existing BEN2 implementation for the trial input.""" - root = Path(__file__).resolve().parents[2] - sys.path.insert(0, str(root / "src/anyimage/server/models")) - from onnx_ben2 import create_session, infer_alpha - - session = create_session(root / "models/BEN2-ONNX", "cpu") - alpha = infer_alpha(session, image, release_memory=True) - foreground = image.convert("RGBA") - foreground.putalpha(Image.fromarray((alpha * 255).astype(np.uint8))) - return foreground - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--image", type=Path) - parser.add_argument("--model", type=Path, default=Path("models/moge-2-vits-normal-onnx/model.onnx")) - parser.add_argument("--output", type=Path, default=Path(__file__).parent / "output") - parser.add_argument("--size", type=int, default=256) - parser.add_argument("--width", type=int, default=256) - parser.add_argument("--reference-only", action="store_true") - parser.add_argument("--remove-background", action="store_true") - args = parser.parse_args() - args.output.mkdir(parents=True, exist_ok=True) - width, height = args.width, args.width // 2 - directions = panorama_directions(width, height) - truth, _ = room_reference(directions) - if args.image: - with Image.open(args.image) as source: - source = source.convert("RGBA") - if args.remove_background: - foreground = remove_background(source) - original_alpha = np.asarray(source.getchannel("A"), dtype=np.float32) / 255 - alpha = np.asarray(foreground.getchannel("A"), dtype=np.float32) * original_alpha - source.putalpha(Image.fromarray(alpha.astype(np.uint8))) - image = np.asarray(source, dtype=np.float32) / 255 - if image.shape[1] != 2 * image.shape[0]: - raise ValueError("Expected a full 2:1 equirectangular panorama") - else: - _, image = room_reference(panorama_directions(1024, 512)) - np.savez_compressed(args.output / "reference.npz", distance=truth.astype(np.float32), - alpha=np.ones_like(truth, dtype=np.float32)) - Image.fromarray(np.uint8(np.clip(image, 0, 1) * 255)).save(args.output / "panorama.png") - bases = camera_bases() - distances, masks, panels = [], [], [] - start = time.perf_counter() - if not args.reference_only: - import onnxruntime as ort - options = ort.SessionOptions() - options.intra_op_num_threads = 8 - session = ort.InferenceSession(str(args.model), options, providers=["CPUExecutionProvider"]) - scale_name = next(item.name for item in session.get_outputs() - if item.name in {"scale", "metric_scale"}) - for index, basis in enumerate(bases): - rays = view_rays(args.size) @ basis - view = sample_panorama(image, rays).astype(np.float32) - source_valid = np.ones(view.shape[:2], dtype=bool) - if view.shape[-1] == 4: - source_valid = view[..., 3] > 0 - view = view[..., :3] * view[..., 3:] - panels.append(np.uint8(np.clip(view, 0, 1) * 255)) - if args.reference_only: - distance, _ = room_reference(rays) - mask = np.ones_like(distance, dtype=bool) - else: - prediction = session.run(["points", "mask", scale_name], - {"image": view.transpose(2, 0, 1)[None], - "num_tokens": np.array(1200, dtype=np.int64)}) - distance, mask = fixed_fov_distance(prediction[0][0], prediction[1][0] * source_valid, - prediction[2].reshape(-1)[0]) - mask &= source_valid - distances.append(distance) - masks.append(mask) - print(f"View {index + 1}/12: valid={mask.mean():.3f}, elapsed={time.perf_counter()-start:.1f}s", flush=True) - Image.fromarray(np.concatenate([np.concatenate(panels[i:i + 4], axis=1) - for i in range(0, 12, 4)], axis=0)).save(args.output / "views.png") - np.savez_compressed(args.output / "views.npz", distances=distances, masks=masks) - distance, validity = merge_distances(distances, masks, bases, width, height) - combined_alpha = depth_alpha(image, validity) - np.savez_compressed(args.output / "prediction.npz", distance=distance, alpha=combined_alpha) - stats = {"mode": "analytic" if args.reference_only else args.model.parent.name + "-cpu", - "input": str(args.image) if args.image else "synthetic room", - "background_removal": "BEN2" if args.remove_background else None, - "size": [width, height], "valid_fraction": float(validity.mean()), - "depth_alpha_valid_fraction": float((combined_alpha >= 0.9).mean()), - "seconds": time.perf_counter() - start, - "distance_range": [float(distance.min()), float(distance.max())]} - if not args.image: - ratio = np.median(truth[validity] / distance[validity]) - stats["reference_scale_alignment"] = float(ratio) - stats["aligned_mean_relative_error"] = float(np.mean(np.abs(distance[validity] * ratio / truth[validity] - 1))) - stats["aligned_seam_mean_relative_error"] = float(np.mean(np.abs(distance[:, [0, -1]] * ratio / truth[:, [0, -1]] - 1))) - (args.output / "inference.json").write_text(json.dumps(stats, indent=2), encoding="utf-8") - print(json.dumps(stats, indent=2), flush=True) - - -if __name__ == "__main__": - main() diff --git a/experiments/panorama_surface/quad_surface.py b/experiments/panorama_surface/quad_surface.py deleted file mode 100644 index 7e1daca..0000000 --- a/experiments/panorama_surface/quad_surface.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Build an offline Quad Sphere panorama asset with point-domain validity culling.""" - -from pathlib import Path -import sys - -import bpy - -sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -from tools.nodes.common.nodes import interface_socket, prepare_node_group - - -REFERENCE_LIBRARY = Path("D:/Projects/OmooNodes/subspaces/O_Essentials/O_Essentials_GeometryNodes.blend1") - - -def build_node_group(): - with bpy.data.libraries.load(str(REFERENCE_LIBRARY), link=False) as (source, target): - if "O Quad Sphere" not in source.node_groups: - raise ValueError("Reference library has no O Quad Sphere") - target.node_groups = ["O Quad Sphere"] - group = bpy.data.node_groups.new("Panorama Surface Trial", "GeometryNodeTree") - group.is_modifier = True - for name, kind in (("Subdivide", "NodeSocketInt"), ("Depth Scale", "NodeSocketFloat"), - ("Validity Threshold", "NodeSocketFloat"), - ("Distance", "NodeSocketImage"), ("Material", "NodeSocketMaterial")): - socket = interface_socket(group, name, "INPUT", kind) - if name == "Subdivide": - socket.default_value, socket.min_value, socket.max_value = 7, 0, 8 - elif name == "Depth Scale": - socket.default_value, socket.min_value, socket.max_value = 1.0, 0.0, 1.0 - socket.subtype = "FACTOR" - elif name == "Validity Threshold": - socket.default_value, socket.min_value, socket.max_value = 0.9, 0.0, 1.0 - socket.subtype = "FACTOR" - elif name == "Material": - socket.hide_in_modifier = True - interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") - links = group.links - - def node(kind, x, y): - result = group.nodes.new(kind) - result.location = (x, y) - return result - - def math(operation, a, b, x, y): - result = node("ShaderNodeMath", x, y) - result.operation = operation - for index, value in enumerate((a, b)): - if isinstance(value, (float, int)): - result.inputs[index].default_value = value - else: - links.new(value, result.inputs[index]) - return result.outputs[0] - - def store(geometry, name, field, domain, x, y): - result = node("GeometryNodeStoreNamedAttribute", x, y) - result.data_type, result.domain = "FLOAT_VECTOR", domain - result.inputs["Name"].default_value = name - links.new(geometry, result.inputs["Geometry"]) - links.new(field, result.inputs["Value"]) - return result.outputs["Geometry"] - - def read(name, x, y): - result = node("GeometryNodeInputNamedAttribute", x, y) - result.data_type = "FLOAT_VECTOR" - result.inputs["Name"].default_value = name - return result.outputs["Attribute"] - - inputs = node("NodeGroupInput", 0, 400) - segments = math("POWER", 2, inputs.outputs["Subdivide"], 0, 150) - segments = math("ADD", segments, 1, 180, 150) - sphere = node("GeometryNodeGroup", 400, 400) - sphere.node_tree = target.node_groups[0] - sphere.inputs["Radius"].default_value = 1 - links.new(segments, sphere.inputs["Segments"]) - position = node("GeometryNodeInputPosition", 400, 100) - geometry = store(sphere.outputs["Mesh"], "panorama_direction", position.outputs[0], "POINT", 620, 400) - xyz = node("ShaderNodeSeparateXYZ", 620, 100) - links.new(read("panorama_direction", 400, -80), xyz.inputs[0]) - angle = math("ARCTAN2", xyz.outputs["Y"], xyz.outputs["X"], 800, 100) - longitude = math("DIVIDE", angle, 6.283185307179586, 980, 100) - longitude = math("FRACT", math("ADD", longitude, 1, 1160, 100), 0, 1340, 100) - latitude = math("ARCCOSINE", xyz.outputs["Z"], 0, 800, -100) - latitude = math("SUBTRACT", 1, math("DIVIDE", latitude, 3.141592653589793, 980, -100), 1160, -100) - uv = node("ShaderNodeCombineXYZ", 1540, 100) - links.new(longitude, uv.inputs["X"]) - links.new(latitude, uv.inputs["Y"]) - geometry = store(geometry, "panorama_sample_uv", uv.outputs[0], "POINT", 1540, 400) - - # Each face chooses its own longitude branch; pole corners use the face longitude. - face_position = node("GeometryNodeFieldOnDomain", 800, -400) - face_position.data_type, face_position.domain = "FLOAT_VECTOR", "FACE" - links.new(position.outputs[0], face_position.inputs["Value"]) - face_xyz = node("ShaderNodeSeparateXYZ", 980, -400) - links.new(face_position.outputs[0], face_xyz.inputs[0]) - face_angle = math("ARCTAN2", face_xyz.outputs["Y"], face_xyz.outputs["X"], 1160, -400) - face_u = math("DIVIDE", face_angle, 6.283185307179586, 1340, -400) - correction = math("ROUND", math("SUBTRACT", face_u, longitude, 1540, -300), 0, 1720, -300) - corner_u = math("ADD", longitude, correction, 1900, -300) - pole = math("GREATER_THAN", math("ABSOLUTE", xyz.outputs["Z"], 0, 1540, -520), 0.999999, 1720, -520) - choose_u = node("GeometryNodeSwitch", 2080, -300) - choose_u.input_type = "FLOAT" - links.new(pole, choose_u.inputs["Switch"]) - links.new(corner_u, choose_u.inputs["False"]) - links.new(face_u, choose_u.inputs["True"]) - corner_uv = node("ShaderNodeCombineXYZ", 2300, -300) - links.new(choose_u.outputs[0], corner_uv.inputs["X"]) - links.new(latitude, corner_uv.inputs["Y"]) - geometry = store(geometry, "UVMap", corner_uv.outputs[0], "CORNER", 2300, 400) - - samples = node("NodeGroupInput", 2520, -150) - sample_uv = read("panorama_sample_uv", 2520, -400) - texture = node("GeometryNodeImageTexture", 2740, 0) - texture.interpolation, texture.extension = "Closest", "EXTEND" - links.new(samples.outputs["Distance"], texture.inputs["Image"]) - links.new(sample_uv, texture.inputs["Vector"]) - depth = node("ShaderNodeSeparateXYZ", 2960, 0) - links.new(texture.outputs["Color"], depth.inputs[0]) - blend = node("ShaderNodeMapRange", 3180, 0) - blend.clamp = True - blend.inputs["To Min"].default_value = 1 - links.new(inputs.outputs["Depth Scale"], blend.inputs["Value"]) - links.new(depth.outputs["X"], blend.inputs["To Max"]) - radial = node("ShaderNodeVectorMath", 3400, 0) - radial.operation = "SCALE" - links.new(read("panorama_direction", 3180, -260), radial.inputs[0]) - links.new(blend.outputs["Result"], radial.inputs[3]) - deform = node("GeometryNodeSetPosition", 3620, 400) - links.new(geometry, deform.inputs["Geometry"]) - links.new(radial.outputs[0], deform.inputs["Position"]) - - channels = node("FunctionNodeSeparateColor", 2960, -430) - channels.mode = "RGB" - links.new(texture.outputs["Color"], channels.inputs["Color"]) - invalid = node("FunctionNodeCompare", 3400, -500) - invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN" - links.new(channels.outputs["Alpha"], invalid.inputs["A"]) - links.new(inputs.outputs["Validity Threshold"], invalid.inputs["B"]) - point_invalid = node("GeometryNodeFieldOnDomain", 3620, -500) - point_invalid.data_type, point_invalid.domain = "BOOLEAN", "POINT" - links.new(invalid.outputs["Result"], point_invalid.inputs["Value"]) - delete = node("GeometryNodeDeleteGeometry", 3840, 400) - delete.domain, delete.mode = "FACE", "ALL" - links.new(deform.outputs["Geometry"], delete.inputs["Geometry"]) - links.new(point_invalid.outputs[0], delete.inputs["Selection"]) - material = node("GeometryNodeSetMaterial", 4060, 400) - links.new(delete.outputs["Geometry"], material.inputs["Geometry"]) - links.new(inputs.outputs["Material"], material.inputs["Material"]) - output = node("NodeGroupOutput", 4280, 400) - links.new(material.outputs["Geometry"], output.inputs[0]) - prepare_node_group(group) - return group diff --git a/experiments/panorama_surface/test_geometry.py b/experiments/panorama_surface/test_geometry.py deleted file mode 100644 index 6139925..0000000 --- a/experiments/panorama_surface/test_geometry.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Behavior checks with independent spherical and room geometry references.""" - -import numpy as np - -from geometry import (camera_bases, merge_distances, panorama_directions, - room_reference, sample_panorama, view_rays) -from prototype import fixed_fov_distance - - -def test_fixed_fov_recovers_shift_and_metric_distance(): - rays = view_rays(64) - depth = 2 + 0.3 * rays[..., 0] - points = rays * depth[..., None] - points[..., 2] -= 0.7 - distance, mask = fixed_fov_distance(points, np.ones((64, 64)), 1.5) - np.testing.assert_allclose(distance, depth * np.linalg.norm(rays, axis=-1) * 1.5, rtol=1e-5) - assert mask.all() - - -def test_longitude_sampling_wraps_without_a_seam(): - directions = panorama_directions(256, 128) - image = 2 + directions[..., 0] - rays = np.array(((1, 1e-8, 0), (1, -1e-8, 0))) - sampled = sample_panorama(image, rays) - np.testing.assert_allclose(sampled[0], sampled[1], atol=1e-6) - np.testing.assert_allclose(sampled, 3, atol=0.001) - - -def test_fusion_removes_per_view_scale_offsets(): - bases = camera_bases() - scales = np.exp(np.linspace(-0.4, 0.4, 12)) - distances = [] - for scale, basis in zip(scales, bases): - rays = view_rays(128) @ basis - rays /= np.linalg.norm(rays, axis=-1, keepdims=True) - distances.append((3 + 0.4 * rays[..., 0] + 0.2 * rays[..., 2]) * scale) - merged, mask = merge_distances(distances, np.ones((12, 128, 128), bool), bases, 64, 32) - rays = panorama_directions(64, 32) - expected = 3 + 0.4 * rays[..., 0] + 0.2 * rays[..., 2] - merged *= np.median(expected / merged) - assert mask.all() - assert np.mean(np.abs(merged / expected - 1)) < 0.002 - assert np.max(np.abs(merged[:, [0, -1]] / expected[:, [0, -1]] - 1)) < 0.005 - - -def test_room_distance_lands_on_floor(): - rays = np.array(((0.0, 0.0, -1.0), (0.2, -0.3, -1.0))) - rays /= np.linalg.norm(rays, axis=-1, keepdims=True) - distance, _ = room_reference(rays) - np.testing.assert_allclose((rays * distance[:, None])[:, 2], -1.6) - - -def test_sky_view_returns_invalid_samples(): - distance, mask = fixed_fov_distance(np.ones((32, 32, 3)), np.zeros((32, 32)), 1) - assert not mask.any() - assert np.isfinite(distance).all() - - -def test_room_projection_fusion_preserves_geometry(): - bases = camera_bases() - distances = [room_reference(view_rays(256) @ basis)[0] for basis in bases] - merged, mask = merge_distances(distances, np.ones((12, 256, 256), bool), bases, 128, 64) - expected, _ = room_reference(panorama_directions(128, 64)) - assert mask.all() - assert np.mean(np.abs(merged / expected - 1)) < 0.005 - - -def test_input_alpha_survives_panorama_export(tmp_path, monkeypatch): - from PIL import Image - from prototype import main - - rgba = np.full((16, 32, 4), 255, dtype=np.uint8) - rgba[:, :16, 3] = 0 - source = tmp_path / "source.png" - destination = tmp_path / "result" - Image.fromarray(rgba).save(source) - monkeypatch.setattr("sys.argv", ["prototype", "--image", str(source), "--output", str(destination), - "--reference-only", "--size", "32", "--width", "32"]) - main() - with Image.open(destination / "panorama.png") as exported: - np.testing.assert_array_equal(np.asarray(exported)[..., 3], rgba[..., 3]) - with np.load(destination / "prediction.npz") as data: - assert np.all(data["alpha"][:, :16] == 0) - assert np.any(data["alpha"][:, 16:] > 0) - - -def test_depth_alpha_combines_mask_and_source_alpha_once(): - from prototype import depth_alpha - - image = np.ones((2, 4, 4), dtype=np.float32) - image[..., 3] = (0, 0.5, 0.75, 1) - mask = np.full((2, 4), 0.5, dtype=np.float32) - np.testing.assert_allclose(depth_alpha(image, mask), np.tile((0, 0.25, 0.375, 0.5), (2, 1))) diff --git a/experiments/panorama_surface/validation.py b/experiments/panorama_surface/validation.py deleted file mode 100644 index 426304b..0000000 --- a/experiments/panorama_surface/validation.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Verify saved panorama assets using synthetic validity and depth inputs.""" - -import json - -import bpy -import numpy as np - - -def socket_id(group, name): - return next(item.identifier for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == name) - - -def evaluated_mesh(obj): - obj.update_tag() - bpy.context.view_layer.update() - evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) - return evaluated, evaluated.to_mesh() - - -def evaluated_points(obj): - evaluated, mesh = evaluated_mesh(obj) - points = np.empty(len(mesh.vertices) * 3, dtype=np.float32) - mesh.vertices.foreach_get("co", points) - counts = (len(mesh.vertices), len(mesh.edges), len(mesh.polygons)) - evaluated.to_mesh_clear() - return points.reshape(-1, 3), counts - - -def test_image(name, alpha=1): - image = bpy.data.images.new(name, width=16, height=8, alpha=True, float_buffer=True) - image.colorspace_settings.name = "Non-Color" - image.alpha_mode = "CHANNEL_PACKED" - pixels = np.full((8, 16, 4), 3, dtype=np.float32) - pixels[..., 3] = alpha - image.pixels.foreach_set(pixels.ravel()) - image.update() - return image - - -def verify(output): - full, empty, partial = test_image("Full"), test_image("Empty", 0), test_image("Combined Alpha", 0.25) - half = test_image("Half") - pixels = np.full((8, 16, 4), 3, dtype=np.float32) - pixels[..., 3] = 1 - pixels[:, :8, 3] = 0 - half.pixels.foreach_set(pixels.ravel()) - half.update() - results = {} - try: - for obj in list(bpy.data.objects): - if obj.type != "MESH" or not obj.modifiers: - continue - modifier = obj.modifiers[0] - group = modifier.node_group - image_inputs = [item.name for item in group.interface.items_tree if item.item_type == "SOCKET" - and item.in_out == "INPUT" and item.socket_type == "NodeSocketImage"] - assert image_inputs == ["Distance"] - names = ("Subdivide", "Depth Scale", "Validity Threshold", "Distance") - keys = {name: socket_id(group, name) for name in names} - original = {name: modifier[key] for name, key in keys.items()} - - def set_inputs(**values): - for name, value in values.items(): - modifier[keys[name.replace("_", " ")]] = value - - try: - set_inputs(Distance=full, Depth_Scale=0, Validity_Threshold=0.9) - face_counts = [] - for level in (0, 2, 4): - set_inputs(Subdivide=level) - evaluated, mesh = evaluated_mesh(obj) - try: - assert len(mesh.polygons) == 6 * 4 ** level - assert all(len(face.vertices) == 4 for face in mesh.polygons) - assert len(mesh.vertices) - len(mesh.edges) + len(mesh.polygons) == 2 - use = np.zeros(len(mesh.edges), dtype=int) - for loop in mesh.loops: - use[loop.edge_index] += 1 - assert np.all(use == 2) - points = np.array([vertex.co[:] for vertex in mesh.vertices]) - np.testing.assert_allclose(np.linalg.norm(points, axis=1), 1, atol=1e-5) - if level >= 2: - uv = mesh.attributes["UVMap"].data - assert all(np.ptp([uv[i].vector.x for i in face.loop_indices]) < 0.51 - for face in mesh.polygons), "UV seam crosses the full image" - face_counts.append(len(mesh.polygons)) - finally: - evaluated.to_mesh_clear() - sphere, _ = evaluated_points(obj) - set_inputs(Depth_Scale=1) - surface, _ = evaluated_points(obj) - np.testing.assert_allclose(surface, sphere * 3, atol=1e-5) - set_inputs(Depth_Scale=0.5) - middle, _ = evaluated_points(obj) - np.testing.assert_allclose(middle, sphere * 2, atol=1e-5) - set_inputs(Distance=empty) - assert evaluated_points(obj)[1][2] == 0 - set_inputs(Distance=half) - retained = evaluated_points(obj)[1][2] - assert 0 < retained < face_counts[-1] - set_inputs(Distance=partial, Validity_Threshold=0.3) - assert evaluated_points(obj)[1][2] == 0, "Mask times alpha must be below threshold" - set_inputs(Validity_Threshold=0.2) - assert evaluated_points(obj)[1][2] == face_counts[-1] - finally: - for name, value in original.items(): - modifier[keys[name]] = value - obj.update_tag() - points, counts = evaluated_points(obj) - assert np.isfinite(points).all() - assert counts[2] > 0 - evaluated, mesh = evaluated_mesh(obj) - try: - directions = np.empty((counts[0], 3), dtype=np.float32) - uv = np.empty_like(directions) - mesh.attributes["panorama_direction"].data.foreach_get("vector", directions.ravel()) - mesh.attributes["panorama_sample_uv"].data.foreach_get("vector", uv.ravel()) - expected_u = np.arctan2(directions[:, 1], directions[:, 0]) / (2 * np.pi) % 1 - delta_u = np.abs(uv[:, 0] - expected_u) - assert np.max(np.minimum(delta_u, 1 - delta_u)) < 1e-6 - np.testing.assert_allclose(uv[:, 1], 1 - np.arccos(np.clip(directions[:, 2], -1, 1)) / np.pi, atol=1e-6) - image = original["Distance"] - width, height = image.size - pixels = np.array(image.pixels[:]).reshape(height, width, 4) - data_path = output / ("reference.npz" if obj.name == "Reference Room" else "prediction.npz") - with np.load(data_path) as data: - np.testing.assert_allclose(pixels[::-1, :, 3], data["alpha"], atol=0.0005) - x = np.clip((uv[:, 0] * width).astype(int), 0, width - 1) - y = np.clip((uv[:, 1] * height).astype(int), 0, height - 1) - radius = 1 + original["Depth Scale"] * (pixels[y, x, 0] - 1) - np.testing.assert_allclose(points, directions * radius[:, None], rtol=2e-6, atol=2e-5) - finally: - evaluated.to_mesh_clear() - results[obj.name] = {"subdivide_levels": [0, 2, 4], "quad_face_counts": face_counts, - "closed_quad_sphere": True, "uv_seam_verified": True, - "depth_scale_verified": True, "mask_alpha_verified": True, - "image_sampling_verified": True, - "single_depth_image_verified": True, - "packed_depth_alpha_verified": True, - "vertices": counts[0], "faces": counts[2]} - assert results - finally: - for image in (full, empty, partial, half): - bpy.data.images.remove(image) - (output / "blender_validation.json").write_text(json.dumps(results, indent=2), encoding="utf-8") - print(json.dumps(results, indent=2), flush=True) diff --git a/openspec/changes/refine-cutout-split-boundaries/.openspec.yaml b/openspec/changes/refine-cutout-split-boundaries/.openspec.yaml new file mode 100644 index 0000000..515eaae --- /dev/null +++ b/openspec/changes/refine-cutout-split-boundaries/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-11 diff --git a/openspec/changes/refine-cutout-split-boundaries/design.md b/openspec/changes/refine-cutout-split-boundaries/design.md new file mode 100644 index 0000000..834d418 --- /dev/null +++ b/openspec/changes/refine-cutout-split-boundaries/design.md @@ -0,0 +1,61 @@ +## Context + +本设计对应 proposal 中的 Cutout 切口行为,基于当前工作区已有的边界深度平滑。实图实验位于 `.tmp/skull-mask-check`;正式测试使用可重建的合成几何,实图用于视觉验收。 + +采用 `split_round_trial.py` 的 DELETE 分支中的三角面删除与厚度轮廓计算作为边界处理参考,平滑沿用当前公共实现;采用 `live_contact_comparison.py` 中独立的厚度关联法向混合逻辑。最新共点修复虽然降低了拓扑异常计数,用户截图中的视觉接触仍然存在,因此该实验不能作为视觉问题已解决的依据。 + +## Goals / Non-Goals + +**Goals:** 保留参数实时求值,改善新切口的平滑、厚度与圆润过渡,并使薄厚度下的法向平滑影响连续趋零。 + +**Non-Goals:** 按用户决定,暂缓共点/共线修复与空间接触消除;不引入 Valid Only、Validity Threshold 或实验用 Repair Contacts 控件。 + +## Decisions + +### 求值顺序 + +```mermaid +flowchart TD + A[原网格与原轮廓参照] --> B[实时 Split] + B --> C[一次删除全边界三角面] + C --> D[现有边界深度平滑与投影] + D --> E[现有边界网格平滑] + E --> F[新切口厚度轮廓收敛] + F --> G[按厚度混合法向] + G --> H[厚度与 Edge Round 成形] + H --> I[现有前后表面连接和输出] +``` + +### 边界三角面清理 + +在 Split 输出的当前拓扑上,以 Point 域边界标记转换至 Face 域,选出三个顶点全部位于边界的三角面,一次删除后再投影和平滑。使用一次清理保留实验中已经认可的形状,避免递归删除持续侵蚀轮廓。 + +清理后使用现有 Boundary Smooth。`Boundary Smooth = 0` 直接跳过平滑,保留既有深度平滑控制语义。验收时比较清理后、平滑前后的面朝向,确认已观察到的锯齿翻面问题得到改善。 + +### 新切口的厚度与圆润过渡 + +在清理后的网格上识别新边界,使用明确的原几何参照保护原 Alpha 轮廓。实验通过 UV 空间最近点采样原边界标记;正式实现需覆盖新旧边界交接处,确保内部切口不会误判为原轮廓。 + +沿新边界计算 UV 距离 `d`,过渡宽度 `w = 0.025 + 0.035 * Edge Round`;保留比例 `r = sqrt(clamp(d / w, 0, 1))`。新边界取零,保护点取一,更新厚度轮廓为 `o_balloon * r`。没有新边界时直接保留原轮廓字段,避免空目标距离产生错误衰减。 + +在现有厚度、中心面与圆润计算之前更新轮廓,使新切口成为前后表面的零厚度交汇线。复用现有前后表面成形链,并验证最终交汇处实际厚度,不能只检查 `o_balloon` 数值。Edge Round 增大时过渡带加宽,内部厚度逐渐恢复;原轮廓保持现有规则。 + +`o_balloon` 是现有 Point 域 FLOAT 厚度协议,此处在当前前表面写入、Selection 为全部点,由后续厚度和圆润消费者读取。其余字段优先直接连接;跨几何采样明确参照几何及域,保持 Capture Attribute 为零。实验诊断属性不进入正式资产。 + +### 厚度关联法向平滑 + +保留 Normal Smooth 迭代数,以厚度控制原法向与 Blur 结果的混合,再 Normalize:`normalize(mix(raw_normal, blurred_normal, smoothstep(0, 1, t)))`。 + +Balloon 模式 `t = Thickness`;Uniform 模式 `t = Thickness / max(Reference Depth, 1e-8)`。应用于前表面、中心面及膨胀方向的三处法向平滑消费位置,各自在原有源几何和域上求值。混合始终启用,保留用户 Normal Smooth 参数;厚度达到归一化值 1 后恢复完整 Blur 结果。薄厚度减弱的是平滑额外产生的方向偏差,不能据此承诺所有前后表面完全重合。 + +### 参数与资产 + +Split Threshold、Boundary Smooth、Thickness、Normal Smooth、Edge Round 保留现有接口与实时输入。清理与轮廓计算全部在节点求值阶段完成。正式资产使用项目现有布局与加载版本机制,按实施时基线更新版本。 + +实现完成后运行相关测试和 `uv run node-group build`,更新源码、测试、资产并独立加载验证。当前提案阶段只产出 OpenSpec 文件。 + +## Risks / Trade-offs + +- 全边界三角面可能构成细小真实结构 → 用窄条、尖角与孔洞检查一次清理的形状代价。 +- UV 距离过渡在不同长宽比和网格密度下外观不同 → 覆盖长宽图及多个 Mesh Detail,保持归一化 UV 语义。 +- 拓扑计数正常仍可能有空间接触或交叉 → 本次验收以已确认的切口形状改善为目标,保留用户指出的共点问题记录。 diff --git a/openspec/changes/refine-cutout-split-boundaries/proposal.md b/openspec/changes/refine-cutout-split-boundaries/proposal.md new file mode 100644 index 0000000..1c77370 --- /dev/null +++ b/openspec/changes/refine-cutout-split-boundaries/proposal.md @@ -0,0 +1,25 @@ +## Why + +Depth Cutout 的 Split 新边界仍继承原 Balloon 厚度,容易膨胀成尖脊;Boundary Smooth 在锯齿三角形上可能翻面,进一步改变厚度方向。恐龙头骨实图实验已确认边界厚度收敛、切口圆润和薄厚度下减弱法向平滑的效果,需要将这些行为整理为正式节点实现。 + +## What Changes + +- Split 后清理三个顶点都位于边界的三角面,降低锯齿切口的退化风险。 +- 新切口的 Balloon 厚度在边界收敛为零,邻近区域逐渐恢复;Edge Round 同时控制新切口的圆润过渡。 +- 法向平滑结果按厚度连续混合:薄厚度接近原法向,正常厚度恢复既有平滑效果,默认始终启用。 +- 保留 Split Threshold、Boundary Smooth 及厚度、Edge Round 的实时调节,参数变化后重新计算边界与形状。 + +## Capabilities + +### New Capabilities + +- `cutout-split-boundary-shaping`:切口清理、边界厚度收敛、圆润过渡及厚度关联法向平滑。 + +### Modified Capabilities + +## Impact + +- `tools/nodes/groups/image_depth_cutout.py` 及必要的 `tools/nodes/common` 构建函数。 +- `tests/tools/nodes` 的 Cutout、边界平滑及属性生命周期测试。 +- `src/anyimage/assets/O_AnyImage.blend` 中的 Cutout 节点资产及对应加载版本。 +- 基于当前工作区的边界深度平滑继续实施,保留已有修改。 diff --git a/openspec/changes/refine-cutout-split-boundaries/specs/cutout-split-boundary-shaping/spec.md b/openspec/changes/refine-cutout-split-boundaries/specs/cutout-split-boundary-shaping/spec.md new file mode 100644 index 0000000..0a6dc11 --- /dev/null +++ b/openspec/changes/refine-cutout-split-boundaries/specs/cutout-split-boundary-shaping/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Split boundary triangle cleanup + +Depth Cutout SHALL 在 Split 后、投影与边界平滑前,一次删除三个顶点均在当前边界上的三角面。 + +#### Scenario: Remove a boundary ear +- **WHEN** Split 输出包含三个边界顶点的三角面及包含内部顶点的相邻三角面 +- **THEN** 前者被删除,后者保留,清理不递归传播 + +### Requirement: Boundary smoothing after cleanup + +Depth Cutout SHALL 在三角面清理后的几何上沿用现有 Boundary Smooth,保留其参数语义。 + +#### Scenario: Smooth the cleaned boundary +- **WHEN** 全边界三角面清理完成且 Boundary Smooth 大于 0 +- **THEN** 现有平滑在保留几何上求值,已删除三角面不参与平滑 + +#### Scenario: Disable boundary smoothing +- **WHEN** Boundary Smooth 为 0 +- **THEN** 深度及网格平滑步骤保持各自输入值,其他切口形状处理继续按当前参数求值 + +### Requirement: Rounded split thickness profile + +Depth Cutout SHALL 使新切口的 Balloon 厚度轮廓在边界为零,在邻近区域连续恢复,并使 Edge Round 影响新切口的圆润过渡。 + +#### Scenario: Form a new meeting boundary +- **WHEN** Split 产生内部切口并执行 Balloon 厚度成形 +- **THEN** 新切口前后表面在零厚度边界交汇,影响带外保留原厚度轮廓 + +#### Scenario: Adjust edge rounding +- **WHEN** 用户增加 Edge Round +- **THEN** 新切口的过渡带随之加宽,最终几何呈现圆润过渡的变化,原 Alpha 轮廓继续使用既有规则 + +#### Scenario: No new boundary +- **WHEN** 当前 Split 结果没有新增边界 +- **THEN** 切口轮廓步骤保留原厚度字段 + +### Requirement: Thickness weighted normal smoothing + +Depth Cutout SHALL 始终按归一化厚度的 smoothstep 权重混合原法向与平滑法向,再归一化;保留 Normal Smooth 对平滑迭代数的控制。 + +#### Scenario: Thin thickness +- **WHEN** Balloon Thickness 趋于 0,或 Uniform Thickness 与 Reference Depth 的比例趋于 0 +- **THEN** 法向平滑引入的方向偏差连续趋于 0,结果保持有限 + +#### Scenario: Full smoothing strength +- **WHEN** 归一化厚度达到或超过 1 +- **THEN** 使用完整的既有法向平滑结果 + +#### Scenario: Normal smoothing disabled +- **WHEN** Normal Smooth 为 0 +- **THEN** 混合结果等于对应消费几何上的原法向归一化结果 + +### Requirement: Live cutout controls + +Depth Cutout SHALL 在每次参数变更后重新求值 Split、边界平滑和厚度成形,保留现有参数接口。 + +#### Scenario: Edit the loaded modifier +- **WHEN** 用户加载节点资产后连续修改 Split Threshold、Boundary Smooth 和 Edge Round +- **THEN** 几何按新参数实时更新,恢复相同参数可重现相同结果,不依赖冻结的中间网格 + +### Requirement: Loadable node asset + +本功能 SHALL 随正式节点资产交付,所有相关节点组及测试辅助图保持 Capture Attribute 为零,并保留用户属性。 + +#### Scenario: Independently load built asset +- **WHEN** 在独立 Blender 进程加载重建资产 +- **THEN** 接口、动态形状求值和必要属性均正确,输出不含实验诊断或已结束生命周期的内部临时属性 diff --git a/openspec/changes/refine-cutout-split-boundaries/tasks.md b/openspec/changes/refine-cutout-split-boundaries/tasks.md new file mode 100644 index 0000000..6fa491b --- /dev/null +++ b/openspec/changes/refine-cutout-split-boundaries/tasks.md @@ -0,0 +1,23 @@ +## 1. 切口清理 + +- [x] 1.1 核对当前 Cutout 与公共边界平滑调用链,在 Split 后接入一次全边界三角面清理,保留现有工作区修改。 +- [x] 1.2 在清理后的几何上沿用现有边界平滑,检查域求值和 Boundary Smooth 为 0 时的行为。 +- [x] 1.3 用锯齿、窄条、孔洞合成网格验证一次删除范围,并检查清理后平滑的面朝向,覆盖 Boundary Smooth 0、5、12、20。 + +## 2. 切口厚度与圆润 + +- [x] 2.1 区分原 Alpha 轮廓与新切口,在明确源几何上求值 UV 距离过渡并更新现有 o_balloon 字段,处理无新边界分支。 +- [x] 2.2 将过渡轮廓接入厚度和 Edge Round 消费链,验证最终交汇厚度、过渡带外保持及新旧边界交接。 +- [x] 2.3 覆盖 Edge Round 0、0.2、0.6,不同图像长宽比、Mesh Detail 与 Balloon/Uniform 模式,检查有限结果及原轮廓行为。 + +## 3. 厚度关联法向 + +- [x] 3.1 在三处法向平滑消费位置接入始终启用的厚度结果混合,保留 Normal Smooth 迭代数和现有源几何语义。 +- [x] 3.2 添加薄厚度趋零、归一化厚度 1 还原完整平滑、Normal Smooth 0 和 Reference Depth 零值保护的自动化回归。 + +## 4. 资产与验收 + +- [x] 4.1 整理节点布局和最少属性生命周期,检查源码与测试辅助图 Capture 为零、保留用户属性并清理临时字段。 +- [x] 4.2 运行相关 Cutout、边界平滑、属性及加载测试,执行 `uv run node-group build`,更新并验证正式资产及加载版本。 +- [x] 4.3 独立加载资产,连续调整并恢复 Split Threshold、Boundary Smooth、厚度与 Edge Round,检查实时更新及结果可重现。 +- [x] 4.4 使用已缓存恐龙头骨数据做边界形状验收,覆盖 Split Threshold 0、0.25、0.5、1 和 Boundary Smooth 0、5、12、20,记录 High 网格求值耗时;厚度关联效果以自动化回归确认。 diff --git a/openspec/changes/remove-geometry-capture-attributes/.openspec.yaml b/openspec/changes/remove-geometry-capture-attributes/.openspec.yaml new file mode 100644 index 0000000..515eaae --- /dev/null +++ b/openspec/changes/remove-geometry-capture-attributes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-11 diff --git a/openspec/changes/remove-geometry-capture-attributes/design.md b/openspec/changes/remove-geometry-capture-attributes/design.md new file mode 100644 index 0000000..f5647bc --- /dev/null +++ b/openspec/changes/remove-geometry-capture-attributes/design.md @@ -0,0 +1,70 @@ +## Context + +本次只读核对了当前构建源码和保存资产。以下为组内节点数量,嵌套引用单独追踪,实施前须重新确认基线。 + +| 几何节点组 | Capture | Store | +| --- | ---: | ---: | +| O Image Plane | 1 | 2 | +| O Image Depth Plane | 6 | 0 | +| O Image Relief Plane | 0 | 0 | +| O Image Cutout | 0 | 1 | +| O Image Depth Cutout | 8 | 3 | +| O Image Depth Balloon | 0 | 1 | +| O Image Depth Panorama | 6 | 1 | +| 合计 | 21 | 8 | + +Depth Plane、Relief Plane 引用 Image Plane。三个材质组作为命名属性消费者纳入核查。工作区已有节点逻辑、测试和资产修改,基线采用实施开始时的实际工作区状态。 + +## Goals / Non-Goals + +目标是全部几何组及其嵌套组 Capture 为零、字段消费关系清晰、存储具有实际必要性,源码、资产、规范与测试一致。保持公开接口、几何效果、UV、材质及用户输入属性行为;交付包括节点资产构建与测试。 + +## Decisions + +### 字段在消费位置求值 + +```mermaid +flowchart TD + A[字段及实际消费者] --> B{需要按名称访问} + B -->|是| C[保留必要命名属性] + B -->|否| D{消费上下文求值等价} + D -->|是| E[字段直连并显式转换域] + D -->|否| F[调整消费顺序或在源几何采样] + F --> G{仍需保存源数据} + G -->|否| E + G -->|是| H[仅存储必要命名数据] + H --> I[最后消费者后清理临时属性] +``` + +Capture Attribute 在生产构建、嵌套组、保存资产和相关测试辅助图中均禁止使用。多个消费者、跨区域或连线较长不构成存值理由。Evaluate on Domain 用于域转换;它不固定几何版本。源几何采样必须明确源几何、域与索引对应关系,并同时评估布局和求值开销。 + +每处保留的 Store 记录名称、类型、域、Selection、写入阶段、实际读者和必要性。跨变形或拓扑变化仍需保存时,先减少携带的数据,再使用必要命名属性;不按现有 Capture 的字段清单逐项替换 Store。内部临时属性使用专用前缀,在最后消费者后清理;避免覆盖或删除已有用户属性,必要时通过名称冲突检查与更具体的命名解决。 + +### 按业务链拆解现有捕获 + +| 范围 | 当前用途 | 实施方向 | +| --- | --- | --- | +| boundary_smoothing、smoothing;三个深度组共 12 个 Capture | 原边界、影响范围、循环边界、源索引及最近边界映射 | 拓扑不变时直接消费 Boundary 和 Influence;Panorama 的闭合初始球面使用 False 表示原边界。其他原边界及固定映射逐项审查源几何采样或最小命名数据,保持原轮廓保护和循环映射语义 | +| depth_surface 的切分;3 个 Capture | Camera、Cut 跨 Split Edges 与删面传播 | 分开审查面采样与切分标记,在源几何计算或采样,保持 FACE、CORNER、POINT 转换顺序;实际需要传播的最少信息才存值 | +| depth_surface 的厚度;2 个 Capture | Position、Vertex、Normal 跨展平、挤出、合并、恢复 | 优先简化厚度构建顺序和源表面采样,保持独立重叠表面身份及零厚度行为;论证剩余源身份或数据的必要存储 | +| image_depth_cutout;2 个 Capture | center、front_normal、structure、inflation_normal、edge_weight、front_delta、thickness、normal | 前表面计算尽量在原消费几何完成,保留独立中心表面供采样;逐字段核对圆化和壳体消费者,合并可复用的源数据,保持 Balloon、Uniform 两分支行为 | +| material;2 个 Capture | Source 标记 Join 后待删除的输入网格 | 改为可明确验证的来源选择或材质传递流程,优先由几何数量及分支关系得到选择;验证空输入、多材质和生成几何保留 | +| 现有 8 次 Store | UVMap、o_normal_scale | 保留命名协议,审查重复写入合并机会,保持角 UV、正背面与侧壁掩码 | + +表中替代路径是待求值验证的候选。对于跨拓扑数据,实施中以真实结果确认直接求值是否等价;结构可读性和语义一致性共同决定最终方案。Capture 为零是硬性条件。 + +### 规范和测试一起纠正 + +更新 AGENTS.md、docs/internals/node-assets.md 及实际受影响的当前内部说明。规范明确禁止 Capture,描述直连、显式域转换、源几何采样、必要命名存储及清理规则,流程图从上到下。旧变更文件保留历史记录,本提案作为后续实施依据。 + +资产清单测试同时检查构建定义与保存资产,递归遍历嵌套几何组并去重;断言 Capture 为零。删除 capture_fields 辅助函数及测试用法,替换 test_depth_relief_planes、test_boundary_smoothing 中强制 Capture 的断言。原测试只允许 UVMap、o_normal_scale 的 Store 名称白名单应按经审查的必要存储更新,不把数量下降作为行为正确的替代。 + +复用真实求值用例,覆盖域转换、非均匀深度、断层、原边界保护、平滑影响范围与固定映射、厚度、Balloon/Uniform、法线、UV、材质和用户属性。布局通过完整图检查区域关系、连线跨度、交叉、节点重叠和标题可读性;零重叠仅是其中一项。 + +## Risks / Trade-offs + +- 直连改变求值几何或属性版本 → 使用非均匀输入和跨阶段场景对比实施前基线,明确每处采样域。 +- 变形后最近邻映射漂移、重叠表面串值 → 优先稳定索引对应,验证循环平滑与独立重叠表面。 +- 源几何采样增加连线或重复计算 → 结合逐组图审查及代表场景耗时选择简单方案;必要命名存储需逐项证明。 +- 临时命名属性冲突或清理影响用户数据 → 明确名称与生命周期,测试输入用户属性保留及临时属性消失。 +- 资产与源码不同步 → 执行 uv run node-group build,独立加载保存资产验证,再运行 uv run pytest 并确认正常退出。 diff --git a/openspec/changes/remove-geometry-capture-attributes/proposal.md b/openspec/changes/remove-geometry-capture-attributes/proposal.md new file mode 100644 index 0000000..79ae949 --- /dev/null +++ b/openspec/changes/remove-geometry-capture-attributes/proposal.md @@ -0,0 +1,30 @@ +## Why + +之前的属性整理将大量 Store Named Attribute 替换为 Capture Attribute,保留了中间值存储并增加跨阶段连线,偏离了直接消费字段、提高结构可读性的要求。当前七个几何节点组共有 21 个 Capture;规范与部分测试还在强制沿用这一方向,需要一并纠正。 + +## What Changes + +- 全面检查全部几何节点、嵌套组与共享构建函数,禁止使用 Capture Attribute。 +- 优先让字段在消费位置直接求值,显式表达必要域转换;通过调整处理顺序或源几何采样减少中间存储。 +- 逐项论证 Store Named Attribute 的必要性:保留实际命名协议,以及确实需要跨几何变化保存的最少数据;禁止机械替换 Capture。 +- 更新 AGENTS.md、节点资产规范和相关当前实现说明,统一禁止 Capture、优先直连的规则。 +- 更新测试,移除依赖 Capture 的结构断言及测试辅助用法,检查全部资产与嵌套几何组中 Capture 为零,并验证真实几何行为。 +- 重建节点资产,核对布局、接口、UV、材质与几何结果。 + +## Capabilities + +### New Capabilities + +- `geometry-field-flow`: 几何节点字段直连、必要命名存储、禁止 Capture 及对应资产验证规则。 + +### Modified Capabilities + +无。 + +## Impact + +- tools/nodes/common、tools/nodes/groups,以及实际受影响的排列代码。 +- tests/tools/nodes、tests/support 及相关材质、Operator 测试。 +- src/anyimage/assets/O_AnyImage.blend。 +- AGENTS.md、docs/internals/node-assets.md 及检索确认受影响的当前内部说明。 +- 公开接口、几何功能与属性协议保持一致;实施时保留工作区已有修改。该提案取代 simplify-node-attribute-flow 中关于 Capture 的设计决策。 diff --git a/openspec/changes/remove-geometry-capture-attributes/specs/geometry-field-flow/spec.md b/openspec/changes/remove-geometry-capture-attributes/specs/geometry-field-flow/spec.md new file mode 100644 index 0000000..33512b4 --- /dev/null +++ b/openspec/changes/remove-geometry-capture-attributes/specs/geometry-field-flow/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Geometry graphs prohibit Capture Attribute + +全部生产几何节点定义、嵌套几何组、保存资产及相关测试辅助图 MUST 不使用 GeometryNodeCaptureAttribute。 + +#### Scenario: Inspect definitions and saved assets +- **WHEN** 分别构建节点定义并加载保存资产,递归检查全部几何组及嵌套组 +- **THEN** 每个已访问几何组的 Capture Attribute 数量均为零 + +### Requirement: Fields are consumed directly when evaluation is equivalent + +字段 SHALL 在消费位置直接连接,明确必要域转换;跨几何变化时 SHALL 优先审查调整消费顺序及源几何采样,以减少中间存储。多个消费者或跨区域连线 MUST 不作为存值的充分理由。 + +#### Scenario: Reuse a field without geometry changes +- **WHEN** 多个消费者所在几何状态、域、类型与输入属性版本允许等价求值 +- **THEN** 消费者直接使用字段及必要域转换,保持原结果而不新增存储 + +#### Scenario: Consume a field after topology changes +- **WHEN** 切分、删面、挤出或合并改变源字段求值上下文 +- **THEN** 实现明确源几何与映射,验证结果等价,并仅保存无法通过合理调整消费或采样消除的必要数据 + +### Requirement: Named storage has an explicit purpose and lifetime + +每处 Store Named Attribute SHALL 有明确的实际消费者与存储必要性,记录名称、类型、域、Selection 和阶段。必要命名协议 SHALL 保留;临时属性 SHALL 在最后消费者后清理,并保留已有用户属性。 + +#### Scenario: Preserve material protocols +- **WHEN** 输出几何供材质消费 UVMap 或 o_normal_scale +- **THEN** 属性域与数值保持正确,包括角 UV 以及正背面和侧壁掩码 + +#### Scenario: Finish consuming temporary data +- **WHEN** 几何处理完成临时命名数据的最后一次消费 +- **THEN** 最终输出不携带新增内部临时属性,且输入已有用户属性的名称、类型和值按原行为保留 + +### Requirement: Geometry behavior and graph readability are preserved + +重构 SHALL 保持修改器接口、几何结果、UV 和材质行为,并逐组检查字段消费关系与完整节点布局的可读性。 + +#### Scenario: Validate depth processing +- **WHEN** 使用非均匀深度测试 Split 开关、零与正厚度、独立重叠表面、Balloon/Uniform、圆化、膨胀及法线平滑 +- **THEN** 实际几何与实施前行为基线等价,索引重排按拓扑和对应几何数据比较 + +#### Scenario: Validate cut boundary smoothing and panorama +- **WHEN** 对原轮廓、切分边界、平滑迭代和 Panorama 接缝、极点、无效像素及 Valid Only 求值 +- **THEN** 原轮廓保护、影响范围、固定映射、球面采样与 UV 保持预期行为 + +#### Scenario: Inspect material inheritance +- **WHEN** 输入为空或带有多个材质槽,执行 Image Plane 或 Panorama 的材质继承 +- **THEN** 材质槽及面材质索引符合原行为,生成几何保留且输入来源几何正确处理 + +### Requirement: Rules tests and saved assets agree + +项目规范及当前节点资产说明 SHALL 明确禁止 Capture 并采用一致的字段传递规则。测试 SHALL 覆盖结构约束与真实行为;节点资产 SHALL 重建并经过独立加载验证,相关测试及全量测试正常退出后完成交付。 + +#### Scenario: Deliver a geometry graph change +- **WHEN** 本次节点整理完成 +- **THEN** 源码、规范、测试和 O_AnyImage.blend 同步,Capture 旧结构断言已替换,uv run node-group build 与 uv run pytest 成功且正常退出 diff --git a/openspec/changes/remove-geometry-capture-attributes/tasks.md b/openspec/changes/remove-geometry-capture-attributes/tasks.md new file mode 100644 index 0000000..378d14f --- /dev/null +++ b/openspec/changes/remove-geometry-capture-attributes/tasks.md @@ -0,0 +1,29 @@ +## 1. 基线与规范 + +- [x] 1.1 核对实施时工作区已有修改、七个几何组及嵌套引用,记录 Capture/Store 数量、各存值字段的域、阶段、实际消费者和必要性;运行现有节点测试,记录基线及已有失败。 +- [x] 1.2 保存代表场景的几何、UV、材质索引与属性行为基线,覆盖深度切分、厚度、边界平滑、重叠表面、两种 Cutout 模式与 Panorama。 +- [x] 1.3 更新 AGENTS.md、docs/internals/node-assets.md 及实际受影响的当前内部说明,统一禁止 Capture、字段直连、显式域转换、源几何采样与必要命名存储规则;核对当前规则一致。 + +## 2. 几何节点整理 + +- [x] 2.1 整理原边界与 Influence:Panorama 原边界使用闭合源面的常量语义,拓扑不变的字段直接求值,其余保护数据选择必要传递方式;验证原轮廓保护与平滑影响范围。 +- [x] 2.2 整理平滑循环的 Boundary、SourceIndex、BoundaryIndex,明确稳定源几何与映射,移除 Capture;验证迭代前后映射、边界运动和拓扑行为。 +- [x] 2.3 整理共用深度切分的 Camera、Cut,保持面采样、角域及点域转换与切分前后语义;验证非均匀深度、Split 开关和删面。 +- [x] 2.4 整理厚度构建的源位置、顶点身份及法线,简化几何阶段或源采样,论证剩余必要命名数据;验证零厚度、封口、法线及独立重叠表面。 +- [x] 2.5 整理 Depth Cutout 的中心、结构、圆化、厚度和膨胀字段,减少跨阶段存值;验证 Balloon/Uniform、厚度连续性、Edge Round、Front Inflation 和 Normal Smooth。 +- [x] 2.6 整理材质继承的来源选择,移除 Source 捕获;验证 Image Plane、Panorama 的空输入、多材质槽、材质索引及生成几何保留。 +- [x] 2.7 完成所有几何组和三个材质消费者的复查,保留 UVMap、o_normal_scale 等必要协议,逐项审查重复 Store;清理临时命名数据并验证用户属性保留。 +- [x] 2.8 删除 capture_fields 及失去用途的辅助函数、导入和节点相关支持项;沿实际调用链确认旧实现引用消失,使用明确节点或 socket 引用接线。 + +## 3. 测试与可读性 + +- [x] 3.1 将 Capture 禁用检查加入构建定义及保存资产测试,递归遍历嵌套几何组并去重;更新 Store 名称规则以反映逐项批准的必要存储。 +- [x] 3.2 替换 test_depth_relief_planes、test_boundary_smoothing 中强制 Capture 的断言,移除 test_depth_cutout 等测试辅助图中的 Capture 用法,保留有效行为覆盖与状态隔离。 +- [x] 3.3 补齐本次受影响的域转换、边界固定映射、临时属性清理和用户属性保留测试;运行相关节点、材质及 Operator 测试并修复回归。 +- [x] 3.4 逐组检查完整节点图的消费关系、跨区连线、交叉、重叠和接口显示,按需调整布局;验证排列前后实际连接与求值一致。 + +## 4. 资产与交付 + +- [x] 4.1 运行 uv run node-group build,更新 O_AnyImage.blend,完成保存后独立 Blender 加载与几何验证,确认全部几何组及嵌套组 Capture 为零。 +- [x] 4.2 运行 uv run pytest 并确认正常退出;对比实施前行为基线,检查接口、几何、UV、材质及用户属性。 +- [x] 4.3 检查最终差异、旧引用与当前规范一致性,交付逐组调整和保留 Store 的必要性清单、数量及验证结果;确认源码、规范、测试和资产同步,保留已有其他修改,不构建文档或打包扩展。 diff --git a/openspec/changes/remove-geometry-capture-attributes/validation.md b/openspec/changes/remove-geometry-capture-attributes/validation.md new file mode 100644 index 0000000..1c5d91c --- /dev/null +++ b/openspec/changes/remove-geometry-capture-attributes/validation.md @@ -0,0 +1,52 @@ +# 几何节点核查 + +## 实施基线 + +- 基线采用实施开始时工作区源码,保留已有无效点清理、边界平滑、模型目录与粘贴预览等修改。 +- 节点基线测试:295 项通过,正常退出。 +- 保存 41 个场景、236 组数组,包含 Balloon/Uniform、Split、零与正厚度、法线平滑、膨胀、四边面与三角面边界平滑、Depth/Relief Plane、Panorama 有效性及重叠表面。 +- 本地核查文件:build/attributes-capture-before.json、build/geometry-capture-before.npz;实现后的对应文件以 capture-after 命名。 + +## 存储决策 + +| 范围 | 最终实现与必要性 | +| --- | --- | +| 原边界 | Depth Plane 直接消费 UV 矩形边缘,Panorama 使用闭合源面的 False;Depth Cutout 的任意输入轮廓跨切分后无法从当前拓扑恢复,保留一个 POINT/BOOLEAN 标记 | +| 平滑 Boundary、Influence | 直接由不变拓扑和受保护轮廓计算,不保存字段 | +| 平滑 SourceIndex、BoundaryIndex | 分别分离源边界与当前边界,保持相同紧凑索引顺序;用原网格按 Index 采样的位置查询源边界,再按结果索引消费当前边界位置,不保存索引 | +| 切分 Camera | 面相机采样在当前面直接求值,保持显式 FACE 域,不保存 Camera | +| 切分 Cut | 原顶点受切分影响的标记跨分裂及删面使用,每个深度组保留一个 CORNER/BOOLEAN 标记;完成投影后精确删除 | +| 厚度 Position、Vertex、Normal | 将源索引编码进构造网格坐标后再挤出,按整数解码后的索引从独立源表面采样位置、厚度与法线,不保存字段;保留独立重叠表面身份 | +| Depth Cutout 中心、结构、圆化、膨胀字段 | 直接在投影源表面求值并按壳体源索引采样;仅厚度构建需要的圆化厚度额外在对应前表面索引上求值 | +| 材质 Source | 按输入网格顶点数量和 Join 的实际输入顺序识别待删除范围,不保存标记;验证空输入、多材质槽与排列前后结果 | +| UVMap | Image Plane 两个分支、Panorama 各有实际输出 UV 写入,共 3 次,CORNER/FLOAT2,材质按名称消费 | +| o_normal_scale | Image Cutout、Depth Balloon 各一次 POINT/FLOAT;Depth Cutout 原面 1、合并背面与侧壁后 0 两次 FACE/FLOAT,共 4 次;形状和材质按名称消费 | + +所有保留 Store 的 Selection 均为 True。新增临时名称为 `.o_anyimage_depth_cut`、`.o_anyimage_original_boundary`,清理按完整名称执行;用户的其他属性,包括同前缀属性,保留原行为。 + +## 数量 + +| 节点组 | Capture 前 → 后 | Store 前 → 后 | +| --- | --- | --- | +| O Image Plane | 1 → 0 | 2 → 2 | +| O Image Depth Plane | 6 → 0 | 0 → 1 | +| O Image Relief Plane | 0 → 0 | 0 → 0 | +| O Image Cutout | 0 → 0 | 1 → 1 | +| O Image Depth Cutout | 8 → 0 | 3 → 4 | +| O Image Depth Balloon | 0 → 0 | 1 → 1 | +| O Image Depth Panorama | 6 → 0 | 1 → 2 | +| 合计 | 21 → 0 | 8 → 11 | + +按节点组自身计数,嵌套 Image Plane 在递归结构检查中去重。11 次 Store 由 7 次实际输出协议写入和 4 次必要拓扑标记写入组成。 + +## 验证结果 + +- 41 个场景的顶点位置、带方向的面、角 UV、材质索引和法线掩码与基线一致;面按顶点坐标及角 UV 对应比较,允许输出索引重排。 +- 源码和保存资产递归检查 Capture 为零;相关测试辅助图中已无 Capture。 +- 已修正全量测试发现的旧模型名称断言,匹配工作区当前模型名称。 +- 已刷新本地 BlendJob 0.1.17 wheel 的锁文件哈希;版本、依赖声明及 Manifest 保持一致。 +- uv run node-group build 成功且正常退出:Blender 4.5.10 LTS 重建资产,独立进程重新加载并验证全部 10 个节点组,递归几何组 Capture 为零。 +- 构建命令串行调用的全量 pytest:1237 项通过,耗时 221.33 秒,正常退出;覆盖新增跨 4096 索引行的厚度采样、临时属性精确清理、多材质继承、空输入和排列前后求值。 +- 逐组检查最终布局、字段连接与共享源几何路径,7 个几何组均无节点重叠,排列前后行为及保存布局稳定性测试通过;交互预览位于 build/capture-nodes.html。 +- git diff --check 与 OpenSpec 严格校验通过。源码检索中的 GeometryNodeCaptureAttribute 仅存在于禁止该节点的检查断言。 +- 源码、规范、测试与资产同步完成,未构建文档或打包扩展。 diff --git a/openspec/changes/unify-boundary-depth-smoothing/.openspec.yaml b/openspec/changes/unify-boundary-depth-smoothing/.openspec.yaml new file mode 100644 index 0000000..515eaae --- /dev/null +++ b/openspec/changes/unify-boundary-depth-smoothing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-11 diff --git a/openspec/changes/unify-boundary-depth-smoothing/design.md b/openspec/changes/unify-boundary-depth-smoothing/design.md new file mode 100644 index 0000000..2a2d6c9 --- /dev/null +++ b/openspec/changes/unify-boundary-depth-smoothing/design.md @@ -0,0 +1,72 @@ +## Context + +当前三个深度表面均在投影后执行 Boundary Smooth。Depth Cutout 保护原 Alpha 轮廓的三维位置,Depth Plane 平滑有效性剔除形成的新边界,Depth Panorama 平滑球面的实际开口。 + +独立实验复用了猫、幼虫和人物的已有深度数据。Cutout 的六个网格在 4、12 次深度 Blur 后明显减少尖刺;Depth Plane 在 Subdivide 4、6 和 Boundary Smooth 5、12 下也有改善。两轮实验均验证关闭还原、拓扑保持和厚度/Split 组合。全景尚需专项验证。 + +实验文件位于本机临时目录 `anyimage-boundary-depth-trial`:`boundary-smooth-comparison.blend`、`convert-boundary-comparison.blend` 及对应指标 JSON。正式回归测试使用可重建的合成数据;实图用于人工验收。 + +## Goals + +- 三个已有 Boundary Smooth 的深度表面使用同一标量平滑规则和参数语义。 +- 在边界窄带降低采样深度突变,保留内部数据及对应投影方向。 +- 通过现有公共节点构建模块复用实现,并更新可直接加载的节点资产。 + +## Decisions + +### 求值顺序 + +```mermaid +flowchart TD + A[基础网格与深度采样] --> B[有效性剔除] + B --> C[Split 与切口深度处理] + C --> D[在当前连通网格上 Blur 深度标量] + D --> E[按边界两圈影响权重混合] + E --> F[沿原投影方向置换] + F --> G[现有 Boundary Smooth 网格平滑] + G --> H[厚度与后续形状处理] +``` + +共享实现位于 `tools/nodes/common/boundary_smoothing.py`,输入深度标量和边界保护字段,输出平滑后的标量。字段在剔除和 Split 后、置换前的 Point 域求值,使已删区域及已分离表面按当前拓扑隔离。各组负责把标量接入自身的投影表达式。 + +### 参数与影响范围 + +`Boundary Smooth` 同时给深度 Blur 和现有网格平滑提供迭代数,默认 `5`、范围 `0–20`,保持已有 socket 标识。实验中的独立 `Boundary Depth Blur` 输入收敛为现有控制项。 + +深度 Blur 使用 `FLOAT`、固定 Weight `0.5`。令 `B` 为可处理的边界点字段,`P` 为保护字段: + +- 在当前网格上对 `B` 执行 2 次、Weight `1` 的 Blur。 +- 应用权重为 `max(B, blurred_B) * (1 - P)`,外边界完全应用,邻近两圈逐渐减弱。 +- 原深度与平滑深度按该权重混合。 +- 迭代数为 `0` 或权重为 `0` 时直接返回原采样值,保证关闭及影响范围外的精确保持。 + +深度计算和影响权重使用共享字段连接。`Smooth Weight` 延续现有网格平滑职责,深度 Blur 的固定 Weight 与独立实验一致。 + +### 三个节点组的边界与投影 + +| 节点组 | 深度 Blur 影响边界 | 标量与重建方式 | +| --- | --- | --- | +| O Image Depth Cutout | 原 Alpha 轮廓及 Split 后的新边界 | 平滑相机 Z;按新旧 Z 比例缩放原相机点,再进入现有投影 | +| O Image Depth Plane | 有效性剔除及 Split 后的新边界;保护 UV 原矩形外框 | 平滑相机 Z;按原相机射线重建点,再进入现有投影 | +| O Image Depth Panorama | 球面有效性剔除及 Split 后产生的实际开口 | 平滑有效径向距离;保持球面方向,再进入 Depth Scale 的半径表达式 | + +Cutout 原轮廓的深度可以变化,后续网格平滑继续使用现有原轮廓保护字段。这样保留图像投影轮廓,同时允许轮廓深度变平顺。Depth Plane 的矩形外框保护同时约束影响起点和最终权重。 + +Panorama 的经度接缝和极点依照真实球面拓扑求值;UV 接缝本身不形成平滑边界。有效距离平滑在 `Invalid Distance` 替代分支之前接入,保留区域的无效距离继续由该参数决定。无效区域与有效区域之间沿现有剔除逻辑断开混合面,验证其不会参与有效边界的平均。 + +相机 Z 重建必须处理零值及不可用于比例计算的采样,直接保留对应原点值,保证结果有限。投影方向保持的断言放在深度平滑/置换阶段;后续网格平滑仍按既有规则移动顶点。 + +### 节点与资产组织 + +公共函数以标量字段为复用边界,三个消费组分别完成相机与球面重建。使用显式域转换及明确源几何采样,所有几何组与嵌套组保持 Capture Attribute 为零。直接连接可复用字段;必要的现有临时命名协议在最后消费者后清理,保留用户属性。 + +实施时读取当前工作区基线,保留其他修改。完成源码与测试后运行 `uv run node-group build`,更新 `O_AnyImage.blend` 并独立加载验证接口、求值和节点布局。 + +三个消费组的资产版本统一设为 12,运行时通过 `load_node_group(..., asset_revision=12)` 请求。加载器保留当前文件中旧组及其现有使用者,为新建对象读取新版本;对应 Operator 回归覆盖旧组存在时的行为。 + +## Risks / Trade-offs + +- 稀疏网格的两圈覆盖范围较宽,较高迭代数可能软化真实轮廓深度 → 保留默认值 5,并用 Subdivide 4、6 对比 5、12 的效果。 +- 深度 Blur 与后置网格平滑叠加 → 分别检查置换阶段的射线保持、最终尖刺改善和整体形状,不把梯度下降单独视为几何更准确。 +- 模型 validity 开口可能位于物体内部 → 使用内部孔洞、真实深度断层与孤立尖刺的合成用例,检查影响范围及不同连通表面的隔离。 +- 球面方向和无效距离与相机 Z 语义不同 → Panorama 使用径向适配,覆盖经度接缝、极点、Invalid Distance 和 Valid Only 两个分支后再交付。 diff --git a/openspec/changes/unify-boundary-depth-smoothing/proposal.md b/openspec/changes/unify-boundary-depth-smoothing/proposal.md new file mode 100644 index 0000000..079bc9b --- /dev/null +++ b/openspec/changes/unify-boundary-depth-smoothing/proposal.md @@ -0,0 +1,27 @@ +## Why + +Alpha 轮廓和有效性剔除留下的网格边界仍可能采到突变深度,形成尖刺。独立 Blender 实验已验证:在置换前平滑边界采样深度,再执行现有边界网格平滑,可明显改善 Depth Cutout,并进一步改善 Depth Plane。 + +## What Changes + +- 为 `O Image Depth Cutout`、`O Image Depth Plane`、`O Image Depth Panorama` 统一加入置换前的边界深度平滑。 +- 复用 `Boundary Smooth` 控制深度 Blur 与现有边界网格平滑的迭代数,保留默认值 `5`、范围 `0–20`;`0` 完全关闭两者。 +- 在有效性剔除及 Split 后的连通网格上平滑深度标量,仅应用于可处理边界及邻近两圈,并保持各顶点原投影方向。 +- Cutout 的原 Alpha 轮廓参与深度平滑;Depth Plane 保留原矩形外框保护;Panorama 按球面径向距离处理实际网格边界。 +- 复用公共构建函数,补齐行为测试、全景专项验证与节点资产重建。 + +## Capabilities + +### New Capabilities + +- `boundary-depth-smoothing`:三个深度表面的统一参数、边界选择、采样阶段、投影保持及资产验证。 + +### Modified Capabilities + +## Impact + +- `tools/nodes/common/boundary_smoothing.py` 及三个深度表面节点构建模块。 +- `tests/tools/nodes` 中的边界平滑、深度表面、Cutout 和全景行为测试。 +- `src/anyimage/assets/O_AnyImage.blend` 中对应节点组及必要的资产版本标识。 +- Cutout、Depth Plane 和 Panorama 的资产读取入口:新建对象请求本次节点版本,保留场景内旧组的现有使用者。 +- 使用现有 Blender 节点与依赖;深度平滑在几何节点求值阶段完成。 diff --git a/openspec/changes/unify-boundary-depth-smoothing/specs/boundary-depth-smoothing/spec.md b/openspec/changes/unify-boundary-depth-smoothing/specs/boundary-depth-smoothing/spec.md new file mode 100644 index 0000000..3e7ee58 --- /dev/null +++ b/openspec/changes/unify-boundary-depth-smoothing/specs/boundary-depth-smoothing/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: Shared Boundary Smooth control + +`O Image Depth Cutout`、`O Image Depth Plane`、`O Image Depth Panorama` SHALL 使用同一 `Boundary Smooth` 输入控制边界深度 Blur 和现有边界网格平滑的迭代数,默认值为 5,范围为 0–20,并保留该输入的既有标识。 + +#### Scenario: Positive iteration count +- **WHEN** 用户将任一组的 Boundary Smooth 设为 12 +- **THEN** 深度 Blur 和现有边界网格平滑均使用 12 次迭代 +- **AND** 修改器只提供现有 Boundary Smooth 控制项 + +#### Scenario: Disabled smoothing +- **WHEN** Boundary Smooth 为 0 +- **THEN** 输出几何与两种边界平滑均关闭的基线一致 +- **AND** 深度采样直接使用原值 + +### Requirement: Topology-local scalar smoothing + +系统 SHALL 在有效性剔除及 Split 后、投影前的网格 Point 域对深度标量执行 Weight 为 0.5 的 Blur,并在可处理边界及邻近两圈内应用结果。边界影响权重 SHALL 由边界字段的 2 次 Weight 为 1 的 Blur 与原边界字段取最大值后,排除保护点得到。 + +#### Scenario: Isolated boundary spike +- **WHEN** 原本平缓的表面在一个可处理边界点具有深度尖刺并启用 Boundary Smooth +- **THEN** 深度突变通过当前网格邻域的标量平均减弱 +- **AND** 深度平滑阶段的顶点、边、面及 UV 拓扑保持不变 + +#### Scenario: Interior preservation +- **WHEN** 顶点位于影响范围之外 +- **THEN** 深度平滑阶段直接保留其原采样值 + +#### Scenario: Deleted and separated samples +- **WHEN** 邻近像素所属网格已被有效性剔除,或邻接表面已由 Split 分离 +- **THEN** 平滑仅使用当前连通网格的样本,不跨已删区域或分离表面平均 + +### Requirement: Cutout silhouette depth smoothing + +Depth Cutout SHALL 将原 Alpha 轮廓和 Split 后的新边界纳入深度平滑,并保留现有后置网格平滑的原轮廓保护规则。 + +#### Scenario: Fine Outline with zero thickness +- **WHEN** Fine Outline 的原轮廓存在异常深度,Thickness 为 0 且 Boundary Smooth 大于 0 +- **THEN** 原轮廓点的深度参与平滑 +- **AND** 深度平滑后的相机投影轮廓在浮点容差内保持一致 + +### Requirement: Depth Plane rectangle protection + +Depth Plane SHALL 对有效性剔除或 Split 形成的可处理边界应用深度平滑,并同时从边界起点和最终应用权重中排除原 UV 矩形外框。 + +#### Scenario: Alpha or model-validity opening +- **WHEN** Valid Only 因 Alpha 或模型 validity 删除网格并形成新开口 +- **THEN** 新开口及邻近两圈参与深度平滑 +- **AND** 保留下来的原矩形外框点保持既有保护行为 + +#### Scenario: Intact rectangle +- **WHEN** Valid Only 和 Split 均关闭,网格仅有原矩形外边界 +- **THEN** 新增深度 Blur 对输出不产生变化 + +### Requirement: Projection-aware reconstruction + +Cutout 和 Depth Plane SHALL 在深度平滑阶段保持原相机射线,通过平滑后的 Z 重建相机点;Depth Panorama SHALL 平滑径向距离并保持原球面方向。方向保持以现有后置网格平滑之前的结果为准。 + +#### Scenario: Camera depth reconstruction +- **WHEN** 正的有效相机 Z 被平滑 +- **THEN** 新旧相机点的 X/Z、Y/Z 在浮点容差内一致 + +#### Scenario: Degenerate camera depth +- **WHEN** 原采样 Z 无法安全用于比例重建 +- **THEN** 保留原采样,不因深度平滑产生非有限值 + +#### Scenario: Panorama seam and poles +- **WHEN** 有效表面跨越经度接缝或覆盖极点 +- **THEN** 深度 Blur 使用实际球面邻接,不将 UV 接缝当作网格开口 +- **AND** 径向重建保持每个点的球面方向 + +#### Scenario: Retained invalid panorama region +- **WHEN** Panorama 保留无效区域并使用 Invalid Distance +- **THEN** 无效区域继续使用指定距离 +- **AND** 无效替代距离不混入有效边界的深度平均 + +### Requirement: Integrated geometry and asset verification + +系统 SHALL 保持厚度、Split、Depth Scale、UV、材质和用户属性的既有职责,所有几何节点及嵌套组 MUST 保持 Capture Attribute 为零。更新后的节点源码、行为测试及保存资产 SHALL 一致。 + +#### Scenario: Surface modes and downstream shaping +- **WHEN** 三个深度表面组合使用边界平滑与自身支持的 Split、Depth Scale、厚度或 Cutout 模式 +- **THEN** 输出为有效几何,属性和材质传递符合原有协议 +- **AND** 深度平滑不改变有效性剔除与 Split 的拓扑判定 + +#### Scenario: Rebuilt asset +- **WHEN** 执行节点资产构建并在独立 Blender 进程中重新加载保存资产 +- **THEN** 资产与源码构建结果通过相同的行为验证 +- **AND** 公共参数、节点连接和 Capture 禁用检查通过 diff --git a/openspec/changes/unify-boundary-depth-smoothing/tasks.md b/openspec/changes/unify-boundary-depth-smoothing/tasks.md new file mode 100644 index 0000000..c9ae834 --- /dev/null +++ b/openspec/changes/unify-boundary-depth-smoothing/tasks.md @@ -0,0 +1,28 @@ +## 1. 实施基线 + +- [x] 1.1 检查当前工作区修改、三个节点组的参数标识和调用顺序,保留已有工作;运行相关边界与深度节点测试,记录基线。 +- [x] 1.2 复查独立实验的 Cutout 和 Depth Plane 对比,准备可重建的尖刺、内部孔洞、深度断层及全景接缝/极点合成场景。 + +## 2. 公共深度平滑 + +- [x] 2.1 在 common/boundary_smoothing.py 中实现 Point 域标量 Blur、两圈影响权重及保护字段,复用现有节点辅助函数。 +- [x] 2.2 实现迭代数为 0 和影响权重为 0 时的原值直通,验证内部精确保持、拓扑不变及分离表面的采样隔离。 + +## 3. 节点组接入 + +- [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.4 三组共用 Boundary Smooth 迭代数,保留默认 5、范围 0–20 和既有 socket 标识;更新对应界面描述并精简重复连接。 + +## 4. 回归与实图验收 + +- [x] 4.1 为源码构建和保存资产补齐零值直通、边界尖刺、两圈外保持、零深度、相机射线/球面方向及原矩形保护的行为测试。 +- [x] 4.2 覆盖 Alpha 与模型 validity 开口、Split 隔离、Cutout 两种模式、零/正厚度及 Depth Scale;检查几何、UV、材质与用户属性传递。 +- [x] 4.3 对三张既有实图复验 Cutout 与 Depth Plane,比较 Boundary Smooth 5、12 及不同密度;检查尖刺改善和过度软化,并完成全景专项验收。 +- [x] 4.4 递归检查几何组及嵌套组 Capture 为零,审查命名属性的必要性与清理位置,确认节点布局可读。 + +## 5. 资产与交付 + +- [x] 5.1 运行 uv run node-group build,更新 O_AnyImage.blend 和必要资产版本标识,在独立 Blender 进程中加载并验证。 +- [x] 5.2 运行相关节点及 Operator 测试,确认正常退出;核对源码与资产的一致性、最终差异和旧引用,保留已有其他修改。 diff --git a/openspec/changes/update-pasted-brush-preview/.openspec.yaml b/openspec/changes/update-pasted-brush-preview/.openspec.yaml new file mode 100644 index 0000000..e8cda9e --- /dev/null +++ b/openspec/changes/update-pasted-brush-preview/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-10 diff --git a/openspec/changes/update-pasted-brush-preview/design.md b/openspec/changes/update-pasted-brush-preview/design.md new file mode 100644 index 0000000..da98363 --- /dev/null +++ b/openspec/changes/update-pasted-brush-preview/design.md @@ -0,0 +1,38 @@ +## Context + +`set_tool_texture()` 先取得可编辑笔刷,再绑定图片纹理、设置 STENCIL 和颜色。外部库笔刷通过 `asset_save_as` 创建并激活本地副本;已有可编辑笔刷直接复用。剪贴板图片已打包,加载时的临时文件随即删除。 + +## Goals + +在 Texture Paint 粘贴流程中,让当前笔刷预览与绑定图片一致,保持图片比例,并随当前文件保存。 + +## Decisions + +### 在纹理绑定成功后更新本地笔刷预览 + +更新入口保留在 `clipboard_image/actions.py`,以 `PAINT_TEXTURE` 判断模式,使用 `_ensure_editable_brush()` 返回的笔刷。所有 Blender 数据操作在 Operator 主线程阶段完成。 + +```mermaid +flowchart TD + A[取得已打包的粘贴图片] --> B[取得可编辑笔刷] + B --> C[绑定纹理并设置 STENCIL] + C --> D{Texture Paint 模式} + D -->|是| E[从图片导出临时预览文件] + E --> F[为当前笔刷加载自定义预览] + F --> G[清理临时文件并刷新界面] + D -->|否| G +``` + +### 使用 Blender 自定义预览加载能力 + +复用 `common` 中适合的图片导出函数,生成临时 PNG,通过覆盖上下文的 `id` 指向目标笔刷,调用 `bpy.ops.ed.lib_id_load_custom_preview`。由 Blender 生成缩略图,保持比例与透明区域。预览加载完成后清理临时文件,预览数据由笔刷保存。 + +### 单独处理预览更新失败 + +预览更新与已有纹理绑定异常处理分开。加载失败时保留已绑定纹理和可用笔刷,通过现有 Operator 报告渠道提示预览更新失败。临时文件在成功与失败路径均清理。 + +## Risks / Trade-offs + +- 自定义预览 Operator 的上下文和界面缓存可能影响可见效果 → 在项目支持的 Blender 版本中验证当前笔刷选择器、资产架与切换笔刷后的显示。 +- 图片导出可能影响色彩或源数据 → 复用图片导出能力,验证透明图和非正方形图,确保源图片像素、尺寸及打包状态保持。 +- 预览保存行为需真实 Blender 验证 → 保存并重新打开 `.blend`,确认本地笔刷预览在临时文件清理后仍可显示。 diff --git a/openspec/changes/update-pasted-brush-preview/proposal.md b/openspec/changes/update-pasted-brush-preview/proposal.md new file mode 100644 index 0000000..84db356 --- /dev/null +++ b/openspec/changes/update-pasted-brush-preview/proposal.md @@ -0,0 +1,24 @@ +## Why + +Texture Paint 模式粘贴图片后,笔刷已使用该图片作为纹理,但图标仍显示原有预览,难以直接识别绘制内容。将笔刷图标同步为粘贴图片,让选择笔刷时的视觉提示与实际纹理一致。 + +## What Changes + +- Texture Paint 模式绑定粘贴图片后,同步更新当前可编辑笔刷的自定义预览。 +- 预览保持图片比例,支持已打包的剪贴板图片及重复粘贴更新。 +- 验证预览即时刷新,以及保存并重新打开文件后的保留效果。 + +## Capabilities + +### New Capabilities + +- `pasted-brush-preview`: Texture Paint 粘贴图片与笔刷预览同步。 + +### Modified Capabilities + +## Impact + +- `src/anyimage/operators/clipboard_image/actions.py`:笔刷纹理绑定与预览更新。 +- `src/anyimage/common/image.py`:复用现有图片读取或导出能力。 +- `tests/anyimage/operators/test_clipboard_image.py`:增加预览行为覆盖。 +- 使用 Blender 自定义预览 API,沿用现有依赖。 diff --git a/openspec/changes/update-pasted-brush-preview/specs/pasted-brush-preview/spec.md b/openspec/changes/update-pasted-brush-preview/specs/pasted-brush-preview/spec.md new file mode 100644 index 0000000..7e61dea --- /dev/null +++ b/openspec/changes/update-pasted-brush-preview/specs/pasted-brush-preview/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Texture Paint 粘贴同步笔刷预览 + +系统 SHALL 在 Texture Paint 模式成功绑定粘贴图片后,将当前可编辑笔刷的自定义预览更新为该图片,并刷新相关界面。 + +#### Scenario: 外部库笔刷首次粘贴 +- **WHEN** 当前笔刷来自外部库,用户在 Texture Paint 模式粘贴图片 +- **THEN** 创建并激活的本地笔刷副本使用该图片作为纹理和自定义预览 + +#### Scenario: 已有笔刷重复粘贴 +- **WHEN** 用户向当前可编辑笔刷粘贴另一张图片 +- **THEN** 同一笔刷的纹理和预览同步更新为新图片 + +#### Scenario: 其他绘制模式粘贴 +- **WHEN** 用户在 Sculpt 或 Vertex Paint 模式粘贴图片 +- **THEN** 系统沿用该模式现有纹理绑定行为并保留笔刷原预览 + +### Requirement: 预览使用已打包图片并保留显示效果 + +系统 SHALL 支持从已打包的剪贴板图片生成预览,保持图片宽高比和透明区域;源图片像素、尺寸及打包状态 MUST 保持。 + +#### Scenario: 非正方形透明图片 +- **WHEN** 用户粘贴已打包且原临时文件已删除的非正方形透明图片 +- **THEN** 笔刷预览展示完整图片,保持比例和透明区域,源图片数据保持 + +### Requirement: 预览随文件保存 + +笔刷自定义预览 SHALL 随当前 `.blend` 文件保存,在预览临时文件清理后仍可恢复。 + +#### Scenario: 保存后重新打开 +- **WHEN** 用户成功更新笔刷预览,保存并重新打开 `.blend` 文件 +- **THEN** 对应本地笔刷仍显示该图片预览 + +### Requirement: 预览失败保留可用纹理 + +预览更新失败时系统 SHALL 保留已绑定纹理并提示预览更新失败;预览临时文件 MUST 在成功或失败后清理。 + +#### Scenario: Blender 拒绝加载预览 +- **WHEN** 纹理已绑定但自定义预览加载失败 +- **THEN** 笔刷仍可使用粘贴纹理绘制,用户收到预览失败提示,临时文件被清理 diff --git a/openspec/changes/update-pasted-brush-preview/tasks.md b/openspec/changes/update-pasted-brush-preview/tasks.md new file mode 100644 index 0000000..fc219c1 --- /dev/null +++ b/openspec/changes/update-pasted-brush-preview/tasks.md @@ -0,0 +1,13 @@ +## 1. 笔刷预览更新 + +- [x] 1.1 在项目支持的 Blender 中验证以笔刷为上下文加载自定义预览的 API、图片比例及保存行为。 +- [x] 1.2 复用 common 图片导出能力,从已打包图片生成临时预览并更新笔刷,保证临时文件清理及源图片数据保持。 +- [x] 1.3 在 Texture Paint 纹理绑定成功后接入预览更新和界面刷新,独立处理预览失败提示并保留已绑定纹理。 + +## 2. 验证 + +- [x] 2.1 更新剪贴板测试,覆盖本地副本、已有笔刷重复粘贴、模式范围、预览失败和临时文件清理,运行相关测试。 +- [ ] 2.2 在真实 Blender 中验证非正方形透明图片、笔刷选择器与资产架刷新、切换笔刷及保存重开后的预览,并检查源图片数据保持。 +- [x] 2.3 检查最终差异与旧引用,确认改动集中于笔刷预览和相关测试。 + +验证记录:55 项测试、15 个子场景通过;Blender 4.5.10 后台验证了非正方形透明图片、源数据保持及保存重开后的预览。2.2 尚待界面目视检查笔刷选择器、资产架与切换笔刷后的显示,当前会话未提供 Blender 界面控制工具。测试使用 `uv run --no-sync`,因为本地 BlendJob wheel 与锁文件哈希不一致。 diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 958068e..e180f7c 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:36c4cc107a924038dc1553045834194061a072e30afea8596f3372d8286b70f6 -size 7032020 +oid sha256:2eb80fcf06db490cea283351adcda71680f8978412113047ea36ab554ca3a303 +size 8601131 diff --git a/src/anyimage/operators/clipboard_image/actions.py b/src/anyimage/operators/clipboard_image/actions.py index 0ddd3c0..c033fd5 100644 --- a/src/anyimage/operators/clipboard_image/actions.py +++ b/src/anyimage/operators/clipboard_image/actions.py @@ -6,7 +6,7 @@ import bpy from ...common.material import create_image_material from ...common.color_image import material_color_image -from ...common.image import image_pixels +from ...common.image import cleanup_image_input, image_pixels, save_packed_image CLIPBOARD_HASH_PROPERTY = "anyimage_clipboard_sha256" CLIPBOARD_CONTENT_PROPERTY = "anyimage_clipboard_content_sha256" @@ -49,6 +49,10 @@ class PasteTargetError(RuntimeError): pass +class BrushPreviewError(RuntimeError): + pass + + def target_for_context(context): area = getattr(context, "area", None) area_type = getattr(area, "type", None) @@ -367,10 +371,31 @@ def set_tool_texture(context, image): raise PasteTargetError( f"The active brush could not use the image texture: {error}" ) from error - _redraw_area(context) + try: + if getattr(context, "mode", "") == "PAINT_TEXTURE": + _set_brush_preview(context, brush, image) + finally: + _redraw_area(context) return texture +def _set_brush_preview(context, brush, image): + preview_path = None + try: + preview_path = save_packed_image(image, context.scene) + with context.temp_override(id=brush): + result = bpy.ops.ed.lib_id_load_custom_preview(filepath=str(preview_path)) + if result != {"FINISHED"}: + raise RuntimeError("Blender did not load the custom preview") + except (OSError, RuntimeError, ValueError) as error: + raise BrushPreviewError( + f"Texture pasted, but the brush preview could not be updated: {error}" + ) from error + finally: + if preview_path is not None: + cleanup_image_input(preview_path, True) + + def _set_unbiased_paint_color(context, brush): if getattr(context, "mode", "") not in WHITE_BRUSH_COLOR_MODES: return diff --git a/src/anyimage/operators/clipboard_image/operators.py b/src/anyimage/operators/clipboard_image/operators.py index 617df6c..f62e8e7 100644 --- a/src/anyimage/operators/clipboard_image/operators.py +++ b/src/anyimage/operators/clipboard_image/operators.py @@ -3,6 +3,7 @@ import sys import bpy from .actions import ( + BrushPreviewError, PasteTargetError, acquire_packed_image, paste_image, @@ -134,6 +135,9 @@ class PasteClipboardImage(bpy.types.Operator): import_as=self.import_as, shadeless=self.shadeless, ) + except BrushPreviewError as error: + self.report({"WARNING"}, str(error)) + return {"FINISHED"} except (OSError, RuntimeError, PasteTargetError) as error: if image is not None and not image_reused and image.users == 0: bpy.data.images.remove(image) diff --git a/src/anyimage/operators/convert_to_panorama/object.py b/src/anyimage/operators/convert_to_panorama/object.py index 3cf51e5..1be60c2 100644 --- a/src/anyimage/operators/convert_to_panorama/object.py +++ b/src/anyimage/operators/convert_to_panorama/object.py @@ -35,7 +35,7 @@ def create_panorama_from_result(context, result, operator): try: with material_color_image(input_path=operator.color_path) as color: metadata = load_panorama_metadata(result.file("depth_metadata")) - group = load_node_group("O Image Depth Panorama") + group = load_node_group("O Image Depth Panorama", asset_revision=14) 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") diff --git a/src/anyimage/operators/convert_to_plane/object.py b/src/anyimage/operators/convert_to_plane/object.py index 05c56e0..0b7a2cc 100644 --- a/src/anyimage/operators/convert_to_plane/object.py +++ b/src/anyimage/operators/convert_to_plane/object.py @@ -62,7 +62,7 @@ def create_depth_plane_object( node_group = load_node_group({ "DEPTH": "O Image Depth Plane", "RELIEF": "O Image Relief Plane", - }[plane_type]) + }[plane_type], asset_revision=14 if plane_type == "DEPTH" else None) source_name = source_object.name bounds = image_empty_bounds(source_object) mesh = create_image_plane_mesh(source_name, bounds) diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index 571c4b9..c93cf2b 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -99,7 +99,7 @@ def _assign_float_point_attribute(mesh, name, values): def load_cutout_node_group(shape): - return load_node_group(CUTOUT_NODE_GROUP_NAMES[shape], asset_revision=11) + return load_node_group(CUTOUT_NODE_GROUP_NAMES[shape], asset_revision=18 if shape == "DEPTH_SOLID" else 11) def _connect_cutout_normal_scale(material): diff --git a/src/anyimage/preferences.py b/src/anyimage/preferences.py index 1432083..98ec8f4 100644 --- a/src/anyimage/preferences.py +++ b/src/anyimage/preferences.py @@ -26,13 +26,13 @@ MAX_FRAME_RESOLUTION = 8192 ADDON_PACKAGE = __package__ DEFAULT_STORAGE_ROOT = Path.home() / ".anyimage" UPSCALE_MODEL_ITEMS = tuple( - (model.key, model.label, model.description, number) - for model, number in zip(UPSCALE_MODELS.values(), (0, 2, 4)) + (model.key, f"{tier} | {model.label}", model.description, number) + for model, tier, number in zip(UPSCALE_MODELS.values(), ("Fast", "Base", "Pro"), (0, 2, 4)) ) BACKGROUND_MODEL_ITEMS = tuple( - (model.key, model.label, model.description, number) - for model, number in zip(BACKGROUND_MODELS.values(), (1, 0, 2)) + (model.key, f"{tier} | {model.label}", model.description, number) + for model, tier, number in zip(BACKGROUND_MODELS.values(), ("Fast", "Base", "Pro"), (1, 0, 2)) ) BALLOON_PROFILE_ALGORITHM_ITEMS = ( diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index a460c85..266fbf4 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -122,11 +122,11 @@ def _model_items(models): return [ ( model["key"], - model["label"], + f"{tier} | {model['label']}", f"{model['description']}; license: {model['license']}", numbers[model["key"]], ) - for model in models + for model, tier in zip(models, ("Fast", "Base", "Pro")) ] diff --git a/src/anyimage/server/geometry/edge_depth.py b/src/anyimage/server/geometry/edge_depth.py deleted file mode 100644 index c158973..0000000 --- a/src/anyimage/server/geometry/edge_depth.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Extend reliable depth along each visible component's silhouette.""" - -from dataclasses import replace - - -def extend_edge_depth(frame, alpha, alpha_threshold=0.9): - """Return edge-corrected geometry while preserving reliable camera points.""" - import numpy as np - from scipy import ndimage - - from ..models.onnx_moge2 import depth_to_points - - depth = frame.depth - alpha = np.asarray(alpha, dtype=np.float32) - if alpha.shape != depth.shape: - raise ValueError("Edge Alpha must match the depth dimensions") - if np.all(alpha == 1.0): - return frame - - visible = (alpha > 0.0) & (alpha >= alpha_threshold) - opaque = visible & (alpha >= 0.95) - candidates = ( - opaque - & (frame.validity > 0.5) - & np.isfinite(depth) - & (depth > 0.0) - ) - if not candidates.any(): - return frame - interior_distance = ndimage.distance_transform_edt( - np.pad(opaque, 3, mode="edge") - )[3:-3, 3:-3] - interior = opaque & (interior_distance > 2.0) - core = candidates & interior - del interior_distance - labels, _ = ndimage.label(visible, structure=np.ones((3, 3), dtype=bool)) - corrected = depth.copy() - background_distance = np.full(depth.shape, np.inf, dtype=np.float32) - - for component_id, bounds in enumerate(ndimage.find_objects(labels), start=1): - region = tuple( - slice(max(0, bound.start - 7), min(size, bound.stop + 7)) - for bound, size in zip(bounds, depth.shape) - ) - component = labels[region] == component_id - donors = core[region] & component - if not donors.any(): - donors = candidates[region] & component - if not donors.any(): - continue - - indices = ndimage.distance_transform_edt( - ~donors, return_distances=False, return_indices=True - ) - extended = depth[region][tuple(indices)] - smoothed = ndimage.gaussian_filter(extended, sigma=0.8, radius=3) - distance = ndimage.distance_transform_edt(~component) - background = ( - ~visible[region] - & (distance <= 4.0) - & (distance < background_distance[region]) - ) - target = (component & ~interior[region] & ~donors) | background - corrected[region][target] = smoothed[target] - background_distance[region][background] = distance[background] - - changed = corrected != depth - if not changed.any(): - return frame - points = frame.points - if points is not None: - height, width = depth.shape - intrinsics = frame.intrinsics.copy() - intrinsics[0] /= width - intrinsics[1] /= height - projected = depth_to_points(corrected, intrinsics) - points = points.copy() - points[changed] = projected[changed] - return replace(frame, depth=corrected, points=points) diff --git a/src/anyimage/server/geometry/prediction_artifacts.py b/src/anyimage/server/geometry/prediction_artifacts.py index ae04ea1..346c643 100644 --- a/src/anyimage/server/geometry/prediction_artifacts.py +++ b/src/anyimage/server/geometry/prediction_artifacts.py @@ -8,7 +8,6 @@ from .depth_texture import ( write_depth_texture, ) from .normal_texture import write_camera_normal_texture -from .edge_depth import extend_edge_depth def _limited_model_input(source_path, output_path, max_input_size): @@ -79,12 +78,11 @@ def generate_moge_artifacts( if generate_depth: context.progress(0.45, "Generating depth texture") alpha = _image_alpha(input_path, frame.depth.shape) - depth_frame = extend_edge_depth(frame, alpha, alpha_threshold) result["depth"] = write_depth_texture( - depth_frame, context.directory / "depth.exr", alpha=alpha, + frame, context.directory / "depth.exr", alpha=alpha, ) result["depth_metadata"] = write_depth_metadata( - depth_frame, + frame, (alpha > 0.0) & (alpha >= alpha_threshold), context.directory / "depth.json", ) diff --git a/src/anyimage/server/model_catalog.py b/src/anyimage/server/model_catalog.py index d43b835..41d30f8 100644 --- a/src/anyimage/server/model_catalog.py +++ b/src/anyimage/server/model_catalog.py @@ -18,7 +18,7 @@ class ModelSpec: BIREFNET_LITE = ModelSpec( key="BIREFNET_LITE", - label="Fast | BiRefNet Lite", + label="BiRefNet Lite", directory_name="birefnet-lite", repository="ZhengPeng7/BiRefNet_lite", license_name="MIT", @@ -35,7 +35,7 @@ BIREFNET_LITE = ModelSpec( BEN2_MODEL = ModelSpec( key="BEN2_BASE", - label="General | BEN2 Base", + label="BEN2 Base", directory_name="BEN2-ONNX", repository="onnx-community/BEN2-ONNX", license_name="Apache 2.0", @@ -54,7 +54,7 @@ BEN2_MODEL = ModelSpec( REALESRGAN_GENERAL_WDN_X4V3 = ModelSpec( key="REALESRGAN_GENERAL_WDN_X4V3", - label="Fast | Real-ESRGAN General WDN x4v3", + label="Real-ESRGAN General WDN x4v3", directory_name="realesr-general-wdn-x4v3", repository="xinntao/Real-ESRGAN", license_name="BSD 3-Clause", @@ -72,7 +72,7 @@ REALESRGAN_GENERAL_WDN_X4V3 = ModelSpec( REALESRGAN_X4PLUS = ModelSpec( key="REALESRGAN_X4PLUS", - label="General | Real-ESRGAN x4plus", + label="Real-ESRGAN x4plus", directory_name="realesrgan-x4plus", repository="jonathanst29/tinier-upscale-models", license_name="BSD 3-Clause", @@ -93,7 +93,7 @@ REALESRGAN_X4PLUS = ModelSpec( HAT_GAN_X4_SHARPER = ModelSpec( key="HAT_GAN_X4_SHARPER", - label="Detailed | HAT Sharper", + label="HAT Sharper", directory_name="hat-gan-x4-sharper", repository="XPixelGroup/HAT", license_name="Apache 2.0", @@ -124,7 +124,7 @@ DEFAULT_UPSCALE_MODEL_KEY = REALESRGAN_GENERAL_WDN_X4V3.key MOGE2_MODELS = { "MOGE2_VITS_NORMAL": ModelSpec( key="MOGE2_VITS_NORMAL", - label="Fast | MoGe-2 ViT-S Normal", + label="MoGe-2 ViT-S Normal", directory_name="moge-2-vits-normal-onnx", repository="Ruicheng/moge-2-vits-normal-onnx", license_name="MIT", @@ -141,7 +141,7 @@ MOGE2_MODELS = { ), "MOGE2_VITB_NORMAL": ModelSpec( key="MOGE2_VITB_NORMAL", - label="Balanced | MoGe-2 ViT-B Normal", + label="MoGe-2 ViT-B Normal", directory_name="moge-2-vitb-normal-onnx", repository="Ruicheng/moge-2-vitb-normal-onnx", license_name="MIT", @@ -159,7 +159,7 @@ MOGE2_MODELS = { } MOGE3_VITL = ModelSpec( key="MOGE3_VITL", - label="Detailed | MoGe-3 ViT-L", + label="MoGe-3 ViT-L", directory_name="moge-3-vitl-onnx", repository="Ruicheng/moge-3-vitl", license_name="MIT", @@ -176,7 +176,7 @@ MOGE3_VITL = ModelSpec( MOGE_MODELS = {**MOGE2_MODELS, MOGE3_VITL.key: MOGE3_VITL} BIREFNET_HR_MATTING = ModelSpec( key="BIREFNET_HR_MATTING", - label="Detailed | BiRefNet HR-matting (CPU)", + label="BiRefNet HR-matting (CPU)", directory_name="birefnet-hr-matting", repository="ZhengPeng7/BiRefNet_HR-matting", license_name="MIT", diff --git a/tests/anyimage/operators/test_ai_setup.py b/tests/anyimage/operators/test_ai_setup.py index 4f135de..350fbb4 100644 --- a/tests/anyimage/operators/test_ai_setup.py +++ b/tests/anyimage/operators/test_ai_setup.py @@ -50,9 +50,9 @@ class AiSetupTest(BlenderTestCase): with self.assertRaisesRegex( RuntimeError, ( - "Fast \\| MoGe-2 ViT-S Normal: network unavailable; " - "Fast \\| BiRefNet Lite: network unavailable; " - "Fast \\| Real-ESRGAN General WDN x4v3: network unavailable" + "MoGe-2 ViT-S Normal: network unavailable; " + "BiRefNet Lite: network unavailable; " + "Real-ESRGAN General WDN x4v3: network unavailable" ), ): self.runtime.post_install(SimpleNamespace(request=request)) diff --git a/tests/anyimage/operators/test_brush_preview_integration.py b/tests/anyimage/operators/test_brush_preview_integration.py new file mode 100644 index 0000000..57aa886 --- /dev/null +++ b/tests/anyimage/operators/test_brush_preview_integration.py @@ -0,0 +1,37 @@ +import bpy +import numpy as np + +from anyimage.operators.clipboard_image.actions import _set_brush_preview + + +def test_packed_brush_preview_preserves_image_and_survives_reopen(tmp_path): + bpy.ops.wm.read_factory_settings(use_empty=True) + image = bpy.data.images.new("Preview Source", width=80, height=40, alpha=True) + pixels = np.zeros((40, 80, 4), dtype=np.float32) + pixels[:, :40] = (1.0, 0.0, 0.0, 1.0) + image.pixels.foreach_set(pixels.ravel()) + image.pack() + original_pixels = tuple(image.pixels) + original_format = image.file_format + brush = bpy.data.brushes.new("Preview Brush", mode="TEXTURE_PAINT") + brush.asset_mark() + try: + _set_brush_preview(bpy.context, brush, image) + assert tuple(brush.preview.image_size) == (80, 40) + preview = np.array(brush.preview.image_pixels_float[:]).reshape(40, 80, 4) + assert np.allclose(preview[:, :40, 0], 1.0, atol=1 / 255) + assert np.all(preview[:, :40, 3] == 1.0) + assert np.all(preview[:, 40:, 3] == 0.0) + assert tuple(image.pixels) == original_pixels + assert tuple(image.size) == (80, 40) + assert image.packed_file is not None + assert image.file_format == original_format + expected_preview = tuple(brush.preview.image_pixels) + path = str(tmp_path / "brush-preview.blend") + bpy.ops.wm.save_as_mainfile(filepath=path) + bpy.ops.wm.open_mainfile(filepath=path) + restored = bpy.data.brushes["Preview Brush"] + assert tuple(restored.preview.image_size) == (80, 40) + assert tuple(restored.preview.image_pixels) == expected_preview + finally: + bpy.ops.wm.read_factory_settings(use_empty=True) diff --git a/tests/anyimage/operators/test_clipboard_image.py b/tests/anyimage/operators/test_clipboard_image.py index 03b6521..e3b8e90 100644 --- a/tests/anyimage/operators/test_clipboard_image.py +++ b/tests/anyimage/operators/test_clipboard_image.py @@ -631,6 +631,89 @@ class ClipboardImageTest(unittest.TestCase): self.assertEqual(brush.texture_slot.map_mode, "STENCIL") self.assertEqual(brush.stencil_dimension, (256.0, 144.0)) + def test_brush_preview_updates_local_copy_only_in_texture_paint(self): + for mode in ("PAINT_TEXTURE", "SCULPT", "PAINT_VERTEX"): + with self.subTest(mode=mode): + brush = SimpleNamespace( + texture=None, + texture_slot=SimpleNamespace(texture=None), + stencil_dimension=(256.0, 256.0), + update_tag=Mock(), + ) + context = SimpleNamespace(mode=mode, brush=object()) + images = [SimpleNamespace(name=name, size=(80, 40)) for name in ("A", "B")] + textures = FakeTextures() + with ( + patch.object(self.actions.bpy, "data", SimpleNamespace(textures=textures)), + patch.object(self.actions, "_ensure_editable_brush", return_value=brush), + patch.object(self.actions, "_set_brush_preview") as preview, + ): + for image in images: + self.actions.set_tool_texture(context, image) + self.assertIs(brush.texture.image, images[-1]) + self.assertEqual(preview.call_count, 2 if mode == "PAINT_TEXTURE" else 0) + if mode == "PAINT_TEXTURE": + self.assertEqual([call.args[1:] for call in preview.call_args_list], + [(brush, image) for image in images]) + + def test_brush_preview_cleans_export_on_success_cancel_and_error(self): + for outcome in ({"FINISHED"}, {"CANCELLED"}, RuntimeError("preview failed")): + with self.subTest(outcome=outcome): + brush, image = object(), object() + context = SimpleNamespace(scene=object(), temp_override=Mock(return_value=nullcontext())) + loader = Mock() + if isinstance(outcome, Exception): + loader.side_effect = outcome + else: + loader.return_value = outcome + with ( + patch.object(self.actions, "save_packed_image", return_value="preview.png"), + patch.object(self.actions, "cleanup_image_input") as cleanup, + patch.object(self.actions.bpy, "ops", SimpleNamespace( + ed=SimpleNamespace(lib_id_load_custom_preview=loader)), create=True), + ): + expectation = nullcontext() if outcome == {"FINISHED"} else self.assertRaises(self.actions.BrushPreviewError) + with expectation: + self.actions._set_brush_preview(context, brush, image) + cleanup.assert_called_once_with("preview.png", True) + context.temp_override.assert_called_once_with(id=brush) + loader.assert_called_once_with(filepath="preview.png") + + def test_preview_failure_preserves_texture_and_redraws(self): + textures = FakeTextures() + brush = SimpleNamespace( + texture=None, texture_slot=SimpleNamespace(texture=None), + stencil_dimension=(256.0, 256.0), update_tag=Mock(), + ) + context = SimpleNamespace(mode="PAINT_TEXTURE", brush=brush, area=SimpleNamespace(tag_redraw=Mock())) + image = SimpleNamespace(name="Clipboard", size=(80, 40)) + with ( + patch.object(self.actions.bpy, "data", SimpleNamespace(textures=textures)), + patch.object(self.actions, "_set_brush_preview", side_effect=self.actions.BrushPreviewError("preview failed")), + self.assertRaises(self.actions.BrushPreviewError), + ): + self.actions.set_tool_texture(context, image) + self.assertEqual(len(textures), 1) + self.assertIs(brush.texture.image, image) + context.area.tag_redraw.assert_called_once() + + def test_preview_warning_finishes_paste(self): + module = self.addon.clipboard_image + operator = module.PasteClipboardImage() + operator.paste_target = "TOOL_TEXTURE" + operator.location_set = False + operator.import_as = "REFERENCE" + operator.shadeless = False + operator.report = Mock() + with ( + patch.object(module, "_should_defer_to_native_paste", return_value=False), + patch.object(module, "read_clipboard_image", return_value=(b"image", ".png")), + patch.object(module, "acquire_packed_image", return_value=(object(), False)), + patch.object(module, "paste_image", side_effect=self.actions.BrushPreviewError("preview failed")), + ): + self.assertEqual(operator.execute(SimpleNamespace()), {"FINISHED"}) + operator.report.assert_called_once_with({"WARNING"}, "preview failed") + def test_localizes_read_only_active_brush(self): local_brush = SimpleNamespace(is_editable=True, library=None) linked_brush = SimpleNamespace( diff --git a/tests/anyimage/operators/test_cutout_depth.py b/tests/anyimage/operators/test_cutout_depth.py index 708aa0c..f87f11f 100644 --- a/tests/anyimage/operators/test_cutout_depth.py +++ b/tests/anyimage/operators/test_cutout_depth.py @@ -26,6 +26,17 @@ def test_new_cutout_does_not_reuse_a_saved_wrapper(): assert any(item.name == "Shape" for item in saved.interface.items_tree) +def test_depth_cutout_loads_boundary_blur_revision_and_preserves_existing_group(): + bpy.ops.wm.read_factory_settings(use_empty=True) + saved = bpy.data.node_groups.new("O Image Depth Cutout", "GeometryNodeTree") + saved["anyimage_asset_revision"] = 11 + current = load_cutout_node_group("DEPTH_SOLID") + assert current != saved and current["anyimage_asset_revision"] == 18 + assert saved in bpy.data.node_groups.values() + assert saved["anyimage_asset_revision"] == 11 + assert load_cutout_node_group("DEPTH_SOLID") == current + + def test_refined_boundary_ears_have_interior_balloon_samples(): points = np.array([(0, 0), (1, 0), (1, 1), (0, 1)], dtype=float) points, faces, boundary = refine_balloon_triangles(points, [(0, 1, 2), (0, 2, 3)], np.ones(4, bool)) diff --git a/tests/anyimage/operators/test_cutout_geometry.py b/tests/anyimage/operators/test_cutout_geometry.py index 284dd02..dcbf269 100644 --- a/tests/anyimage/operators/test_cutout_geometry.py +++ b/tests/anyimage/operators/test_cutout_geometry.py @@ -46,7 +46,8 @@ def test_cutout_node_groups_are_loaded_from_the_asset(): ) as load: for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES: assert cutout_object.load_cutout_node_group(shape) is loaded - load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape], asset_revision=11) + load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape], + asset_revision=18 if shape == "DEPTH_SOLID" else 11) assert load.call_count == 4 diff --git a/tests/anyimage/operators/test_cutout_objects.py b/tests/anyimage/operators/test_cutout_objects.py index 765689e..10381a4 100644 --- a/tests/anyimage/operators/test_cutout_objects.py +++ b/tests/anyimage/operators/test_cutout_objects.py @@ -187,7 +187,11 @@ def test_cutout_presets_load_three_direct_modifier_assets(): assert inputs["Mode"].default_value == "Balloon" assert inputs["Smooth"].default_value == 0 assert inputs["Smooth Weight"].default_value == 1 - assert "Edge Smooth" not in inputs + if shape == "DEPTH_SOLID": + assert inputs["Boundary Smooth"].default_value == 5 + assert inputs["Boundary Smooth"].parent.name == "Options" + else: + assert "Boundary Smooth" not in inputs assert not any(node.bl_idname == "GeometryNodeRepeatInput" and node.inputs["Iterations"].default_value == 8 for node in group.nodes) assert next(item for item in groups["DEPTH_BALLOON"].interface.items_tree if item.name == "Double Sided").default_value @@ -424,17 +428,17 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge(): assert np.ptp(positions[:, 2]) < 1e-4 -def test_depth_surface_split_removes_isolated_triangle_without_loose_geometry(): +def test_depth_surface_removes_boundary_ears_without_loose_geometry(): bpy.ops.wm.read_factory_settings(use_empty=True) obj, modifier = _depth_surface_step_object() split_id = object_data.modifier_input_identifier(modifier.node_group, "Split Threshold") - # The raised corner becomes an isolated triangle after separation. - for value, expected in ((0., (9, 8)), (.5, (8, 7)), (1., (8, 7))): + # Both outer ears have three boundary vertices, including the raised corner. + for value in (0., .5, 1.): object_data.set_modifier_input(modifier, split_id, value) obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() vertices, faces, loose_edges, loose_points = _evaluated_topology(obj) - assert (vertices, faces) == expected + assert (vertices, faces) == (7, 6) assert not loose_edges and not loose_points diff --git a/tests/anyimage/operators/test_panorama_conversion.py b/tests/anyimage/operators/test_panorama_conversion.py index 9dfd303..9cdd741 100644 --- a/tests/anyimage/operators/test_panorama_conversion.py +++ b/tests/anyimage/operators/test_panorama_conversion.py @@ -81,12 +81,17 @@ def artifacts(tmp_path, source): def test_result_replaces_source_with_zero_rotation_and_shadeless_material(source, artifacts, monkeypatch): result, operator = artifacts + saved = bpy.data.node_groups.new("O Image Depth Panorama", "GeometryNodeTree") + saved["anyimage_asset_revision"] = 11 matrix = Matrix.Translation((2, -3, 4)) @ Matrix.Rotation(0.6, 4, "Y") @ Matrix.Diagonal((2, 3, 4, 1)) source.matrix_world = matrix source.empty_image_offset = (0.3, -0.8) undo = Mock(return_value={"FINISHED"}) monkeypatch.setattr(conversion, "bpy", SimpleNamespace(data=bpy.data, ops=SimpleNamespace(ed=SimpleNamespace(undo_push=undo)))) obj = conversion.create_panorama_from_result(bpy.context, result, operator) + assert obj.modifiers[0].node_group != saved + assert obj.modifiers[0].node_group["anyimage_asset_revision"] == 14 + assert saved in bpy.data.node_groups.values() and saved["anyimage_asset_revision"] == 11 assert obj.name == operator.source_object_name and obj.type == "MESH" np.testing.assert_allclose(obj.location, matrix.translation, atol=1e-6) np.testing.assert_allclose(obj.scale, matrix.to_scale(), atol=1e-6) diff --git a/tests/anyimage/operators/test_plane_conversion.py b/tests/anyimage/operators/test_plane_conversion.py index 195a91d..eea681d 100644 --- a/tests/anyimage/operators/test_plane_conversion.py +++ b/tests/anyimage/operators/test_plane_conversion.py @@ -11,7 +11,7 @@ def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surf obj, _, _, inputs, image = surface group = obj.modifiers[0].node_group - monkeypatch.setattr(conversion, "load_node_group", lambda name: group) + monkeypatch.setattr(conversion, "load_node_group", lambda name, *, asset_revision: group) source = bpy.data.objects.new("Offset Image", None) source.empty_display_type = "IMAGE" source.data = image @@ -48,7 +48,7 @@ def test_failed_modifier_initialization_removes_partial_object(surface, monkeypa from anyimage.operators.convert_to_plane import object as conversion obj, _, _, _, image = surface - monkeypatch.setattr(conversion, "load_node_group", lambda name: obj.modifiers[0].node_group) + monkeypatch.setattr(conversion, "load_node_group", lambda name, *, asset_revision: obj.modifiers[0].node_group) source = bpy.data.objects.new("Source", None) source.empty_display_type = "IMAGE" source.data = image diff --git a/tests/anyimage/operators/test_plane_objects.py b/tests/anyimage/operators/test_plane_objects.py index c8fd449..15bdc5c 100644 --- a/tests/anyimage/operators/test_plane_objects.py +++ b/tests/anyimage/operators/test_plane_objects.py @@ -73,7 +73,8 @@ def test_clipboard_plane_uses_the_mesh_material_slot(): def test_converted_planes_center_the_origin_without_moving_world_corners(): bpy.ops.wm.read_factory_settings(use_empty=True) - build_image_depth_plane_group(build_image_plane_group()) + saved = build_image_depth_plane_group(build_image_plane_group()) + saved["anyimage_asset_revision"] = 11 def create_source(name): image = bpy.data.images.new(name, width=200, height=100) @@ -149,6 +150,9 @@ def test_converted_planes_center_the_origin_without_moving_world_corners(): assert_centered(depth_plane, depth_bounds, depth_matrix) assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE" depth_modifier = depth_plane.modifiers[0] + assert depth_modifier.node_group != saved + assert depth_modifier.node_group["anyimage_asset_revision"] == 14 + assert saved in bpy.data.node_groups.values() and saved["anyimage_asset_revision"] == 11 uniform_scale = depth_modifier[ object_data.modifier_input_identifier( depth_modifier.node_group, diff --git a/tests/anyimage/server/test_edge_depth.py b/tests/anyimage/server/test_edge_depth.py deleted file mode 100644 index 90bae2b..0000000 --- a/tests/anyimage/server/test_edge_depth.py +++ /dev/null @@ -1,236 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import patch -import numpy as np -import pytest -from PIL import Image - - -from server.geometry import prediction_artifacts as artifacts -from server.geometry.edge_depth import extend_edge_depth -from server.models.geometry import GeometryFrame -from server.models.onnx_moge2 import depth_to_points - - -def make_frame(depth): - depth = np.asarray(depth, dtype=np.float32) - height, width = depth.shape - normalized = np.array([[1.2, 0, 0.4], [0, 1.4, 0.6], [0, 0, 1]], np.float32) - intrinsics = normalized.copy() - intrinsics[0] *= width - intrinsics[1] *= height - return GeometryFrame( - depth=depth, - validity=np.ones_like(depth), - intrinsics=intrinsics, - points=depth_to_points(depth, normalized), - normal=np.zeros((*depth.shape, 3), np.float32), - ) - - -def silhouette(): - alpha = np.zeros((25, 35), np.float32) - alpha[4:21, 4:22] = 1 - alpha[4:21, 22:25] = 0.2 - depth = np.full(alpha.shape, 20, np.float32) - depth[alpha == 1] = 2 - return make_frame(depth), alpha - - -def test_hair_extension_preserves_core_alpha_and_projection(): - frame, alpha = silhouette() - original_alpha = alpha.copy() - result = extend_edge_depth(frame, alpha) - assert np.all(result.depth[alpha >= 0.1] == 2) - np.testing.assert_array_equal(result.points[9:16, 9:16], frame.points[9:16, 9:16]) - np.testing.assert_array_equal(alpha, original_alpha) - np.testing.assert_array_equal(frame.depth[alpha == 0.2], 20) - np.testing.assert_allclose( - result.points[..., :2] / result.points[..., 2:], - frame.points[..., :2] / frame.points[..., 2:], - atol=1e-6, - ) - assert result.validity is frame.validity - assert result.normal is frame.normal - assert np.isfinite(result.points).all() - - -def test_sampling_padding_is_bounded(): - frame, alpha = silhouette() - result = extend_edge_depth(frame, alpha, alpha_threshold=0.1) - assert np.all(result.depth[12, 24:29] == 2) - assert result.depth[12, 29] == 20 - assert (result.depth[12, 24] + result.depth[12, 25]) / 2 == 2 - - -def test_edge_padding_tracks_selected_threshold(): - frame, alpha = silhouette() - loose = extend_edge_depth(frame, alpha, alpha_threshold=0.1) - strict = extend_edge_depth(frame, alpha) - assert loose.depth[12, 28] == 2 - assert strict.depth[12, 28] == 20 - - -def test_strict_threshold_excludes_lower_alpha_donors(): - alpha = np.zeros((10, 10), np.float32) - alpha[3:7, 3:7] = 0.96 - alpha[5, 5] = 1 - depth = np.full(alpha.shape, 10, np.float32) - depth[5, 5] = 2 - result = extend_edge_depth(make_frame(depth), alpha, alpha_threshold=1) - assert result.depth[5, 6] == 2 - - -def test_image_rectangle_does_not_erode_subject(): - alpha = np.ones((15, 20), np.float32) - alpha[:, 15:] = 0 - depth = np.full(alpha.shape, 2, np.float32) - depth[0, 0] = 3 - frame = make_frame(depth) - result = extend_edge_depth(frame, alpha) - assert result.depth[0, 0] == 3 - np.testing.assert_array_equal(result.points[0, 0], frame.points[0, 0]) - - -def test_small_components_fall_back_without_borrowing_other_depths(): - alpha = np.zeros((15, 30), np.float32) - alpha[5, 5:7] = [0.95, 0.1] - alpha[5, 10:12] = [1, 0.2] - alpha[5, 14] = 0.94 - depth = np.full(alpha.shape, 30, np.float32) - depth[5, 5] = 2 - depth[5, 10] = 8 - result = extend_edge_depth(make_frame(depth), alpha) - assert result.depth[5, 6] == 2 - assert result.depth[5, 11] == 8 - assert result.depth[5, 14] == 30 - assert result.depth[5, 7] == 2 - assert result.depth[5, 9] == 8 - - -def test_diagonal_pixels_share_a_component(): - alpha = np.zeros((10, 10), np.float32) - alpha[4, 4] = 1 - alpha[5, 5] = 0.2 - depth = np.full(alpha.shape, 10, np.float32) - depth[4, 4] = 2 - result = extend_edge_depth(make_frame(depth), alpha) - assert result.depth[5, 5] == 2 - - -@pytest.mark.parametrize("validity", [0.0, 0.2, 0.5]) -def test_invalid_donors_are_excluded(validity): - frame, alpha = silhouette() - frame.depth[10, 6] = 100 - frame.validity[10, 6] = validity - result = extend_edge_depth(frame, alpha) - assert result.depth[10, 4] == 2 - assert result.depth[10, 6] == 100 - assert result.validity[10, 6] == np.float32(validity) - - -def test_internal_mask_hole_does_not_expand_alpha_repair(): - frame, alpha = silhouette() - frame.depth[9:14, 10:15] = 100 - frame.validity[9:14, 10:15] = 0 - original = frame.depth.copy() - result = extend_edge_depth(frame, alpha) - np.testing.assert_array_equal(result.depth[6:19, 6:20], original[6:19, 6:20]) - assert result.depth[12, 24] == 2 - - -def test_opaque_image_keeps_low_mask_depth(): - frame = make_frame(np.ones((10, 10))) - frame.depth[5, 5] = 100 - frame.validity[5, 5] = 0 - assert extend_edge_depth(frame, np.ones((10, 10))) is frame - - -def test_component_without_valid_donors_keeps_its_depth(): - frame, alpha = silhouette() - frame.validity[alpha > 0] = 0 - assert extend_edge_depth(frame, alpha) is frame - - -def test_local_smoothing_reduces_donor_step_and_preserves_core(): - frame, alpha = silhouette() - frame.depth[12:, alpha.shape[1] // 4:22] = 4 - result = extend_edge_depth(frame, alpha) - assert 2 < result.depth[11, 23] < 4 - assert 2 < result.depth[12, 23] < 4 - np.testing.assert_array_equal(result.depth[8:17, 8:17], frame.depth[8:17, 8:17]) - - -@pytest.mark.parametrize("alpha_value", [0, 0.3, 1]) -def test_images_without_repair_return_original_frame(alpha_value): - frame = make_frame(np.ones((5, 8))) - assert extend_edge_depth(frame, np.full((5, 8), alpha_value)) is frame - - -def test_alpha_resize_retains_threshold_values(tmp_path): - rgba = np.zeros((1, 4, 4), np.uint8) - rgba[0, :, 3] = [25, 26, 242, 243] - path = tmp_path / "alpha.png" - Image.fromarray(rgba).save(path) - alpha = artifacts._image_alpha(path, (2, 8)) - np.testing.assert_array_equal( - alpha >= 0.1, [[False] * 2 + [True] * 6] * 2 - ) - np.testing.assert_array_equal( - alpha >= 0.95, [[False] * 6 + [True] * 2] * 2 - ) - - -@pytest.mark.parametrize( - "generate_depth,normal_mode", - [ - (True, "OBJECT"), - (True, "TANGENT"), - (True, "NONE"), - (False, "OBJECT"), - ], -) -def test_artifacts_use_corrected_depth_and_original_normals( - tmp_path, generate_depth, normal_mode -): - frame, alpha = silhouette() - rgba = np.full((*alpha.shape, 4), 255, np.uint8) - rgba[..., 3] = np.round(alpha * 255).astype(np.uint8) - path = tmp_path / "source.png" - Image.fromarray(rgba).save(path) - context = SimpleNamespace( - directory=tmp_path, - resource=lambda _: None, - progress=lambda *_: None, - check_cancelled=lambda: None, - ) - with ( - patch.object(artifacts.moge, "infer", return_value=[frame]), - patch.object( - artifacts, "write_depth_texture", - return_value=tmp_path / "depth.exr", - ) as depth, - patch.object( - artifacts, "write_depth_metadata", return_value=tmp_path / "depth.json" - ) as metadata, - patch.object( - artifacts, "_write_normal", return_value=tmp_path / "normal.png" - ) as normal, - patch.object(artifacts, "extend_edge_depth", wraps=extend_edge_depth) as repair, - ): - artifacts.generate_moge_artifacts( - context, {}, path, generate_depth=generate_depth, normal_mode=normal_mode, - ) - if normal_mode != "NONE": - assert normal.call_args.args[0] is frame - else: - normal.assert_not_called() - assert repair.call_count == int(generate_depth) - if generate_depth: - written_frame = depth.call_args.args[0] - np.testing.assert_array_equal(depth.call_args.kwargs["alpha"], alpha) - assert metadata.call_args.args[0] is written_frame - assert written_frame.depth[12, 24] == 2 - assert written_frame.validity is frame.validity - else: - metadata.assert_not_called() - depth.assert_not_called() diff --git a/tests/anyimage/server/test_prediction_artifacts.py b/tests/anyimage/server/test_prediction_artifacts.py new file mode 100644 index 0000000..3a2266d --- /dev/null +++ b/tests/anyimage/server/test_prediction_artifacts.py @@ -0,0 +1,104 @@ +from types import SimpleNamespace +from unittest.mock import patch +import numpy as np +import pytest +from PIL import Image + + +from server.geometry import prediction_artifacts as artifacts +from server.models.geometry import GeometryFrame +from server.models.onnx_moge2 import depth_to_points + + +def make_frame(depth): + depth = np.asarray(depth, dtype=np.float32) + height, width = depth.shape + normalized = np.array([[1.2, 0, 0.4], [0, 1.4, 0.6], [0, 0, 1]], np.float32) + intrinsics = normalized.copy() + intrinsics[0] *= width + intrinsics[1] *= height + return GeometryFrame( + depth=depth, + validity=np.ones_like(depth), + intrinsics=intrinsics, + points=depth_to_points(depth, normalized), + normal=np.zeros((*depth.shape, 3), np.float32), + ) + + +def silhouette(): + alpha = np.zeros((25, 35), np.float32) + alpha[4:21, 4:22] = 1 + alpha[4:21, 22:25] = 0.2 + depth = np.full(alpha.shape, 20, np.float32) + depth[alpha == 1] = 2 + return make_frame(depth), alpha + + +def test_alpha_resize_retains_threshold_values(tmp_path): + rgba = np.zeros((1, 4, 4), np.uint8) + rgba[0, :, 3] = [25, 26, 242, 243] + path = tmp_path / "alpha.png" + Image.fromarray(rgba).save(path) + alpha = artifacts._image_alpha(path, (2, 8)) + np.testing.assert_array_equal( + alpha >= 0.1, [[False] * 2 + [True] * 6] * 2 + ) + np.testing.assert_array_equal( + alpha >= 0.95, [[False] * 6 + [True] * 2] * 2 + ) + + +@pytest.mark.parametrize( + "generate_depth,normal_mode", + [ + (True, "OBJECT"), + (True, "TANGENT"), + (True, "NONE"), + (False, "OBJECT"), + ], +) +def test_artifacts_preserve_original_geometry_with_alpha( + tmp_path, generate_depth, normal_mode +): + frame, alpha = silhouette() + rgba = np.full((*alpha.shape, 4), 255, np.uint8) + rgba[..., 3] = np.round(alpha * 255).astype(np.uint8) + path = tmp_path / "source.png" + Image.fromarray(rgba).save(path) + context = SimpleNamespace( + directory=tmp_path, + resource=lambda _: None, + progress=lambda *_: None, + check_cancelled=lambda: None, + ) + with ( + patch.object(artifacts.moge, "infer", return_value=[frame]), + patch.object( + artifacts, "write_depth_texture", + return_value=tmp_path / "depth.exr", + ) as depth, + patch.object( + artifacts, "write_depth_metadata", return_value=tmp_path / "depth.json" + ) as metadata, + patch.object( + artifacts, "_write_normal", return_value=tmp_path / "normal.png" + ) as normal, + ): + artifacts.generate_moge_artifacts( + context, {}, path, generate_depth=generate_depth, normal_mode=normal_mode, + ) + if normal_mode != "NONE": + assert normal.call_args.args[0] is frame + else: + normal.assert_not_called() + if generate_depth: + written_frame = depth.call_args.args[0] + np.testing.assert_array_equal(depth.call_args.kwargs["alpha"], alpha) + assert metadata.call_args.args[0] is written_frame + assert written_frame is frame + assert written_frame.depth[12, 24] == 20 + assert written_frame.validity is frame.validity + else: + metadata.assert_not_called() + depth.assert_not_called() diff --git a/tests/anyimage/test_preferences.py b/tests/anyimage/test_preferences.py index 18bb673..fcd9b7e 100644 --- a/tests/anyimage/test_preferences.py +++ b/tests/anyimage/test_preferences.py @@ -118,6 +118,21 @@ class PreferencesTest(BlenderTestCase): geometry = properties.geometry_model_enum_items(None, None) self.assertEqual(tuple(item[0] for item in geometry), expected) + def test_model_tiers_only_appear_in_choices(self): + properties = self.anyimage.properties + preferences = self.anyimage.preferences + catalog = {model["key"]: model for model in properties.model_catalog()} + for items in ( + properties.geometry_model_enum_items(None, None), + preferences.BACKGROUND_MODEL_ITEMS, + preferences.UPSCALE_MODEL_ITEMS, + ): + for item, tier in zip(items, ("Fast", "Base", "Pro")): + label = catalog[item[0]]["label"] + self.assertNotIn(" | ", label) + self.assertEqual(item[1], f"{tier} | {label}") + self.assertEqual(properties.upscale_model_label("HAT_GAN_X4_SHARPER"), "HAT Sharper") + def test_model_preferences_keep_valid_choices_and_normalize_retired_keys(self): module = self.anyimage.preferences stored = self.fake_bpy.context.preferences.addons["anyimage"].preferences diff --git a/tests/support/depth_surface.py b/tests/support/depth_surface.py index e2e6007..c226809 100644 --- a/tests/support/depth_surface.py +++ b/tests/support/depth_surface.py @@ -68,6 +68,7 @@ def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False): ("Split Threshold", 0.0), ("Mode", 1), ("Smooth", 0), + ("Boundary Smooth", 0), ): set_value(name, value) if wrapper: diff --git a/tests/support/planes.py b/tests/support/planes.py index 7141164..7d7d52e 100644 --- a/tests/support/planes.py +++ b/tests/support/planes.py @@ -63,6 +63,8 @@ def create_surface(source, plane_type): ("Uniform Scale", 0.5), ("Reference Depth", 1.2), ("Depth Scale", 0.0), ("Thickness", 0.0)): set_value(name, value) + if "Boundary Smooth" in inputs: + set_value("Boundary Smooth", 0) return obj, set_value, plane, inputs, image diff --git a/tests/tools/nodes/test_boundary_depth.py b/tests/tools/nodes/test_boundary_depth.py new file mode 100644 index 0000000..ef18452 --- /dev/null +++ b/tests/tools/nodes/test_boundary_depth.py @@ -0,0 +1,186 @@ +"""Verify boundary depth filtering before camera and radial projection.""" + +import bpy +import numpy as np +import pytest + +from tests.support.depth_surface import evaluated +from tests.tools.nodes.test_boundary_smoothing import boundary_neighbors, edge_band +from tools.nodes.common.boundary_smoothing import smooth_boundary_camera + + +def test_camera_blur_preserves_rays_interior_protection_and_disconnected_surfaces(): + bpy.ops.wm.read_factory_settings(use_empty=True) + points, faces, cameras, protected = [], [], [], [] + for offset, depth in ((0, 2), (81, 50)): + for y in range(9): + for x in range(9): + value = 20 if offset == 0 and (x, y) == (4, 0) else depth + if offset == 0 and (x, y) == (8, 8): + value = 0 + points.append((x, y, 0)) + cameras.append((x * value / 10, y * value / 10, value)) + protected.append(x == 0) + faces.extend((offset + y * 9 + x, offset + y * 9 + x + 1, + offset + (y + 1) * 9 + x + 1, offset + (y + 1) * 9 + x) + for y in range(8) for x in range(8)) + mesh = bpy.data.meshes.new("Disconnected camera samples") + mesh.from_pydata(points, [], faces) + camera = np.asarray(cameras, np.float32) + mesh.attributes.new("test_camera", "FLOAT_VECTOR", "POINT").data.foreach_set("vector", camera.ravel()) + mesh.attributes.new("test_protected", "BOOLEAN", "POINT").data.foreach_set("value", protected) + obj = bpy.data.objects.new("Depth samples", mesh) + bpy.context.collection.objects.link(obj) + group = bpy.data.node_groups.new("Boundary depth test", "GeometryNodeTree") + for direction in ("INPUT", "OUTPUT"): + group.interface.new_socket(name="Geometry", in_out=direction, socket_type="NodeSocketGeometry") + control = group.interface.new_socket(name="Boundary Smooth", in_out="INPUT", socket_type="NodeSocketInt") + source = group.nodes.new("NodeGroupInput") + attributes = [] + for name, kind in (("test_camera", "FLOAT_VECTOR"), ("test_protected", "BOOLEAN")): + node = group.nodes.new("GeometryNodeInputNamedAttribute") + node.data_type = kind + node.inputs["Name"].default_value = name + attributes.append(node.outputs["Attribute"]) + position = group.nodes.new("GeometryNodeSetPosition") + group.links.new(source.outputs["Geometry"], position.inputs["Geometry"]) + group.links.new(smooth_boundary_camera(group, *attributes), position.inputs["Position"]) + output = group.nodes.new("NodeGroupOutput") + group.links.new(position.outputs[0], output.inputs["Geometry"]) + modifier = obj.modifiers.new("Depth", "NODES") + modifier.node_group = group + modifier[control.identifier] = 0 + np.testing.assert_array_equal(evaluated(obj)[0], camera) + modifier[control.identifier] = 12 + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + result, result_faces = evaluated(obj) + assert result_faces == faces + assert 2 < result[4, 2] < 10 + assert np.isfinite(result).all() + np.testing.assert_array_equal(result[protected], camera[protected]) + np.testing.assert_array_equal(result[81:], camera[81:]) + np.testing.assert_array_equal(result[80], camera[80]) + band = edge_band(faces, set(boundary_neighbors(faces)) - set(np.flatnonzero(protected))) + interior = [i for i in range(len(camera)) if i not in band] + assert interior + np.testing.assert_array_equal(result[interior], camera[interior]) + valid = camera[:, 2] > 0 + np.testing.assert_allclose(result[valid, :2] / result[valid, 2:3], + camera[valid, :2] / camera[valid, 2:3], atol=1e-6) + + +@pytest.mark.parametrize("source", ["definition", "asset"]) +def test_cutout_fine_outline_blurs_depth_without_moving_projection(source): + from tests.support.depth_surface import surface + from anyimage.operators.cutout_tool.fine_mesh import build_fine_mesh + from anyimage.operators.cutout_tool.object import load_cutout_node_group + + obj, set_value = surface(step=0, resolution=8) + if source == "asset": + bpy.data.node_groups.remove(obj.modifiers[0].node_group, do_unlink=True) + obj.modifiers[0].node_group = load_cutout_node_group("DEPTH_SOLID") + yy, xx = np.mgrid[:32, :64] + alpha = ((xx - 32)**2 / 27**2 + (yy - 16)**2 / 13**2 < 1).astype(float) + points, faces, _, _ = build_fine_mesh(alpha, 3, .9) + obj.data.clear_geometry() + obj.data.from_pydata([(x / 32, y / 32, 0) for x, y in points], [], faces.tolist()) + uv = obj.data.uv_layers.get("UVMap") or obj.data.uv_layers.new(name="UVMap") + for loop in obj.data.loops: + uv.data[loop.index].uv = points[loop.vertex_index] / (64, 32) + image = bpy.data.images["Camera"] + pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4) + pixels[..., 2] = 2 + 12 * ((xx > 25) & (xx < 39) & (yy > 25)) + image.pixels.foreach_set(pixels.ravel()) + image.update() + set_value("Boundary Smooth", 0) + set_value("Smooth Weight", 0) + before, original_faces = evaluated(obj) + set_value("Boundary Smooth", 12) + after, result_faces = evaluated(obj) + assert result_faces == original_faces + assert after[:, 2].min() > before[:, 2].min() + 1 + # Reference Depth = 1, Uniform Scale = 1 and Depth Scale = 1. + np.testing.assert_allclose(after[:, :2] / (1 - after[:, 2:3]), + before[:, :2] / (1 - before[:, 2:3]), atol=1e-6) + band = edge_band(original_faces, boundary_neighbors(original_faces)) + interior = [i for i in range(len(before)) if i not in band] + assert interior + np.testing.assert_array_equal(after[interior], before[interior]) + set_value("Boundary Smooth", 0) + np.testing.assert_array_equal(evaluated(obj)[0], before) + + +@pytest.mark.parametrize("source", ["definition", "asset"]) +def test_plane_validity_hole_blurs_depth_and_protects_rectangle(source): + from tests.support.planes import create_surface, evaluated as plane_mesh + + obj, set_value, _, _, image = create_surface(source, "DEPTH") + pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4) + pixels[..., 2] = 2 + pixels[2:6, 6:10, 3] = 0 + pixels[1:3, 7:9, 2] = 12 + image.pixels.foreach_set(pixels.ravel()) + image.update() + for name, value in (("Subdivide", 5), ("Depth Scale", 1), ("Smooth Weight", 0)): + set_value(name, value) + before, faces, uv, *_ = plane_mesh(obj) + set_value("Boundary Smooth", 12) + after, result_faces, result_uv, *_ = plane_mesh(obj) + assert faces == result_faces + np.testing.assert_array_equal(result_uv, uv) + assert after[:, 2].min() > before[:, 2].min() + .1 + protected = np.any(np.isclose(uv, 0) | np.isclose(uv, 1), axis=1) + corners = np.concatenate(faces) + outer = np.unique(corners[protected]) + np.testing.assert_array_equal(after[outer], before[outer]) + np.testing.assert_allclose(after[:, :2] / (1.2 - after[:, 2:3]), + before[:, :2] / (1.2 - before[:, 2:3]), atol=1e-6) + set_value("Valid Only", False) + intact = plane_mesh(obj)[0] + set_value("Boundary Smooth", 0) + np.testing.assert_array_equal(plane_mesh(obj)[0], intact) + + +@pytest.mark.parametrize("source", ["definition", "asset"]) +@pytest.mark.parametrize("region", ["seam", "pole"]) +def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(source, region): + from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh + from tools.nodes.build import OUTPUT_PATH + + yy, xx = np.mgrid[:64, :128] + distance = np.minimum(xx, 128 - xx) + hole = (distance**2 + (yy - 32)**2 < 9**2) if region == "seam" else (yy > 54) + spike = ((distance < 4) & (yy > 40) & (yy < 44)) if region == "seam" else ((yy > 50) & (xx < 8)) + depth = np.where(spike, 10., 3.) + obj, set_value = panorama(depth, (~hole).astype(float), subdivide=5) + if source == "asset": + obj.modifiers[0].node_group.name = "Panorama definition" + with bpy.data.libraries.load(str(OUTPUT_PATH)) as (_, target): + target.node_groups = ["O Image Depth Panorama"] + obj.modifiers[0].node_group = target.node_groups[0] + set_value("Smooth Weight", 0) + before, faces, uv = panorama_mesh(obj) + set_value("Boundary Smooth", 12) + after, result_faces, result_uv = panorama_mesh(obj) + assert result_faces == faces + np.testing.assert_array_equal(result_uv, uv) + radii = np.linalg.norm(before, axis=1) + smoothed = np.linalg.norm(after, axis=1) + rim = list(boundary_neighbors(faces)) + assert smoothed[rim].max() < radii[rim].max() - .1 + np.testing.assert_allclose(after / smoothed[:, None], before / radii[:, None], atol=1e-6) + band = edge_band(faces, boundary_neighbors(faces)) + interior = [i for i in range(len(before)) if i not in band] + assert interior + np.testing.assert_array_equal(after[interior], before[interior]) + set_value("Valid Only", False) + near = panorama_mesh(obj)[0] + set_value("Invalid Distance", 250) + far = panorama_mesh(obj)[0] + valid = np.linalg.norm(near, axis=1) < 50 + assert valid.any() and (~valid).any() + np.testing.assert_array_equal(far[valid], near[valid]) + np.testing.assert_allclose(np.linalg.norm(far[~valid], axis=1), 250, atol=5e-5) + set_value("Depth Scale", 0) + np.testing.assert_allclose(np.linalg.norm(panorama_mesh(obj)[0], axis=1), 1, atol=1e-6) diff --git a/tests/tools/nodes/test_boundary_smoothing.py b/tests/tools/nodes/test_boundary_smoothing.py new file mode 100644 index 0000000..d35b556 --- /dev/null +++ b/tests/tools/nodes/test_boundary_smoothing.py @@ -0,0 +1,335 @@ +"""Verify pinned-boundary smoothing stays within its fixed edge band.""" + +from collections import Counter + +import bpy +import bmesh +import numpy as np +import pytest + +from tests.support.depth_surface import surface, evaluated, assert_closed, component_count + + +def diagonal_surface(triangles=False): + obj, set_value = surface(resolution=16) + if triangles: + mesh = bmesh.new() + mesh.from_mesh(obj.data) + bmesh.ops.triangulate(mesh, faces=list(mesh.faces)) + mesh.to_mesh(obj.data) + mesh.free() + image = bpy.data.images["Camera"] + pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4) + yy, xx = np.mgrid[:32, :64] + pixels[..., 2] = 1 + (xx > 18 + yy * 0.7) * 6 + image.pixels.foreach_set(pixels.ravel()) + image.update() + set_value("Split Threshold", 0.5) + set_value("Depth Scale", 0) + return obj, set_value + + +def boundary_neighbors(faces): + edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0]))) + neighbors = {} + for (a, b), count in edges.items(): + if count == 1: + neighbors.setdefault(a, []).append(b) + neighbors.setdefault(b, []).append(a) + return neighbors + + +def edge_band(faces, seeds, rings=2): + adjacency = {} + for face in faces: + for a, b in zip(face, (*face[1:], face[0])): + adjacency.setdefault(a, set()).add(b) + adjacency.setdefault(b, set()).add(a) + selected = set(seeds) + for _ in range(rings): + selected |= {neighbor for vertex in selected for neighbor in adjacency[vertex]} + return selected + + +@pytest.mark.parametrize("triangles", [False, True]) +def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles): + obj, set_value = diagonal_surface(triangles) + original, faces = evaluated(obj) + boundary = boundary_neighbors(faces) + outer = np.isclose(original[:, 0], 0) | np.isclose(original[:, 0], 2) + outer |= np.isclose(original[:, 1], 0) | np.isclose(original[:, 1], 1) + cut = {i for i in boundary if not outer[i]} + band = edge_band(faces, boundary) + movable = np.array([i in band for i in range(len(original))]) + assert movable.any() and (~movable).any() + set_value("Boundary Smooth", 20) + smoothed, result_faces = evaluated(obj) + assert result_faces == faces + np.testing.assert_array_equal(smoothed[~movable], original[~movable]) + assert np.max(np.linalg.norm(smoothed - original, axis=1)) > 0.005 + nearby = [i for i in band if i not in boundary and not outer[i]] + assert np.max(np.linalg.norm(smoothed[nearby] - original[nearby], axis=1)) > 1e-4 + before, after = [], [] + for i in cut: + adjacent = boundary[i] + if len(adjacent) != 2: + continue + before.append(np.linalg.norm(original[adjacent].mean(axis=0) - original[i])) + after.append(np.linalg.norm(smoothed[adjacent].mean(axis=0) - smoothed[i])) + assert sum(after) < 0.8 * sum(before) + np.testing.assert_array_equal(smoothed[:, 2], original[:, 2]) + set_value("Smooth Weight", 0) + np.testing.assert_array_equal(evaluated(obj)[0], original) + set_value("Smooth Weight", 1) + set_value("Boundary Smooth", 0) + np.testing.assert_array_equal(evaluated(obj)[0], original) + + +@pytest.mark.parametrize("source", ["definition", "asset"]) +def test_smoothing_without_split_moves_original_boundary_only_within_band(source): + obj, set_value = diagonal_surface() + if source == "asset": + from anyimage.common.node import node_asset_path + + with bpy.data.libraries.load(str(node_asset_path())) as (_, target): + target.node_groups = ["O Image Depth Cutout"] + obj.modifiers[0].node_group = target.node_groups[0] + set_value("Split Threshold", 0) + original, faces = evaluated(obj) + set_value("Boundary Smooth", 20) + result, result_faces = evaluated(obj) + boundary = boundary_neighbors(faces) + band = edge_band(faces, boundary) + outside = [i for i in range(len(original)) if i not in band] + assert np.max(np.linalg.norm(result[list(boundary)] - original[list(boundary)], axis=1)) > 1e-4 + np.testing.assert_array_equal(result[outside], original[outside]) + assert result_faces == faces + + +@pytest.mark.parametrize("mode", [0, 1]) +def test_smoothed_cut_shells_stay_closed_and_separate(mode): + from anyimage.common.object import modifier_input_identifier, set_modifier_input + + obj, set_value = diagonal_surface() + obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set( + "value", np.full(len(obj.data.vertices), 0.3, dtype=np.float32) + ) + set_value("Depth Scale", 1) + set_value("Boundary Smooth", 4) + original, faces = evaluated(obj) + count = component_count(faces) + set_value("Mode", mode) + modifier = obj.modifiers[0] + identifier = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE" if mode == 0 else "DISTANCE") + set_modifier_input(modifier, identifier, 0.05) + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + result, shell_faces = evaluated(obj) + assert np.isfinite(result).all() + assert_closed(shell_faces) + assert component_count(shell_faces) == count + + +def test_panorama_smoothing_preserves_interior_and_uvs(): + from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh + + yy, xx = np.mgrid[:64, :128] + depth = np.where(xx > 35 + yy * 0.6, 8.0, 2.0) + obj, set_value = panorama(depth, subdivide=4) + set_value("Split Threshold", 0.5) + original, faces, uv = panorama_mesh(obj) + rim = boundary_neighbors(faces) + band = edge_band(faces, rim) + interior = [i for i in range(len(original)) if i not in band] + set_value("Boundary Smooth", 4) + result, result_faces, result_uv = panorama_mesh(obj) + assert np.isfinite(result).all() + assert result_faces == faces + np.testing.assert_array_equal(result[interior], original[interior]) + np.testing.assert_array_equal(result_uv, uv) + assert np.max(np.linalg.norm(result-original, axis=1)) > 1e-4 + + +@pytest.mark.parametrize("source", ["definition", "asset"]) +def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness(source): + from tests.support.planes import create_surface, evaluated as plane_mesh + + obj, set_value, _, inputs, image = create_surface(source, "DEPTH") + assert inputs["Boundary Smooth"].default_value == 5 + assert inputs["Boundary Smooth"].parent.name == "Options" + pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4) + yy, xx = np.mgrid[:8, :16] + pixels[..., 2] = 1 + (xx > 5 + yy * 0.7) * 6 + image.pixels.foreach_set(pixels.ravel()) + image.update() + set_value("Subdivide", 5) + set_value("Depth Scale", 1) + set_value("Split Threshold", 0.5) + original, faces, uv, *_ = plane_mesh(obj) + set_value("Boundary Smooth", 4) + result, result_faces, result_uv, *_ = plane_mesh(obj) + assert result_faces == faces + np.testing.assert_array_equal(result_uv, uv) + assert np.max(np.linalg.norm(result-original, axis=1)) > 1e-4 + set_value("Thickness", 0.03) + assert_closed(plane_mesh(obj)[1]) + + +def test_edge_iterations_reuse_boundary_topology_and_mapping(): + from tools.nodes.arrangement.flow import origin_socket + from tools.nodes.arrangement.zones import node_zones + + obj, _ = diagonal_surface() + group = obj.modifiers[0].node_group + entry, _, members = next( + zone for zone in node_zones(group) + if origin_socket(zone[0].inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth" + ) + nearest = next(node for node in group.nodes if node.bl_idname == "GeometryNodeSampleNearest" + and any(link.to_node in members for link in node.outputs["Index"].links)) + fixed_position = origin_socket(nearest.inputs["Sample Position"].links[0].from_socket).node + assert fixed_position.bl_idname == "GeometryNodeSampleIndex" + source = origin_socket(entry.inputs["Geometry"].links[0].from_socket) + assert origin_socket(fixed_position.inputs["Geometry"].links[0].from_socket) == source + assert fixed_position.domain == "POINT" + assert any(node.bl_idname == "GeometryNodeSeparateGeometry" for node in members) + + +@pytest.mark.parametrize("source", ["definition", "asset"]) +def test_every_split_node_enables_boundary_smoothing_by_default(source): + from tools.nodes.build import OUTPUT_PATH, build_node_groups + from tools.nodes.arrangement.flow import origin_socket + + bpy.ops.wm.read_factory_settings(use_empty=True) + if source == "definition": + groups = build_node_groups() + else: + with bpy.data.libraries.load(str(OUTPUT_PATH)) as (library, target): + target.node_groups = list(library.node_groups) + groups = target.node_groups + split_groups = [] + for group in groups: + inputs = {item.name: item for item in group.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT"} + assert "Edge Smooth" not in inputs + if "Split Threshold" not in inputs: + continue + split_groups.append(group.name) + smooth = inputs["Boundary Smooth"] + assert smooth.default_value == 5 + assert (smooth.min_value, smooth.max_value) == (0, 20) + assert smooth.parent.name == "Options" + assert not {"Depth Blur Iterations", "Depth Band Rings", "Depth Blur Strength", "Skip Inactive Geometry"} & inputs.keys() + depth_mix = next(n for n in group.nodes if n.bl_idname == "ShaderNodeMix" + and n.data_type == "FLOAT" and n.inputs[3].is_linked + and origin_socket(n.inputs[3].links[0].from_socket).node.bl_idname == "GeometryNodeBlurAttribute") + depth_blur = origin_socket(depth_mix.inputs[3].links[0].from_socket).node + assert origin_socket(depth_blur.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth" + depth_bypass = [n for n in group.nodes if n.bl_idname == "GeometryNodeSwitch" + and n.input_type == "FLOAT" and n.inputs["Switch"].is_linked + and origin_socket(n.inputs["Switch"].links[0].from_socket).name == "Boundary Smooth"] + assert len(depth_bypass) == 1 + assert set(split_groups) == { + "O Image Depth Plane", "O Image Depth Cutout", "O Image Depth Panorama", + } + + +@pytest.mark.parametrize("source", ["definition", "asset"]) +def test_validity_cut_smoothing_preserves_original_outline_and_interior(source): + from tests.support.planes import create_surface, evaluated as plane_mesh + + obj, set_value, _, _, image = create_surface(source, "DEPTH") + set_value("Subdivide", 5) + set_value("Split Threshold", 0) + set_value("Valid Only", False) + whole = plane_mesh(obj)[0] + low, high = whole.min(axis=0), whole.max(axis=0) + pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4) + yy, xx = np.mgrid[:8, :16] + pixels[..., 3] = xx < 5 + yy * 0.7 + image.pixels.foreach_set(pixels.ravel()) + image.update() + set_value("Valid Only", True) + before, faces, uv, *_ = plane_mesh(obj) + boundary = boundary_neighbors(faces) + outer = np.any(np.isclose(before[:, :2], low[:2]) | np.isclose(before[:, :2], high[:2]), axis=1) + cut = {i for i in boundary if not outer[i]} + band = edge_band(faces, cut) + fixed = np.array([i not in band or outer[i] for i in range(len(before))]) + set_value("Boundary Smooth", 5) + after, result_faces, result_uv, *_ = plane_mesh(obj) + assert result_faces == faces + np.testing.assert_array_equal(result_uv, uv) + np.testing.assert_array_equal(after[fixed], before[fixed]) + assert np.max(np.linalg.norm(after-before, axis=1)) > 1e-4 + before_bends, after_bends = [], [] + for i in cut: + if len(boundary[i]) == 2: + before_bends.append(np.linalg.norm(before[boundary[i]].mean(axis=0)-before[i])) + after_bends.append(np.linalg.norm(after[boundary[i]].mean(axis=0)-after[i])) + assert sum(after_bends) < sum(before_bends) * 0.8 + set_value("Thickness", 0.03) + assert_closed(plane_mesh(obj)[1]) + set_value("Thickness", 0) + set_value("Valid Only", False) + np.testing.assert_array_equal(plane_mesh(obj)[0], whole) + + +def test_panorama_validity_cut_smoothing_stays_in_two_ring_band(): + from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh + + yy, xx = np.mgrid[:64, :128] + alpha = ((xx-64)**2 + (yy-32)**2 > 15**2).astype(np.float32) + obj, set_value = panorama(np.full((64, 128), 3), alpha, subdivide=4) + set_value("Split Threshold", 0) + before, faces, uv = panorama_mesh(obj) + band = edge_band(faces, boundary_neighbors(faces)) + outside = [i for i in range(len(before)) if i not in band] + set_value("Boundary Smooth", 5) + after, result_faces, result_uv = panorama_mesh(obj) + assert result_faces == faces + np.testing.assert_array_equal(result_uv, uv) + np.testing.assert_array_equal(after[outside], before[outside]) + assert np.max(np.linalg.norm(after-before, axis=1)) > 1e-4 + + +@pytest.mark.parametrize("source", ["definition", "asset"]) +@pytest.mark.parametrize("kind", ["cutout", "plane", "panorama"]) +@pytest.mark.parametrize("iterations,weight", [(0, 1.0), (8, 1.0), (8, 0.0)]) +def test_boundary_loop_bypass_uses_iterations_only(source, kind, iterations, weight): + from anyimage.common.node import node_asset_path + from tests.support.planes import create_surface + from tests.tools.nodes.test_image_depth_panorama import panorama + from tools.nodes.arrangement.flow import origin_socket + + if kind == "cutout": + obj, set_value = surface(resolution=4) + elif kind == "plane": + obj, set_value, *_ = create_surface("definition", "DEPTH") + else: + obj, set_value = panorama(subdivide=2) + if source == "asset": + with bpy.data.libraries.load(str(node_asset_path())) as (_, target): + target.node_groups = [obj.modifiers[0].node_group.name] + obj.modifiers[0].node_group = target.node_groups[0].copy() + set_value("Split Threshold", 0) + set_value("Smooth Weight", weight) + set_value("Boundary Smooth", iterations) + group = obj.modifiers[0].node_group + entry = next(n for n in group.nodes if n.bl_idname == "GeometryNodeRepeatInput" + and origin_socket(n.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth") + bypass = next(n for n in group.nodes if n.bl_idname == "GeometryNodeSwitch" + and n.input_type == "GEOMETRY" and n.inputs["True"].is_linked + and origin_socket(n.inputs["True"].links[0].from_socket).node == entry.paired_output) + control = origin_socket(bypass.inputs["Switch"].links[0].from_socket) + assert control.name == "Boundary Smooth" + assert control.identifier == origin_socket(entry.inputs["Iterations"].links[0].from_socket).identifier + assert origin_socket(bypass.inputs["False"].links[0].from_socket) == origin_socket(entry.inputs["Geometry"].links[0].from_socket) + original, faces = evaluated(obj) + group.links.remove(bypass.inputs["Switch"].links[0]) + bypass.inputs["Switch"].default_value = True + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + unskipped, unskipped_faces = evaluated(obj) + np.testing.assert_array_equal(original, unskipped) + assert faces == unskipped_faces diff --git a/tests/tools/nodes/test_cutout_boundary.py b/tests/tools/nodes/test_cutout_boundary.py new file mode 100644 index 0000000..e457aa7 --- /dev/null +++ b/tests/tools/nodes/test_cutout_boundary.py @@ -0,0 +1,128 @@ +"""Verify split profile shaping and thickness-weighted normals.""" + +import bpy +import numpy as np +import pytest + +from anyimage.common.object import modifier_input_identifier, set_modifier_input +from tests.support.depth_surface import surface, evaluated +from tests.tools.nodes.test_boundary_smoothing import diagonal_surface, boundary_neighbors, edge_band +from tools.nodes.common.cutout_boundary import remove_boundary_triangles +from tools.nodes.groups.image_depth_cutout import build_normal_weight, blend_normal + + +def profile_values(obj): + result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + mesh = result.to_mesh() + try: + return np.array([v.value for v in mesh.attributes["o_balloon"].data]) + finally: + result.to_mesh_clear() + + +def test_boundary_triangle_cleanup_is_one_pass(): + obj, _ = surface(resolution=2) + group = obj.modifiers[0].node_group + mesh = bpy.data.meshes.new("Boundary ears") + mesh.from_pydata([(0,0,0),(1,0,0),(1,1,0),(0,1,0),(.5,.5,0),(2,0,0)], [], + [(0,1,4),(1,2,4),(2,3,4),(3,0,4),(1,5,2)]) + obj.data = mesh + source = group.nodes.new("NodeGroupInput") + cleaned = remove_boundary_triangles(group, source.outputs["Geometry"]) + group.links.new(cleaned, next(n for n in group.nodes if n.type == "GROUP_OUTPUT").inputs["Geometry"]) + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + _, faces = evaluated(obj) + assert len(faces) == 4 + assert all(len(f) == 3 for f in faces) + + +@pytest.mark.parametrize("iterations", [0, 5, 12, 20]) +def test_cleaned_sawtooth_smoothing_preserves_face_orientation(iterations): + obj, set_value = diagonal_surface(triangles=True) + original, faces = evaluated(obj) + set_value("Boundary Smooth", iterations) + points, actual = evaluated(obj) + assert actual == faces + indices = np.array(faces) + def normals(p): + return np.cross(p[indices[:,1]]-p[indices[:,0]], p[indices[:,2]]-p[indices[:,0]]) + assert np.all(np.sum(normals(original)*normals(points), axis=1) > 0) + + +@pytest.mark.parametrize("resolution", [12, 24]) +@pytest.mark.parametrize("aspect", [.5, 2]) +def test_split_profile_tapers_and_rounding_remains_live(resolution, aspect): + obj, set_value = surface(step=6, resolution=resolution) + for vertex in obj.data.vertices: + vertex.co.x *= aspect + image = bpy.data.images["Camera"] + pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4) + pixels[..., 0] *= aspect + image.pixels.foreach_set(pixels.ravel()) + image.update() + obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set( + "value", np.full(len(obj.data.vertices), .3, dtype=np.float32)) + set_value("Depth Scale", 0) + np.testing.assert_allclose(profile_values(obj), .3) + set_value("Split Threshold", .1) + points, faces = evaluated(obj) + boundary = boundary_neighbors(faces) + cut = [i for i in boundary if 0 < points[i,1] < 1 and .5*aspect < points[i,0] < 1.5*aspect] + assert cut + band = edge_band(faces, cut) + outside = [i for i in range(len(points)) if i not in band] + assert outside + results = [] + for amount in (0, .2, .6): + set_value("Edge Round", amount) + values = profile_values(obj) + np.testing.assert_array_equal(values[cut], 0) + np.testing.assert_allclose(values[outside], .3) + assert np.isfinite(values).all() + assert values.max() == pytest.approx(.3) + results.append(values) + assert np.all(results[2] <= results[0] + 1e-7) + set_value("Split Threshold", 0) + np.testing.assert_allclose(profile_values(obj), .3) + + +@pytest.mark.parametrize("mode", [0, 1]) +@pytest.mark.parametrize("reference", [0, 2]) +def test_normal_blend_converges_to_raw_and_reaches_full_smoothing(mode, reference): + obj, set_value = surface(resolution=2) + group = obj.modifiers[0].node_group + # The production interface renames the uniform socket after building. + socket = next(s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT" + and s.name == "Thickness" and s.subtype == "DISTANCE") + socket.name = "Uniform Thickness" + uniform = group.nodes.new("FunctionNodeInputBool") + uniform.boolean = bool(mode) + weight = build_normal_weight(group, uniform.outputs[0]) + raw = group.nodes.new("ShaderNodeCombineXYZ") + raw.inputs["Z"].default_value = 1 + blurred = group.nodes.new("ShaderNodeCombineXYZ") + blurred.inputs["X"].default_value = 1 + result = blend_normal(group, raw.outputs[0], blurred.outputs[0], weight) + store = group.nodes.new("GeometryNodeStoreNamedAttribute") + store.data_type, store.domain = "FLOAT_VECTOR", "POINT" + store.inputs["Name"].default_value = "normal_result" + source = group.nodes.new("NodeGroupInput") + group.links.new(source.outputs["Geometry"], store.inputs["Geometry"]) + group.links.new(result, store.inputs["Value"]) + group.links.new(store.outputs[0], next(n for n in group.nodes if n.type == "GROUP_OUTPUT").inputs["Geometry"]) + modifier = obj.modifiers[0] + identifier = socket.identifier if mode else modifier_input_identifier(group, "Thickness", subtype="NONE") + set_value("Reference Depth", reference) + results = [] + for amount in (0, .001, .01, 1): + set_modifier_input(modifier, identifier, amount * (max(reference, 1e-8) if mode else 1)) + obj.update_tag(refresh={"DATA"}); bpy.context.view_layer.update() + ev = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()); mesh = ev.to_mesh() + try: + results.append(np.array(mesh.attributes["normal_result"].data[0].vector[:])) + finally: + ev.to_mesh_clear() + np.testing.assert_array_equal(results[0], [0,0,1]) + np.testing.assert_array_equal(results[-1], [1,0,0]) + assert np.linalg.norm(results[1]-results[0]) < np.linalg.norm(results[2]-results[0]) < .001 diff --git a/tests/tools/nodes/test_cutouts.py b/tests/tools/nodes/test_cutouts.py index 7f67610..592ecf2 100644 --- a/tests/tools/nodes/test_cutouts.py +++ b/tests/tools/nodes/test_cutouts.py @@ -22,7 +22,8 @@ def test_cutout_interfaces_and_thickness_modes(source): assert len(set(groups.values())) == 3 for group in set(groups.values()): validate_node_structure(group) - assert group.is_modifier and group.get("anyimage_asset_revision") == 11 + revision = 18 if group == groups["DEPTH_SOLID"] else 11 + assert group.is_modifier and group.get("anyimage_asset_revision") == revision assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes) inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"} assert "Shape" not in inputs and "Rim Thickness" not in inputs @@ -34,6 +35,7 @@ def test_cutout_interfaces_and_thickness_modes(source): if "Double Sided" in inputs: assert inputs["Double Sided"].default_value if group == groups["DEPTH_SOLID"]: + assert np.isclose(inputs["Split Threshold"].default_value, 0.1) assert np.isclose(inputs["Edge Round"].default_value, 0.2) assert inputs["Edge Round"].parent.name == "Options" assert list(inputs)[list(inputs).index("Front Inflation") - 1] == "Edge Round" @@ -44,6 +46,7 @@ def test_cutout_interfaces_and_thickness_modes(source): options = [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"] expected = ["Cleanup Threshold"] if group == groups["DEPTH_SOLID"]: + expected.insert(0, "Boundary Smooth") expected += ["Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth"] elif group == groups["DEPTH_BALLOON"]: expected += ["Reference Depth"] diff --git a/tests/tools/nodes/test_depth_cutout.py b/tests/tools/nodes/test_depth_cutout.py index 4c41217..39dc95f 100644 --- a/tests/tools/nodes/test_depth_cutout.py +++ b/tests/tools/nodes/test_depth_cutout.py @@ -6,7 +6,7 @@ import pytest from tools.nodes.groups.image_depth_cutout import build_volume_fields from tools.nodes.groups.image_depth_cutout import build_volume_rounding from anyimage.common.object import modifier_input_identifier, set_modifier_input -from tools.nodes.common.nodes import capture_fields +from tools.nodes.common.nodes import sample_field def store_test_fields(group, geometry, fields): @@ -27,8 +27,8 @@ def set_test_structure(monkeypatch, value): from tools.nodes.groups import image_depth_cutout build = image_depth_cutout.build_front_fields - def fields(group, rim, normal): - result = build(group, rim, normal) + def fields(group, rim, normal, normal_weight): + result = build(group, rim, normal, normal_weight) structure = group.nodes.new("ShaderNodeValue") structure.outputs[0].default_value = value result["structure"] = structure.outputs[0] @@ -106,9 +106,12 @@ def test_rounding_shares_front_and_back_falloff(amount): group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") source = group.nodes.new("NodeGroupInput") fields = build_volume_fields(group) - geometry, center = capture_fields(group, source.outputs["Geometry"], "POINT", center=("FLOAT", fields["center"])) + geometry = source.outputs["Geometry"] + center = {"center": sample_field(group, geometry, fields["center"], "FLOAT")} mode = group.nodes.new("FunctionNodeInputBool") - result, rounded = build_volume_rounding(group, geometry, {**fields, **center}, mode.outputs[0]) + result, rounded = build_volume_rounding(group, geometry, fields, mode.outputs[0]) + rounded = {name: sample_field(group, geometry, value, "FLOAT_VECTOR" if name == "normal" else "FLOAT") + for name, value in rounded.items()} result = store_test_fields(group, result, {**center, **rounded}) group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"]) obj.modifiers.new("Round", "NODES").node_group = group diff --git a/tests/tools/nodes/test_depth_relief_planes.py b/tests/tools/nodes/test_depth_relief_planes.py index 2a1feee..442cda3 100644 --- a/tests/tools/nodes/test_depth_relief_planes.py +++ b/tests/tools/nodes/test_depth_relief_planes.py @@ -205,7 +205,7 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma def test_surface_interface_and_node_structure(surface): obj, _, _, inputs, _ = surface expected = ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Split Threshold", "Smooth", "Valid Only", - "Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"] + "Boundary Smooth", "Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"] assert list(inputs) == expected assert inputs["Split Threshold"].default_value == 0 assert inputs["Split Threshold"].min_value == 0 @@ -307,7 +307,7 @@ def test_relief_interface_and_node_structure(relief): @pytest.mark.parametrize("threshold", [0.0, 0.4, 0.9, 1.0]) @pytest.mark.parametrize("pattern", ["valid", "invalid", "partial", "half", "stripe"]) -def test_valid_only_samples_face_centers(surface, pattern, threshold): +def test_valid_only_deletes_invalid_points_and_wire_edges(surface, pattern, threshold): obj, set_value, _, inputs, _ = surface set_value("Validity Threshold", threshold) depth_image = obj.modifiers[0][inputs["Depth Image"].identifier] @@ -329,9 +329,9 @@ def test_valid_only_samples_face_centers(surface, pattern, threshold): set_value("Subdivide", subdivisions) set_value("Valid Only", False) base = evaluated(obj) - u = np.array([(base[0][list(face), 0].mean() + 2) / 4 for face in base[1]]) + u = (base[0][:, 0] + 2) / 4 sampled = np.interp(u * 16 - 0.5, np.arange(16), alpha) - expected = [face for face, alpha_value in zip(base[1], sampled) if alpha_value >= threshold] + expected = [face for face in base[1] if np.all(sampled[list(face)] >= threshold)] set_value("Valid Only", True) culled = evaluated(obj) assert len(culled[1]) == len(expected) @@ -363,7 +363,7 @@ def test_validity_graph_matches_reference_domains_and_connections(surface): obj, _, _, _, _ = surface group = obj.modifiers[0].node_group delete = next(node for node in group.nodes if node.bl_idname == "GeometryNodeDeleteGeometry") - assert (delete.domain, delete.mode) == ("FACE", "ALL") + assert (delete.domain, delete.mode) == ("POINT", "ALL") compare = origin_socket(delete.inputs["Selection"].links[0].from_socket).node assert (compare.bl_idname, compare.data_type, compare.operation) == ("FunctionNodeCompare", "FLOAT", "LESS_THAN") assert origin_socket(compare.inputs["B"].links[0].from_socket).name == "Validity Threshold" @@ -374,7 +374,9 @@ def test_validity_graph_matches_reference_domains_and_connections(surface): consumers = [link for link in group.links if link.to_node.bl_idname != "NodeReroute" and origin_socket(link.from_socket) == color.outputs["Color"]] assert any(link.to_node == alpha.node for link in consumers) - assert origin_socket(delete.inputs["Geometry"].links[0].from_socket).node.node_tree.name == "O Image Plane" + protected = origin_socket(delete.inputs["Geometry"].links[0].from_socket).node + assert protected.bl_idname == "GeometryNodeGroup" + assert protected.node_tree.name == "O Image Plane" @pytest.mark.parametrize("thickness", [0.0, 0.2]) @@ -412,6 +414,7 @@ def test_plane_interface_panels(source, kind): assert [item.name for item in panels] == ["Options", "Data"] controls = ["Reference Depth"] if kind == "DEPTH": + controls.insert(0, "Boundary Smooth") controls += ["Normal Smooth", "Smooth Weight", "Validity Threshold"] else: controls += ["Smooth Weight", "Depth Direction"] diff --git a/tests/tools/nodes/test_depth_strip_cleanup.py b/tests/tools/nodes/test_depth_strip_cleanup.py index ba6b59c..bf89def 100644 --- a/tests/tools/nodes/test_depth_strip_cleanup.py +++ b/tests/tools/nodes/test_depth_strip_cleanup.py @@ -134,7 +134,8 @@ def test_split_input_precedes_smooth_without_cleanup_switch(kind): split = inputs[names.index("Split Threshold")] assert split.parent == inputs[names.index("Smooth")].parent assert split.parent.name != "Options" - assert (split.default_value, split.min_value, split.max_value, split.subtype) == (0, 0, 1, "FACTOR") + assert split.default_value == pytest.approx(0.1 if kind == "cutout" else 0) + assert (split.min_value, split.max_value, split.subtype) == (0, 1, "FACTOR") assert "Remove Strip Faces" not in names @@ -198,7 +199,7 @@ def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh(kind): np.testing.assert_array_equal(restored, original) -def test_cutout_cleans_triangle_strips_only_when_split_is_enabled(): +def test_cutout_removes_an_all_boundary_triangle_strip(): import bmesh from tests.support.depth_surface import surface, evaluated @@ -208,15 +209,10 @@ def test_cutout_cleans_triangle_strips_only_when_split_is_enabled(): bmesh.ops.triangulate(mesh, faces=list(mesh.faces)) mesh.to_mesh(obj.data) mesh.free() - set_value("Split Threshold", 0.0) - original, faces = evaluated(obj) - assert len(faces) == 4 - set_value("Split Threshold", 0.001) - assert not evaluated(obj)[1] - set_value("Thickness", 0.2) - assert not evaluated(obj)[1] - set_value("Thickness", 0.0) - set_value("Split Threshold", 0.0) - restored, restored_faces = evaluated(obj) - assert restored_faces == faces - np.testing.assert_array_equal(restored, original) + # Every vertex lies on the boundary, even before any depth separation. + for split in (0.0, 0.001, 0.0): + set_value("Split Threshold", split) + for thickness in (0.0, 0.2): + set_value("Thickness", thickness) + points, faces = evaluated(obj) + assert not faces and not len(points) diff --git a/tests/tools/nodes/test_depth_surface_split.py b/tests/tools/nodes/test_depth_surface_split.py index b40c4d3..07cad0e 100644 --- a/tests/tools/nodes/test_depth_surface_split.py +++ b/tests/tools/nodes/test_depth_surface_split.py @@ -24,6 +24,14 @@ def test_shell_walls_directly_connect_caps(mode, split): front, faces = evaluated(obj) edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0]))) boundary_count = sum(count == 1 for count in edges.values()) + result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + mesh = result.to_mesh() + try: + welded = {i for i, value in enumerate(mesh.attributes["o_balloon"].data) + if mode == 0 and value.value == 0} + finally: + result.to_mesh_clear() + collapsed_walls = sum(count == 1 and a in welded and b in welded for (a, b), count in edges.items()) set_value("Mode", mode) modifier = obj.modifiers[0] thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE" if mode == 0 else "DISTANCE") @@ -32,9 +40,9 @@ def test_shell_walls_directly_connect_caps(mode, split): obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() points, shell_faces = evaluated(obj) - assert len(points) == 2 * len(front) - assert len(shell_faces) == 2 * len(faces) + boundary_count - assert all(len(face) == 4 for face in shell_faces) + assert len(points) == 2 * len(front) - len(welded) + assert len(shell_faces) == 2 * len(faces) + boundary_count - collapsed_walls + assert all(len(face) in (3, 4) for face in shell_faces) assert np.isfinite(points).all() assert_closed(shell_faces) @@ -146,6 +154,31 @@ def test_coincident_independent_sheets_do_not_weld_together(): assert component_count(faces) == 2 +def test_shell_source_indices_cross_identity_grid_rows(): + from scipy.spatial import cKDTree + from tools.nodes.common.depth_surface import thicken_depth_surface + + bpy.ops.wm.read_factory_settings(use_empty=True) + group = bpy.data.node_groups.new("Indexed Shell", "GeometryNodeTree") + group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + grid = group.nodes.new("GeometryNodeMeshGrid") + grid.inputs["Vertices X"].default_value = 65 + grid.inputs["Vertices Y"].default_value = 65 + amount = group.nodes.new("ShaderNodeValue") + amount.outputs[0].default_value = 0.1 + shell = thicken_depth_surface(group, grid.outputs["Mesh"], amount.outputs[0]) + group.links.new(shell["geometry"], group.nodes.new("NodeGroupOutput").inputs["Geometry"]) + obj = bpy.data.objects.new("Indexed Shell", bpy.data.meshes.new("Empty")) + bpy.context.collection.objects.link(obj) + obj.modifiers.new("Shell", "NODES").node_group = group + points, faces = evaluated(obj) + expected = np.array([(x / 64 - .5, y / 64 - .5, z) + for z in (0, -.1) for y in range(65) for x in range(65)]) + assert len(points) == len(expected) + assert cKDTree(points).query(expected)[0].max() < 1e-6 + assert_closed(faces) + + @pytest.mark.parametrize("resolution", [1, 2, 4]) def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution): obj, set_value = surface(step=0.0, resolution=resolution) @@ -185,7 +218,7 @@ def test_cut_vertices_preserve_camera_rays_and_other_vertices(): ) -def test_stored_point_positions_prevent_depth_resampling_on_side_walls(): +def test_source_surface_sampling_preserves_depth_on_side_walls(): obj, set_value = surface(step=3.0) set_value("Split Threshold", 0.5) set_value("Thickness", 0.1) @@ -200,10 +233,11 @@ def test_stored_point_positions_prevent_depth_resampling_on_side_walls(): @pytest.mark.parametrize("thickness", [0.0, 0.2]) @pytest.mark.parametrize("split", [0.0, 0.5]) -def test_internal_fields_stay_anonymous_and_user_attributes_survive(thickness, split): +def test_temporary_fields_are_removed_and_user_attributes_survive(thickness, split): obj, set_value = surface(wrapper=True) obj.data.attributes.new("o_depth_probe", "FLOAT", "POINT") attribute = obj.data.attributes.new("o_user_weight", "FLOAT", "POINT") + obj.data.attributes.new(".o_anyimage_user_weight", "FLOAT", "POINT") for value in attribute.data: value.value = 0.75 set_value("Split Threshold", split) @@ -214,6 +248,9 @@ def test_internal_fields_stay_anonymous_and_user_attributes_survive(thickness, s try: assert {a.name for a in mesh.attributes if a.name.startswith(("o_depth_", "o_balloon_surface_"))} == {"o_depth_probe"} assert "UVMap" in mesh.attributes + assert ".o_anyimage_user_weight" in mesh.attributes + assert ".o_anyimage_original_boundary" not in mesh.attributes + assert ".o_anyimage_depth_cut" not in mesh.attributes assert all( v.value == pytest.approx(0.75) for v in mesh.attributes["o_user_weight"].data diff --git a/tests/tools/nodes/test_image_depth_panorama.py b/tests/tools/nodes/test_image_depth_panorama.py index d3f0a7d..bd28f14 100644 --- a/tests/tools/nodes/test_image_depth_panorama.py +++ b/tests/tools/nodes/test_image_depth_panorama.py @@ -36,6 +36,7 @@ def panorama(depth=3, alpha=1, subdivide=3, reference=3): for name, value in (("Depth Image", image), ("Subdivide", subdivide), ("Reference Distance", reference)): set_value(name, value) + set_value("Boundary Smooth", 0) return obj, set_value @@ -209,6 +210,7 @@ def test_continuous_spherical_slope_does_not_split_at_seam_or_poles(): obj, set_value = panorama(depth) original = evaluated(obj) set_value("Split Threshold", 1) + set_value("Boundary Smooth", 12) points, faces, uv = evaluated(obj) np.testing.assert_allclose(points, original[0], atol=1e-6) assert faces == original[1] diff --git a/tests/tools/nodes/test_image_plane.py b/tests/tools/nodes/test_image_plane.py index 62ee208..975c718 100644 --- a/tests/tools/nodes/test_image_plane.py +++ b/tests/tools/nodes/test_image_plane.py @@ -1,4 +1,5 @@ import bpy +import pytest from anyimage.common.object import ( modifier_input_identifier, @@ -17,6 +18,46 @@ def evaluated_mesh(obj): return evaluated, evaluated.to_mesh() +@pytest.mark.parametrize("source_count", [0, 4, 9]) +def test_material_inheritance_removes_only_source_vertices(source_count): + from tools.nodes.common.material import inherit_material_slots + from tools.nodes.arrangement.arrange import arrange_nodes + + bpy.ops.wm.read_factory_settings(use_empty=True) + mesh = bpy.data.meshes.new("Source") + mesh.from_pydata([(20 + i % 3, i // 3, 0) for i in range(source_count)], [], + [(0, 1, 3, 2)] if source_count else []) + materials = [bpy.data.materials.new(name) for name in ("First", "Second")] + for material in materials: + mesh.materials.append(material) + obj = bpy.data.objects.new("Material Source", mesh) + bpy.context.collection.objects.link(obj) + group = bpy.data.node_groups.new("Material Inheritance", "GeometryNodeTree") + group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") + group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + source = group.nodes.new("NodeGroupInput") + grid = group.nodes.new("GeometryNodeMeshGrid") + grid.inputs["Vertices X"].default_value = 3 + grid.inputs["Vertices Y"].default_value = 2 + geometry = inherit_material_slots(group, grid.outputs["Mesh"], source.outputs["Geometry"]) + group.links.new(geometry, group.nodes.new("NodeGroupOutput").inputs["Geometry"]) + obj.modifiers.new("Material Inheritance", "NODES").node_group = group + for arranged in (False, True): + if arranged: + arrange_nodes(group) + obj.update_tag(refresh={"DATA"}) + ev, result = evaluated_mesh(obj) + try: + assert len(result.vertices) == 6 and len(result.polygons) == 2 + assert all(abs(v.co.x) <= 0.5 for v in result.vertices) + if source_count: + assert [material.original for material in result.materials if material] == materials + assert result.materials[0].original == materials[0] + assert all(face.material_index == 0 for face in result.polygons) + finally: + ev.to_mesh_clear() + + def test_saved_image_plane_evaluation(): bpy.ops.wm.read_factory_settings(use_empty=True) mesh = create_image_plane_mesh("Image Plane", (-1.0, 1.0, -0.5, 0.5)) diff --git a/tests/tools/nodes/test_inventory.py b/tests/tools/nodes/test_inventory.py index 9507a62..30d8417 100644 --- a/tests/tools/nodes/test_inventory.py +++ b/tests/tools/nodes/test_inventory.py @@ -1,5 +1,6 @@ """Check the saved library's public inventory and persistence.""" import bpy +import pytest from anyimage.common.node import load_node_group, node_asset_path from tools.nodes.check import GEOMETRY_NAMES, SHADER_NAMES @@ -15,11 +16,46 @@ def test_saved_asset_inventory(): assert node_group.use_fake_user and node_group.users > 0 if name in GEOMETRY_NAMES: assert node_group.is_modifier - stores = [node for node in node_group.nodes if node.bl_idname == "GeometryNodeStoreNamedAttribute"] - assert all(node.inputs["Name"].default_value in {"UVMap", "o_normal_scale"} for node in stores), name if bpy.app.version >= (5, 0, 0): assert node_group.library is not None if name in {"O Image Depth Layer", "O Shadeless"}: expected = ["Color", "Alpha", "Depth", "Bump Scale"] if name == "O Image Depth Layer" else ["Color", "Alpha"] assert [item.name for item in node_group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"] == expected + + +@pytest.mark.parametrize("source", ["definition", "asset"]) +def test_geometry_attribute_contract_includes_nested_groups(source): + from tools.nodes.build import build_node_groups + + bpy.ops.wm.read_factory_settings(use_empty=True) + if source == "definition": + pending = build_node_groups() + else: + with bpy.data.libraries.load(str(node_asset_path())) as (library, target): + target.node_groups = list(library.node_groups) + pending = list(target.node_groups) + seen = set() + allowed = { + "UVMap": ("FLOAT2", "CORNER"), + "o_balloon": ("FLOAT", "POINT"), + ".o_anyimage_depth_cut": ("BOOLEAN", "CORNER"), + } + while pending: + group = pending.pop() + if group in seen or group.bl_idname != "GeometryNodeTree": + continue + seen.add(group) + for node in group.nodes: + assert node.bl_idname != "GeometryNodeCaptureAttribute", group.name + if node.bl_idname == "GeometryNodeGroup" and node.node_tree: + pending.append(node.node_tree) + if node.bl_idname != "GeometryNodeStoreNamedAttribute": + continue + name = node.inputs["Name"].default_value + if name == "o_normal_scale": + assert node.data_type == "FLOAT" and node.domain in {"POINT", "FACE"} + else: + assert name in allowed, (group.name, name) + assert (node.data_type, node.domain) == allowed[name] + assert {group.name for group in seen} == GEOMETRY_NAMES diff --git a/tests/tools/nodes/test_temporary_attributes.py b/tests/tools/nodes/test_temporary_attributes.py new file mode 100644 index 0000000..4c6c57d --- /dev/null +++ b/tests/tools/nodes/test_temporary_attributes.py @@ -0,0 +1,43 @@ +"""Check temporary attribute lifetimes across depth modifier branches.""" + +import bpy +import pytest + +from anyimage.common.node import node_asset_path +from tests.support.depth_surface import surface +from tests.support.planes import create_surface +from tests.tools.nodes.test_image_depth_panorama import panorama + + +@pytest.mark.parametrize("source", ["definition", "asset"]) +@pytest.mark.parametrize("kind", ["cutout", "plane", "panorama"]) +def test_depth_attribute_cleanup_has_no_warnings(source, kind): + if kind == "cutout": + obj, set_value = surface() + elif kind == "plane": + obj, set_value, *_ = create_surface("definition", "DEPTH") + else: + obj, set_value = panorama(subdivide=2) + modifier = obj.modifiers[0] + if source == "asset": + name = modifier.node_group.name + modifier.node_group.name += " Definition" + with bpy.data.libraries.load(str(node_asset_path())) as (_, target): + target.node_groups = [name] + modifier.node_group = target.node_groups[0] + for split in (0.0, 0.5, 0.0): + set_value("Split Threshold", split) + for smooth in (0, 5): + set_value("Boundary Smooth", smooth) + evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + mesh = evaluated.to_mesh() + try: + assert mesh.vertices + assert not {a.name for a in mesh.attributes} & { + ".o_anyimage_depth_cut", ".o_anyimage_original_boundary", + } + assert not list(modifier.node_warnings), [ + warning.message for warning in modifier.node_warnings + ] + finally: + evaluated.to_mesh_clear() diff --git a/tests/tools/nodes/test_validity.py b/tests/tools/nodes/test_validity.py new file mode 100644 index 0000000..00ebd41 --- /dev/null +++ b/tests/tools/nodes/test_validity.py @@ -0,0 +1,40 @@ +"""Verify point culling removes its dangling wire geometry.""" + +import bpy + +from tools.nodes.common.nodes import interface_socket +from tools.nodes.common.validity import delete_invalid_points + + +def test_point_culling_removes_wire_edges_without_removing_adjacent_surface(): + bpy.ops.wm.read_factory_settings(use_empty=True) + mesh = bpy.data.meshes.new("Two Quads") + mesh.from_pydata([(0, 0, 0), (1, 0, 0), (2, 0, 0), + (0, 1, 0), (1, 1, 0), (2, 1, 0)], [], + [(0, 1, 4, 3), (1, 2, 5, 4)]) + obj = bpy.data.objects.new("Surface", mesh) + bpy.context.collection.objects.link(obj) + group = bpy.data.node_groups.new("Point Culling", "GeometryNodeTree") + interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") + interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") + inlet = group.nodes.new("NodeGroupInput") + outlet = group.nodes.new("NodeGroupOutput") + index = group.nodes.new("GeometryNodeInputIndex") + invalid = group.nodes.new("ShaderNodeMath") + invalid.operation = "LESS_THAN" + invalid.inputs[1].default_value = 1 + group.links.new(index.outputs[0], invalid.inputs[0]) + geometry = delete_invalid_points(group, inlet.outputs["Geometry"], invalid.outputs[0]) + group.links.new(geometry, outlet.inputs["Geometry"]) + modifier = obj.modifiers.new("Cull", "NODES") + modifier.node_group = group + evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + result = evaluated.to_mesh() + try: + assert len(result.polygons) == 1 + assert len(result.edges) == 4 + assert {tuple(vertex.co) for vertex in result.vertices} == { + (1, 0, 0), (2, 0, 0), (1, 1, 0), (2, 1, 0), + } + finally: + evaluated.to_mesh_clear() diff --git a/tests/tools/test_pack.py b/tests/tools/test_pack.py index 54c2ba5..17c9dcd 100644 --- a/tests/tools/test_pack.py +++ b/tests/tools/test_pack.py @@ -131,7 +131,6 @@ class ExtensionPackagingTest(unittest.TestCase): self.assertIn("server/geometry/panorama.py", names) self.assertIn("server/geometry/depth_texture.py", names) self.assertIn("server/geometry/prediction_artifacts.py", names) - self.assertIn("server/geometry/edge_depth.py", names) self.assertIn("server/geometry/normal_texture.py", names) self.assertNotIn( "server/geometry/generate_geometry_and_normal.py", names diff --git a/tools/nodes/arrangement/arrange.py b/tools/nodes/arrangement/arrange.py index 841f7fc..b8ce672 100644 --- a/tools/nodes/arrangement/arrange.py +++ b/tools/nodes/arrangement/arrange.py @@ -82,7 +82,8 @@ def _localize_peer_inputs(node_group, ancestors, children): if source.bl_idname == "NodeReroute": source["arrangement_input"] = True continue - if source.bl_idname != "NodeGroupInput" and len(children[source]) <= 1 and sources.count(source) == 1 and source not in upstream: + # Zone exits keep their own lane when a later peer merge is placed. + if source.bl_idname not in {"NodeGroupInput", "GeometryNodeRepeatOutput"} and len(children[source]) <= 1 and sources.count(source) == 1 and source not in upstream: continue port = node_group.nodes.new("NodeReroute") port["arrangement_input"] = True @@ -262,9 +263,9 @@ def _place_geometry_paths(paths, geometry_x, sizes, order): weights /= weights.max() constraints = [] if equalities: - constraints.append(LinearConstraint(np.array(equalities), centers, centers)) + constraints.append(LinearConstraint(np.array(equalities), np.array(centers) / pitch, np.array(centers) / pitch)) if inequalities: - constraints.append(LinearConstraint(np.array(inequalities), separations, np.inf)) + constraints.append(LinearConstraint(np.array(inequalities), np.array(separations) / pitch, np.inf)) compact = [] for node in order: if node not in path_of: @@ -279,14 +280,14 @@ def _place_geometry_paths(paths, geometry_x, sizes, order): matrix = np.array(compact) metric += matrix.T @ matrix * 4 solution = minimize(lambda values: values @ metric @ values, - np.array([-rows[index] * pitch for index in range(len(paths))], dtype=float), + np.array([-rows[index] for index in range(len(paths))], dtype=float), jac=lambda values: 2 * metric @ values, constraints=constraints, - method="SLSQP", options={"ftol": 1e-5, "maxiter": 300}) + method="SLSQP", options={"ftol": 1e-12, "maxiter": 300}) if not solution.success: raise ValueError(f"Geometry lane constraints could not be placed: {solution.message}") for index, path in enumerate(paths): for node in path: - positions[node] = (positions[node][0], float(solution.x[index])) + positions[node] = (positions[node][0], float(solution.x[index] * pitch)) for node in order: if node not in positions: continue @@ -383,7 +384,7 @@ class BranchSpace: if link.to_node != other.to_node and link.from_socket != other.from_socket) -def _place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, trunks, space, horizontal=None): +def _place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, trunks, space, horizontal=None, *, allow_opposite=False): """Place an upstream module together with its forward-facing exit channels.""" right = min(positions[node][0] for node in destinations) - FIELD_GAP baseline = sum(positions[node][1] for node in owners) / len(owners) @@ -394,6 +395,8 @@ def _place_field_region(local, width, height, destinations, owners, positions, s axis_bottom = axis_top - sizes[output].height core_left = min(positions[node][0] for node in core) side = 1 if floor > axis_top else -1 if ceiling < axis_bottom else 0 + if allow_opposite: + side = 0 desired = right - width front_allowed = not horizontal and all(link.from_node in local for node in local for socket in node.inputs for link in socket.links) @@ -433,6 +436,8 @@ def _place_field_region(local, width, height, destinations, owners, positions, s limits[0], limits[1] + height, *(top + BRANCH_GAP + height for _, _, top, _ in occupied), *(bottom - BRANCH_GAP for _, _, _, bottom in occupied)} + tops.update(top + BRANCH_GAP + height for _, boxes in space.exits for _, _, top, _ in boxes) + tops.update(bottom - BRANCH_GAP for _, boxes in space.exits for _, _, _, bottom in boxes) if front_allowed: tops.add(baseline + height / 2) for top in sorted(tops, key=lambda y: abs(y - height / 2 - baseline)): @@ -443,6 +448,9 @@ def _place_field_region(local, width, height, destinations, owners, positions, s placed_left = max(left, minimum) if horizontal else min(left, desired) placed_left = min([placed_left, *(end - max(GEOMETRY_GAP, abs(target_y - top - source_y) * 0.8) for end, source_y, target_y in constraints)]) + if not horizontal: + # Leave clearance at saturated link constraints for solver roundoff. + placed_left -= 0.01 if horizontal and not minimum <= placed_left <= horizontal[1] - width: continue in_front = placed_left + width + BRANCH_GAP <= core_left @@ -478,6 +486,11 @@ def _place_field_region(local, width, height, destinations, owners, positions, s best = candidate best_exits, best_routes = exits, routes if best is None: + if not allow_opposite: + return _place_field_region( + local, width, height, destinations, owners, positions, sizes, + occupied, limits, core, trunks, space, horizontal, allow_opposite=True, + ) raise ValueError("The field region does not fit its reserved scope") _, _, _, left, top = best for node, (x, y) in local.items(): @@ -645,7 +658,7 @@ def _route_references(node_group, sizes, positions, regions, paths, trunks, prot _replace_link(node_group, link, trunk) -def _zone_width(entry, interior, layout, sizes): +def _zone_width(interior, layout, sizes): """Reserve the body and the actual socket runs at each end of a paired scope.""" body_height = max(sizes[node].height for node in interior) spans = [] @@ -654,8 +667,8 @@ def _zone_width(entry, interior, layout, sizes): for node, (x, y) in layout.positions.items(): for socket in node.inputs: for link in socket.links: - if link.from_node == entry: - delta = top + y - sizes[node].sockets[socket] + sizes[entry].sockets[link.from_socket] + if link.from_node not in layout.positions and link.from_node not in interior: + delta = top + y - sizes[node].sockets[socket] + sizes[link.from_node].sockets[link.from_socket] left = max(left, max(GEOMETRY_GAP, abs(delta) * 0.8) - x) for socket in node.outputs: for link in socket.links: @@ -663,7 +676,7 @@ def _zone_width(entry, interior, layout, sizes): delta = top + y - sizes[node].sockets[socket] + sizes[link.to_node].sockets[link.to_socket] right = max(right, x + sizes[node].width + max(GEOMETRY_GAP, abs(delta) * 0.8)) spans.append(left + right) - return max(spans) + return max(spans) + BRANCH_GAP * 2 def arrange_nodes(node_group): @@ -699,7 +712,7 @@ def arrange_nodes(node_group): layout = field_layout(fields, parents, peers, sizes, gap=FIELD_GAP, target_height=FIELD_HEIGHT) field_layouts.append(layout) local, width, height = layout.positions, layout.width, layout.height - span = _zone_width(entry, interior, layout, sizes) + span = _zone_width(interior, layout, sizes) for node in core: if entry in geometry_parents[node]: spacing[node] = max(spacing.get(node, 0), span) @@ -724,6 +737,7 @@ def arrange_nodes(node_group): layouts.append((remaining, targets, layout.positions, layout.width, layout.height)) reserve = max([FIELD_HEIGHT, *(height for _, _, _, _, height in layouts), *(height for _, _, _, _, _, _, height in zones)]) * 2 + BRANCH_GAP * 2 + reserve += sum(height + BRANCH_GAP * 2 for _, _, _, _, _, _, height in zones) limits = (max(y for _, y in positions.values()) + reserve, min(y - sizes[node].height for node, (_, y) in positions.items()) - reserve) for entry, end, interior, fields, local, width, height in zones: diff --git a/tools/nodes/arrangement/dimensions.json b/tools/nodes/arrangement/dimensions.json index cc712d0..6300571 100644 --- a/tools/nodes/arrangement/dimensions.json +++ b/tools/nodes/arrangement/dimensions.json @@ -14,9 +14,9 @@ "FunctionNodeSeparateColor": 1, "GeometryNodeAccumulateField": 2, "GeometryNodeAttributeStatistic": 2, + "GeometryNodeAttributeDomainSize": 1, "GeometryNodeBlurAttribute": 1, "GeometryNodeBoundBox": 0, - "GeometryNodeCaptureAttribute": 3, "GeometryNodeCornersOfEdge": 0, "GeometryNodeCornersOfFace": 0, "GeometryNodeDeleteGeometry": 2, diff --git a/tools/nodes/arrangement/dimensions.py b/tools/nodes/arrangement/dimensions.py index b56980e..1f7c41b 100644 --- a/tools/nodes/arrangement/dimensions.py +++ b/tools/nodes/arrangement/dimensions.py @@ -40,7 +40,7 @@ def estimate_node_dimensions(node): # Conservative reserves include spacing between UI sections. controls = RULES["controls"][node.bl_idname] - for collection in ("repeat_items", "state_items", "input_items", "main_items", "generation_items", "capture_items", "enum_items"): + for collection in ("repeat_items", "state_items", "input_items", "main_items", "generation_items", "enum_items"): controls += len(getattr(node, collection, ())) if node.bl_idname == "GeometryNodeGroup" and node.node_tree: controls += sum(item.item_type == "PANEL" for item in node.node_tree.interface.items_tree) diff --git a/tools/nodes/check.py b/tools/nodes/check.py index 158de06..97db36e 100644 --- a/tools/nodes/check.py +++ b/tools/nodes/check.py @@ -19,6 +19,16 @@ def check_asset_library(asset_path): with bpy.data.libraries.load(str(asset_path), link=True) as (source, target): assert set(source.node_groups) == GEOMETRY_NAMES | SHADER_NAMES target.node_groups = sorted(GEOMETRY_NAMES | SHADER_NAMES) + pending, checked = list(target.node_groups), set() + while pending: + group = pending.pop() + if group in checked or group.bl_idname != "GeometryNodeTree": + continue + checked.add(group) + for node in group.nodes: + assert node.bl_idname != "GeometryNodeCaptureAttribute", group.name + if node.bl_idname == "GeometryNodeGroup" and node.node_tree: + pending.append(node.node_tree) depth_image = bpy.data.images.new("Asset Check Depth", width=8, height=8, float_buffer=True) depth_image.colorspace_settings.name = "Non-Color" depth_image.pixels.foreach_set([ @@ -31,7 +41,11 @@ def check_asset_library(asset_path): if expected_type == "ShaderNodeTree": continue mesh = bpy.data.meshes.new("Asset Check Plane") - mesh.from_pydata([(-0.5, -0.5, 0), (0.5, -0.5, 0), (0.5, 0.5, 0), (-0.5, 0.5, 0)], [], [(0, 1, 2, 3)]) + mesh.from_pydata( + [(x / 3 - 0.5, y / 3 - 0.5, 0) for y in range(4) for x in range(4)], [], + [(y * 4 + x, y * 4 + x + 1, (y + 1) * 4 + x + 1, (y + 1) * 4 + x) + for y in range(3) for x in range(3)], + ) uv = mesh.uv_layers.new(name="UVMap") for loop in mesh.loops: position = mesh.vertices[loop.vertex_index].co @@ -55,6 +69,12 @@ def check_asset_library(asset_path): try: assert len(result.vertices) and len(result.polygons), node_group.name assert all(isfinite(value) for vertex in result.vertices for value in vertex.co), node_group.name + assert not list(modifier.node_warnings), ( + node_group.name, [warning.message for warning in modifier.node_warnings], + ) + assert not {attribute.name for attribute in result.attributes} & { + ".o_anyimage_depth_cut", ".o_anyimage_original_boundary", + }, node_group.name finally: evaluated.to_mesh_clear() bpy.data.objects.remove(obj, do_unlink=True) diff --git a/tools/nodes/common/boundary_smoothing.py b/tools/nodes/common/boundary_smoothing.py new file mode 100644 index 0000000..7ed05a3 --- /dev/null +++ b/tools/nodes/common/boundary_smoothing.py @@ -0,0 +1,79 @@ +"""Apply boundary-preserving smoothing within a narrow cut-edge band.""" + +from .nodes import evaluate_field, group_input +from .depth_surface import _math +from .smoothing import edge_boundary_field, expand_smooth + + +def boundary_influence(group, protected=False): + """Fade a boundary field through two rings while excluding protected points.""" + nodes, links = group.nodes, group.links + boundary = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") + unprotected = _math(group, "SUBTRACT", 1, protected) + cut = _math(group, "MULTIPLY", boundary, unprotected) + blur = nodes.new("GeometryNodeBlurAttribute") + blur.data_type = "FLOAT" + blur.inputs["Iterations"].default_value = 2 + blur.inputs["Weight"].default_value = 1 + links.new(cut, blur.inputs["Value"]) + return _math(group, "MULTIPLY", _math(group, "MAXIMUM", cut, blur.outputs["Value"]), unprotected) + + +def smooth_boundary_depth(group, depth, protected=False): + """Blur point depth on the current mesh and apply it only in the edge band.""" + nodes, links = group.nodes, group.links + depth = evaluate_field(group, depth, "FLOAT", "POINT") + iterations = group_input(nodes, {"Boundary Smooth"}).outputs["Boundary Smooth"] + influence = boundary_influence(group, protected) + blur = nodes.new("GeometryNodeBlurAttribute") + blur.data_type = "FLOAT" + blur.inputs["Weight"].default_value = 0.5 + links.new(depth, blur.inputs["Value"]) + links.new(iterations, blur.inputs["Iterations"]) + mix = nodes.new("ShaderNodeMix") + mix.data_type = "FLOAT" + links.new(influence, mix.inputs[0]) + links.new(depth, mix.inputs[2]) + links.new(blur.outputs["Value"], mix.inputs[3]) + choose = nodes.new("GeometryNodeSwitch") + choose.input_type = "FLOAT" + links.new(influence, choose.inputs["Switch"]) + links.new(depth, choose.inputs["False"]) + links.new(mix.outputs[0], choose.inputs["True"]) + enabled = nodes.new("GeometryNodeSwitch") + enabled.input_type = "FLOAT" + links.new(iterations, enabled.inputs["Switch"]) + links.new(depth, enabled.inputs["False"]) + links.new(choose.outputs[0], enabled.inputs["True"]) + return enabled.outputs[0] + + +def smooth_boundary_camera(group, camera, protected=False): + """Reconstruct blurred camera depth along each unchanged projection ray.""" + nodes, links = group.nodes, group.links + axes = nodes.new("ShaderNodeSeparateXYZ") + links.new(camera, axes.inputs[0]) + depth = axes.outputs["Z"] + smoothed = smooth_boundary_depth(group, depth, protected) + changed = _math(group, "GREATER_THAN", _math(group, "ABSOLUTE", _math(group, "SUBTRACT", smoothed, depth)), 0) + enabled = _math(group, "MULTIPLY", changed, _math(group, "GREATER_THAN", depth, 1e-8)) + projected = nodes.new("ShaderNodeVectorMath") + projected.operation = "SCALE" + links.new(camera, projected.inputs[0]) + links.new(_math(group, "DIVIDE", smoothed, depth), projected.inputs[3]) + choose = nodes.new("GeometryNodeSwitch") + choose.input_type = "VECTOR" + links.new(enabled, choose.inputs["Switch"]) + links.new(camera, choose.inputs["False"]) + links.new(projected.outputs[0], choose.inputs["True"]) + return choose.outputs[0] + + +def smooth_cut_boundary(group, geometry, original_boundary=False): + """Blend O Smooth's pinned-boundary result through a two-ring edge mask.""" + influence = boundary_influence(group, original_boundary) + # The support band depends on topology, which stays unchanged in the loop. + return expand_smooth( + group, geometry, iterations_name="Boundary Smooth", weight_name="Smooth Weight", + influence=influence, pin_sharp=False, + ) diff --git a/tools/nodes/common/cutout_boundary.py b/tools/nodes/common/cutout_boundary.py new file mode 100644 index 0000000..b66047b --- /dev/null +++ b/tools/nodes/common/cutout_boundary.py @@ -0,0 +1,45 @@ +"""Clean cutout boundary triangles and taper the split thickness profile.""" + +from .depth_surface import _math +from .nodes import evaluate_field, group_input, read_float_attribute, sample_field, store_float_attribute +from .smoothing import edge_boundary_field +from .boundary_smoothing import boundary_influence + + +def remove_boundary_triangles(group, geometry): + """Remove all-boundary triangles once on the current topology.""" + nodes, links = group.nodes, group.links + rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") + all_boundary = _math(group, "GREATER_THAN", evaluate_field(group, rim, "FLOAT", "FACE"), 0.99999) + count = nodes.new("GeometryNodeInputMeshFaceNeighbors").outputs["Vertex Count"] + selected = _math(group, "MULTIPLY", all_boundary, _math(group, "COMPARE", count, 3)) + delete = nodes.new("GeometryNodeDeleteGeometry") + delete.domain, delete.mode = "FACE", "ALL" + links.new(geometry, delete.inputs["Geometry"]) + links.new(selected, delete.inputs["Selection"]) + return delete.outputs["Geometry"] + + +def taper_split_profile(group, original, geometry): + """Taper balloon thickness through the shared two-ring influence builder.""" + nodes, links = group.nodes, group.links + uv = nodes.new("GeometryNodeInputNamedAttribute") + uv.data_type = "FLOAT_VECTOR" + uv.inputs["Name"].default_value = "UVMap" + original_uv = nodes.new("GeometryNodeSetPosition") + links.new(original, original_uv.inputs["Geometry"]) + links.new(uv.outputs["Attribute"], original_uv.inputs["Position"]) + nearest = nodes.new("GeometryNodeSampleNearest") + nearest.domain = "POINT" + links.new(original_uv.outputs["Geometry"], nearest.inputs["Geometry"]) + links.new(uv.outputs["Attribute"], nearest.inputs["Sample Position"]) + rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") + protected = sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"]) + influence = boundary_influence(group, protected) + controls = group_input(nodes, {"Edge Round", "Split Threshold"}) + strength = _math(group, "ADD", 1, _math(group, "MULTIPLY", controls.outputs["Edge Round"], 4)) + band = _math(group, "MINIMUM", _math(group, "MULTIPLY", influence, strength), 1) + band = _math(group, "MULTIPLY", band, _math(group, "GREATER_THAN", controls.outputs["Split Threshold"], 0)) + retained = _math(group, "SQRT", _math(group, "MAXIMUM", _math(group, "SUBTRACT", 1, band), 0)) + profile = _math(group, "MULTIPLY", read_float_attribute(group, "o_balloon"), retained) + return store_float_attribute(group, geometry, "o_balloon", profile) diff --git a/tools/nodes/common/depth_surface.py b/tools/nodes/common/depth_surface.py index 2d83ba6..ff48481 100644 --- a/tools/nodes/common/depth_surface.py +++ b/tools/nodes/common/depth_surface.py @@ -1,6 +1,9 @@ """Face separation and connected thickness for Depth Surface assets.""" -from .nodes import capture_fields, evaluate_field, store_float_attribute +from .nodes import evaluate_field, store_float_attribute, store_boolean_attribute, remove_attributes, sample_field + + +CUT_ATTRIBUTE = ".o_anyimage_depth_cut" def _math(group, operation, a, b=None): @@ -211,10 +214,9 @@ def split_depth_surface(group, geometry, face_camera, strength, uniform, *, cut_vertices.data_type = "FLOAT" cut_vertices.domain = "POINT" links.new(cut_edges.outputs["Value"], cut_vertices.inputs["Value"]) - corners, captured = capture_fields( - group, geometry, "CORNER", - Camera=("VECTOR", face_camera), - Cut=("BOOLEAN", _math(group, "GREATER_THAN", cut_vertices.outputs["Value"], 0.0)), + corners, cut = store_boolean_attribute( + group, geometry, CUT_ATTRIBUTE, + _math(group, "GREATER_THAN", cut_vertices.outputs["Value"], 0.0), domain="CORNER", ) split = nodes.new("GeometryNodeSplitEdges") links.new(corners, split.inputs["Mesh"]) @@ -225,17 +227,16 @@ def split_depth_surface(group, geometry, face_camera, strength, uniform, *, links.new(enabled, choose.inputs["Switch"]) links.new(geometry, choose.inputs["False"]) links.new(_remove_strip_faces(group, split.outputs["Mesh"], triangles=triangles), choose.inputs["True"]) - return choose.outputs["Output"], captured["Camera"], captured["Cut"] + return choose.outputs["Output"], face_camera, cut def build_surface_camera(group, original, separated, cut_vertex, strength): """Change cut depth along the original ray, preserving every other vertex.""" - enabled = _math( - group, - "MULTIPLY", - _math(group, "GREATER_THAN", strength, 0.0), - cut_vertex, - ) + enabled = group.nodes.new("GeometryNodeSwitch") + enabled.input_type = "BOOLEAN" + group.links.new(_math(group, "GREATER_THAN", strength, 0.0), enabled.inputs["Switch"]) + enabled.inputs["False"].default_value = False + group.links.new(cut_vertex, enabled.inputs["True"]) original_axes = group.nodes.new("ShaderNodeSeparateXYZ") separated_axes = group.nodes.new("ShaderNodeSeparateXYZ") group.links.new(original, original_axes.inputs[0]) @@ -249,7 +250,7 @@ def build_surface_camera(group, original, separated, cut_vertex, strength): group.links.new(ratio, projected.inputs[3]) choose = group.nodes.new("GeometryNodeSwitch") choose.input_type = "VECTOR" - group.links.new(enabled, choose.inputs["Switch"]) + group.links.new(enabled.outputs["Output"], choose.inputs["Switch"]) group.links.new(original, choose.inputs["False"]) group.links.new(projected.outputs[0], choose.inputs["True"]) return evaluate_field(group, choose.outputs["Output"], "FLOAT_VECTOR", "POINT") @@ -261,36 +262,32 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *, nodes, links = group.nodes, group.links position = nodes.new("GeometryNodeInputPosition") index = nodes.new("GeometryNodeInputIndex") - fields = {"Position": ("VECTOR", position.outputs[0]), "Vertex": ("INT", index.outputs[0])} - if normal_smooth is not None: - normal = nodes.new("GeometryNodeInputNormal") - blur = nodes.new("GeometryNodeBlurAttribute") - blur.data_type = "FLOAT_VECTOR" - links.new(normal_smooth, blur.inputs["Iterations"]) - links.new(normal.outputs[0], blur.inputs["Value"]) - normalize = nodes.new("ShaderNodeVectorMath") - normalize.operation = "NORMALIZE" - links.new(blur.outputs[0], normalize.inputs[0]) - fields["Normal"] = ("VECTOR", normalize.outputs[0]) - geometry, captured = capture_fields(group, geometry, "POINT", **fields) - flattened = nodes.new("ShaderNodeVectorMath") - flattened.operation = "MULTIPLY" - flattened.inputs[1].default_value = (1.0, 1.0, 0.0) - links.new(position.outputs[0], flattened.inputs[0]) - sheet = nodes.new("GeometryNodeSetPosition") - links.new(geometry, sheet.inputs["Geometry"]) - links.new(flattened.outputs[0], sheet.inputs["Position"]) - saved = sheet.outputs["Geometry"] - - # Weld in an integer identity grid, never in scene coordinates. Nearby cuts - # retain distinct IDs; copies of one rim vertex share the same ID and layer. + # Encode identity in the construction geometry before extrusion. The source + # surface remains a separate sampling input; no position or ID is stored. identity = nodes.new("ShaderNodeCombineXYZ") for operation, axis in (("MODULO", "X"), ("DIVIDE", "Y")): part = nodes.new("FunctionNodeIntegerMath") part.operation = operation part.inputs[1].default_value = 4096 - links.new(captured["Vertex"], part.inputs[0]) + links.new(index.outputs[0], part.inputs[0]) links.new(part.outputs[0], identity.inputs[axis]) + sheet = nodes.new("GeometryNodeSetPosition") + links.new(geometry, sheet.inputs["Geometry"]) + links.new(identity.outputs[0], sheet.inputs["Position"]) + saved = sheet.outputs["Geometry"] + layer_axes = nodes.new("ShaderNodeSeparateXYZ") + links.new(position.outputs[0], layer_axes.inputs[0]) + row = nodes.new("FunctionNodeIntegerMath") + row.operation = "MULTIPLY" + row.inputs[1].default_value = 4096 + links.new(layer_axes.outputs["Y"], row.inputs[0]) + vertex = nodes.new("FunctionNodeIntegerMath") + vertex.operation = "ADD" + links.new(layer_axes.outputs["X"], vertex.inputs[0]) + links.new(row.outputs[0], vertex.inputs[1]) + source_index = vertex.outputs[0] + source_position = sample_field(group, geometry, position.outputs[0], "FLOAT_VECTOR", index=source_index) + source_thickness = sample_field(group, geometry, thickness, "FLOAT", index=source_index) extrude = nodes.new("GeometryNodeExtrudeMesh") extrude.mode = "FACES" @@ -315,53 +312,62 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *, join = nodes.new("GeometryNodeJoinGeometry") rear = flip_back.outputs["Mesh"] rim = flip_sides.outputs["Mesh"] + back_parts = nodes.new("GeometryNodeJoinGeometry") + links.new(rear, back_parts.inputs["Geometry"]) + links.new(rim, back_parts.inputs["Geometry"]) + rear = back_parts.outputs["Geometry"] if back_mask is not None: rear = store_float_attribute(group, rear, back_mask, 0.0, domain="FACE") - rim = store_float_attribute(group, rim, back_mask, 0.0, domain="FACE") for value in ( saved, rear, - rim, ): links.new(value, join.inputs["Geometry"]) - encoded = nodes.new("GeometryNodeSetPosition") - links.new(join.outputs["Geometry"], encoded.inputs["Geometry"]) - links.new(identity.outputs[0], encoded.inputs["Position"]) + construction = join.outputs["Geometry"] + if weld_zero_thickness: + positive = _math(group, "GREATER_THAN", source_thickness, 1e-12) + flattened = nodes.new("ShaderNodeCombineXYZ") + for axis in ("X", "Y"): + links.new(layer_axes.outputs[axis], flattened.inputs[axis]) + links.new(_math(group, "MULTIPLY", positive, layer_axes.outputs["Z"]), flattened.inputs["Z"]) + collapse = nodes.new("GeometryNodeSetPosition") + links.new(construction, collapse.inputs["Geometry"]) + links.new(flattened.outputs[0], collapse.inputs["Position"]) + construction = collapse.outputs["Geometry"] merge = nodes.new("GeometryNodeMergeByDistance") merge.mode = "ALL" merge.inputs["Distance"].default_value = 0.1 - links.new(encoded.outputs["Geometry"], merge.inputs["Geometry"]) + links.new(construction, merge.inputs["Geometry"]) displacement = nodes.new("ShaderNodeCombineXYZ") - layer_position = nodes.new("GeometryNodeInputPosition") - layer_axes = nodes.new("ShaderNodeSeparateXYZ") - links.new(layer_position.outputs[0], layer_axes.inputs[0]) - distance = _math(group, "MULTIPLY", layer_axes.outputs["Z"], thickness) + distance = _math(group, "MULTIPLY", layer_axes.outputs["Z"], source_thickness) links.new(distance, displacement.inputs["Z"]) restored = nodes.new("ShaderNodeVectorMath") restored.operation = "ADD" - links.new(captured["Position"], restored.inputs[0]) + links.new(source_position, restored.inputs[0]) links.new(displacement.outputs[0], restored.inputs[1]) result = nodes.new("GeometryNodeSetPosition") links.new(merge.outputs["Geometry"], result.inputs["Geometry"]) links.new(restored.outputs[0], result.inputs["Position"]) - local_position = nodes.new("GeometryNodeInputPosition") - local_axes = nodes.new("ShaderNodeSeparateXYZ") - links.new(local_position.outputs[0], local_axes.inputs[0]) - layer = local_axes.outputs["Z"] - if weld_zero_thickness: - positive = _math(group, "GREATER_THAN", thickness, 1e-12) - layer = _math(group, "MULTIPLY", positive, layer) - links.new(layer, identity.inputs["Z"]) if normal_smooth is not None: + normal = nodes.new("GeometryNodeInputNormal") + blur = nodes.new("GeometryNodeBlurAttribute") + blur.data_type = "FLOAT_VECTOR" + links.new(normal_smooth, blur.inputs["Iterations"]) + links.new(normal.outputs[0], blur.inputs["Value"]) + normalize = nodes.new("ShaderNodeVectorMath") + normalize.operation = "NORMALIZE" + links.new(blur.outputs[0], normalize.inputs[0]) + source_normal = sample_field(group, geometry, normalize.outputs[0], "FLOAT_VECTOR", index=source_index) normal_offset = nodes.new("ShaderNodeVectorMath") normal_offset.operation = "SCALE" - links.new(captured["Normal"], normal_offset.inputs[0]) + links.new(source_normal, normal_offset.inputs[0]) links.new(distance, normal_offset.inputs[3]) links.new(normal_offset.outputs[0], restored.inputs[1]) nodes.remove(displacement) return { "geometry": result.outputs["Geometry"], - "front": captured["Position"], + "front": source_position, + "source_index": source_index, "position": restored.outputs[0], "position_input": result.inputs["Position"], } @@ -374,7 +380,7 @@ def _projection_math(nodes, operation): def project_depth_surface( - group, geometry, camera_position, uniform_scale, reference_depth, depth_scale + group, geometry, camera_position, uniform_scale, reference_depth, depth_scale, split_strength ): """Interpolate the rectangular plane into the model camera coordinates.""" nodes, links = group.nodes, group.links @@ -414,4 +420,7 @@ def project_depth_surface( links.new(geometry, project_geometry.inputs["Geometry"]) links.new(surface_position.outputs["Vector"], project_geometry.inputs["Position"]) - return project_geometry.outputs["Geometry"] + return remove_attributes( + group, project_geometry.outputs["Geometry"], CUT_ATTRIBUTE, + enabled=_math(group, "GREATER_THAN", split_strength, 0.0), + ) diff --git a/tools/nodes/common/material.py b/tools/nodes/common/material.py index 9bdc8ef..ff38a6a 100644 --- a/tools/nodes/common/material.py +++ b/tools/nodes/common/material.py @@ -3,20 +3,23 @@ def inherit_material_slots(group, geometry, source): nodes, links = group.nodes, group.links - capture = nodes.new("GeometryNodeCaptureAttribute") - capture.domain = "POINT" - capture.capture_items.clear() - capture.capture_items.new("BOOLEAN", "Source") - capture.inputs["Source"].default_value = True - links.new(source, capture.inputs["Geometry"]) + size = nodes.new("GeometryNodeAttributeDomainSize") + size.component = "MESH" + links.new(source, size.inputs["Geometry"]) + index = nodes.new("GeometryNodeInputIndex") + source_vertices = nodes.new("FunctionNodeCompare") + source_vertices.data_type, source_vertices.operation = "INT", "LESS_THAN" + links.new(index.outputs["Index"], source_vertices.inputs[2]) + links.new(size.outputs["Point Count"], source_vertices.inputs[3]) join = nodes.new("GeometryNodeJoinGeometry") + # Multi-input links are prepended: source occupies the first point range. links.new(geometry, join.inputs["Geometry"]) - links.new(capture.outputs["Geometry"], join.inputs["Geometry"]) + links.new(source, join.inputs["Geometry"]) material = nodes.new("GeometryNodeSetMaterialIndex") material.inputs["Material Index"].default_value = 0 links.new(join.outputs["Geometry"], material.inputs["Geometry"]) delete = nodes.new("GeometryNodeDeleteGeometry") delete.domain = "POINT" links.new(material.outputs["Geometry"], delete.inputs["Geometry"]) - links.new(capture.outputs["Source"], delete.inputs["Selection"]) + links.new(source_vertices.outputs["Result"], delete.inputs["Selection"]) return delete.outputs["Geometry"] diff --git a/tools/nodes/common/nodes.py b/tools/nodes/common/nodes.py index b04b584..c873074 100644 --- a/tools/nodes/common/nodes.py +++ b/tools/nodes/common/nodes.py @@ -114,13 +114,45 @@ def evaluate_field(group, value, data_type, domain): return node.outputs["Value"] -def capture_fields(group, geometry, domain, **fields): - """Keep source fields attached to geometry through later changes.""" - node = group.nodes.new("GeometryNodeCaptureAttribute") - node.domain = domain - node.capture_items.clear() +def sample_field(group, geometry, value, data_type, domain="POINT", index=None): + """Evaluate a field on an explicit source geometry and index domain.""" + node = group.nodes.new("GeometryNodeSampleIndex") + node.data_type, node.domain = data_type, domain + node.clamp = False + if index is None: + index = group.nodes.new("GeometryNodeInputIndex").outputs["Index"] group.links.new(geometry, node.inputs["Geometry"]) - for name, (data_type, value) in fields.items(): - node.capture_items.new(data_type, name) - group.links.new(value, node.inputs[name]) - return node.outputs["Geometry"], {name: node.outputs[name] for name in fields} + group.links.new(value, node.inputs["Value"]) + group.links.new(index, node.inputs["Index"]) + return node.outputs["Value"] + + +def store_boolean_attribute(group, geometry, name, value, domain="POINT"): + """Store a topology marker needed after destructive mesh operations.""" + node = group.nodes.new("GeometryNodeStoreNamedAttribute") + node.domain, node.data_type = domain, "BOOLEAN" + node.inputs["Name"].default_value = name + group.links.new(geometry, node.inputs["Geometry"]) + group.links.new(value, node.inputs["Value"]) + read = group.nodes.new("GeometryNodeInputNamedAttribute") + read.data_type = "BOOLEAN" + read.inputs["Name"].default_value = name + return node.outputs["Geometry"], read.outputs["Attribute"] + + +def remove_attributes(group, geometry, *names, enabled=None): + """Remove only the explicitly owned temporary attributes.""" + original = geometry + for name in names: + node = group.nodes.new("GeometryNodeRemoveAttribute") + node.inputs["Name"].default_value = name + group.links.new(geometry, node.inputs["Geometry"]) + geometry = node.outputs["Geometry"] + if enabled is not None: + choose = group.nodes.new("GeometryNodeSwitch") + choose.input_type = "GEOMETRY" + group.links.new(enabled, choose.inputs["Switch"]) + group.links.new(original, choose.inputs["False"]) + group.links.new(geometry, choose.inputs["True"]) + geometry = choose.outputs["Output"] + return geometry diff --git a/tools/nodes/common/smoothing.py b/tools/nodes/common/smoothing.py index 91ca6fa..6bcbfa0 100644 --- a/tools/nodes/common/smoothing.py +++ b/tools/nodes/common/smoothing.py @@ -1,5 +1,5 @@ """Build shared boundary fields and iterative smoothing.""" -from .nodes import group_input +from .nodes import group_input, sample_field def edge_boundary_field(nodes, links): @@ -41,7 +41,8 @@ def edge_boundary_field(nodes, links): def expand_smooth( - group, geometry, iterations_name="Iterations", weight_name="Smooth" + group, geometry, iterations_name="Iterations", weight_name="Smooth", *, + influence=None, pin_sharp=True, ): nodes = group.nodes links = group.links @@ -55,7 +56,6 @@ def expand_smooth( iteration_controls.outputs[iterations_name], repeat_input.inputs["Iterations"], ) - links.new(geometry, repeat_input.inputs["Geometry"]) boundary_field = edge_boundary_field(nodes, links) boundary_points = nodes.new("GeometryNodeFieldOnDomain") @@ -67,6 +67,21 @@ def expand_smooth( boundary_geometry.domain = "POINT" links.new(repeat_input.outputs["Geometry"], boundary_geometry.inputs["Geometry"]) links.new(boundary_points.outputs["Value"], boundary_geometry.inputs["Selection"]) + links.new(geometry, repeat_input.inputs["Geometry"]) + nearest_boundary = nodes.new("GeometryNodeSampleNearest") + nearest_boundary.domain = "POINT" + if influence is not None: + # Both boundary meshes keep the same compact index order: only positions change. + source_boundary = nodes.new("GeometryNodeSeparateGeometry") + source_boundary.domain = "POINT" + links.new(geometry, source_boundary.inputs["Geometry"]) + links.new(boundary_points.outputs["Value"], source_boundary.inputs["Selection"]) + links.new(source_boundary.outputs["Selection"], nearest_boundary.inputs["Geometry"]) + source_position = nodes.new("GeometryNodeInputPosition").outputs["Position"] + fixed_position = sample_field(group, geometry, source_position, "FLOAT_VECTOR") + links.new(fixed_position, nearest_boundary.inputs["Sample Position"]) + else: + links.new(boundary_geometry.outputs["Selection"], nearest_boundary.inputs["Geometry"]) position = nodes.new("GeometryNodeInputPosition") smooth_all = nodes.new("GeometryNodeBlurAttribute") @@ -81,9 +96,6 @@ def expand_smooth( smooth_boundary.inputs["Iterations"].default_value = 1 smooth_boundary.inputs["Weight"].default_value = 0.5 links.new(boundary_position.outputs["Position"], smooth_boundary.inputs["Value"]) - nearest_boundary = nodes.new("GeometryNodeSampleNearest") - nearest_boundary.domain = "POINT" - links.new(boundary_geometry.outputs["Selection"], nearest_boundary.inputs["Geometry"]) sample_boundary = nodes.new("GeometryNodeSampleIndex") sample_boundary.data_type = "FLOAT_VECTOR" sample_boundary.domain = "POINT" @@ -94,8 +106,7 @@ def expand_smooth( choose_boundary = nodes.new("GeometryNodeSwitch") choose_boundary.input_type = "VECTOR" - direct_boundary = edge_boundary_field(nodes, links) - links.new(direct_boundary, choose_boundary.inputs["Switch"]) + links.new(boundary_points.outputs["Value"], choose_boundary.inputs["Switch"]) links.new(smooth_all.outputs["Value"], choose_boundary.inputs["False"]) links.new(sample_boundary.outputs["Value"], choose_boundary.inputs["True"]) boundary_mix = nodes.new("ShaderNodeMix") @@ -104,24 +115,33 @@ def expand_smooth( links.new(smooth_all.outputs["Value"], boundary_mix.inputs[4]) links.new(choose_boundary.outputs["Output"], boundary_mix.inputs[5]) - normal = nodes.new("GeometryNodeInputNormal") - smooth_normal = nodes.new("GeometryNodeBlurAttribute") - smooth_normal.data_type = "FLOAT_VECTOR" - smooth_normal.inputs["Iterations"].default_value = 10 - smooth_normal.inputs["Weight"].default_value = 1.0 - links.new(normal.outputs["Normal"], smooth_normal.inputs["Value"]) - normal_coherence = nodes.new("ShaderNodeVectorMath") - normal_coherence.operation = "LENGTH" - links.new(smooth_normal.outputs["Value"], normal_coherence.inputs[0]) - sharp_switch = nodes.new("GeometryNodeSwitch") - sharp_switch.input_type = "FLOAT" - sharp_switch.inputs["Switch"].default_value = True - sharp_switch.inputs["False"].default_value = 1.0 - links.new(normal_coherence.outputs["Value"], sharp_switch.inputs["True"]) - smooth_weight = nodes.new("ShaderNodeMath") - smooth_weight.operation = "MULTIPLY" - links.new(weight_controls.outputs[weight_name], smooth_weight.inputs[0]) - links.new(sharp_switch.outputs["Output"], smooth_weight.inputs[1]) + weight = weight_controls.outputs[weight_name] + if pin_sharp: + normal = nodes.new("GeometryNodeInputNormal") + smooth_normal = nodes.new("GeometryNodeBlurAttribute") + smooth_normal.data_type = "FLOAT_VECTOR" + smooth_normal.inputs["Iterations"].default_value = 10 + smooth_normal.inputs["Weight"].default_value = 1.0 + links.new(normal.outputs["Normal"], smooth_normal.inputs["Value"]) + normal_coherence = nodes.new("ShaderNodeVectorMath") + normal_coherence.operation = "LENGTH" + links.new(smooth_normal.outputs["Value"], normal_coherence.inputs[0]) + sharp_switch = nodes.new("GeometryNodeSwitch") + sharp_switch.input_type = "FLOAT" + sharp_switch.inputs["Switch"].default_value = pin_sharp + sharp_switch.inputs["False"].default_value = 1.0 + links.new(normal_coherence.outputs["Value"], sharp_switch.inputs["True"]) + smooth_weight = nodes.new("ShaderNodeMath") + smooth_weight.operation = "MULTIPLY" + links.new(weight_controls.outputs[weight_name], smooth_weight.inputs[0]) + links.new(sharp_switch.outputs["Output"], smooth_weight.inputs[1]) + weight = smooth_weight.outputs[0] + if influence is not None: + masked_weight = nodes.new("ShaderNodeMath") + masked_weight.operation = "MULTIPLY" + links.new(weight, masked_weight.inputs[0]) + links.new(influence, masked_weight.inputs[1]) + weight = masked_weight.outputs[0] target_position = nodes.new("GeometryNodeSwitch") target_position.input_type = "VECTOR" @@ -131,11 +151,18 @@ def expand_smooth( current_position = nodes.new("GeometryNodeInputPosition") mix_position = nodes.new("ShaderNodeMix") mix_position.data_type = "VECTOR" - links.new(smooth_weight.outputs[0], mix_position.inputs[0]) + links.new(weight, mix_position.inputs[0]) links.new(current_position.outputs["Position"], mix_position.inputs[4]) links.new(target_position.outputs["Output"], mix_position.inputs[5]) set_position = nodes.new("GeometryNodeSetPosition") links.new(repeat_input.outputs["Geometry"], set_position.inputs["Geometry"]) links.new(mix_position.outputs["Result"], set_position.inputs["Position"]) links.new(set_position.outputs["Geometry"], repeat_output.inputs["Geometry"]) + if influence is not None: + enabled = nodes.new("GeometryNodeSwitch") + enabled.input_type = "GEOMETRY" + links.new(iteration_controls.outputs[iterations_name], enabled.inputs["Switch"]) + links.new(geometry, enabled.inputs["False"]) + links.new(repeat_output.outputs["Geometry"], enabled.inputs["True"]) + return enabled.outputs["Output"] return repeat_output.outputs["Geometry"] diff --git a/tools/nodes/common/validity.py b/tools/nodes/common/validity.py new file mode 100644 index 0000000..010eef4 --- /dev/null +++ b/tools/nodes/common/validity.py @@ -0,0 +1,21 @@ +"""Remove invalid vertices and the wire edges left by point deletion.""" + + +def delete_invalid_points(group, geometry, invalid): + nodes, links = group.nodes, group.links + delete = nodes.new("GeometryNodeDeleteGeometry") + delete.domain, delete.mode = "POINT", "ALL" + links.new(geometry, delete.inputs["Geometry"]) + links.new(invalid, delete.inputs["Selection"]) + neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") + wire = nodes.new("FunctionNodeCompare") + wire.data_type, wire.operation = "INT", "EQUAL" + a = next(socket for socket in wire.inputs if socket.identifier == "A_INT") + b = next(socket for socket in wire.inputs if socket.identifier == "B_INT") + b.default_value = 0 + links.new(neighbors.outputs["Face Count"], a) + cleanup = nodes.new("GeometryNodeDeleteGeometry") + cleanup.domain, cleanup.mode = "EDGE", "ALL" + links.new(delete.outputs["Geometry"], cleanup.inputs["Geometry"]) + links.new(wire.outputs["Result"], cleanup.inputs["Selection"]) + return cleanup.outputs["Geometry"] diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index 23f670f..a88aed9 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -2,16 +2,51 @@ import bpy from mathutils import Matrix from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME -from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group, capture_fields, evaluate_field +from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group, sample_field, evaluate_field from ..common.smoothing import expand_smooth +from ..common.boundary_smoothing import smooth_boundary_camera, smooth_cut_boundary from ..common.cutout import _island_cleanup, _position_field, _remove_zero_thickness, _shade_output from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera, thicken_depth_surface from ..common.smoothing import edge_boundary_field +from ..common.cutout_boundary import remove_boundary_triangles, taper_split_profile NODE_GROUP_NAME = "O Image Depth Cutout" +def build_normal_weight(group, uniform): + """Scale normal smoothing by the active mode's relative thickness.""" + control = group_input(group.nodes, {"Thickness", "Uniform Thickness", "Reference Depth"}) + amount = group.nodes.new("GeometryNodeSwitch") + amount.input_type = "FLOAT" + group.links.new(uniform, amount.inputs["Switch"]) + group.links.new(control.outputs["Thickness"], amount.inputs["False"]) + ratio = _math(group, "DIVIDE", control.outputs["Uniform Thickness"], + _math(group, "MAXIMUM", control.outputs["Reference Depth"], 1e-8)) + group.links.new(ratio, amount.inputs["True"]) + ramp = group.nodes.new("ShaderNodeMapRange") + ramp.interpolation_type = "SMOOTHSTEP" + ramp.clamp = True + group.links.new(amount.outputs[0], ramp.inputs["Value"]) + return ramp.outputs["Result"] + + +def blend_normal(group, original, smoothed, weight): + """Normalize the thickness-weighted blend on the consumer geometry.""" + mix = group.nodes.new("ShaderNodeMix") + mix.data_type = "VECTOR" + if isinstance(weight, (int, float)): + mix.inputs[0].default_value = weight + else: + group.links.new(weight, mix.inputs[0]) + group.links.new(original, mix.inputs[4]) + group.links.new(smoothed, mix.inputs[5]) + normalize = group.nodes.new("ShaderNodeVectorMath") + normalize.operation = "NORMALIZE" + group.links.new(mix.outputs["Result"], normalize.inputs[0]) + return normalize.outputs[0] + + def build_volume_fields(group): """Evaluate the center and thickness on the original front surface.""" links = group.links @@ -37,7 +72,7 @@ def build_volume_fields(group): return {"rim": rim, "center": center, "thickness": thickness} -def build_volume_rounding(group, geometry, fields, uniform): +def build_volume_rounding(group, geometry, fields, uniform, normal_weight=1): links = group.links control = group.nodes.new("NodeGroupInput") @@ -87,39 +122,36 @@ def build_volume_rounding(group, geometry, fields, uniform): links.new(normal.outputs[0], blur.inputs["Value"]) local = group.nodes.new("NodeGroupInput") links.new(local.outputs["Normal Smooth"], blur.inputs["Iterations"]) - normalize = group.nodes.new("ShaderNodeVectorMath") - normalize.operation = "NORMALIZE" - links.new(blur.outputs[0], normalize.inputs[0]) + normalized = blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight) index = group.nodes.new("GeometryNodeInputIndex") sample = group.nodes.new("GeometryNodeSampleIndex") sample.data_type, sample.domain = "FLOAT_VECTOR", "POINT" links.new(surface.outputs[0], sample.inputs["Geometry"]) - links.new(normalize.outputs[0], sample.inputs["Value"]) + links.new(normalized, sample.inputs["Value"]) links.new(index.outputs[0], sample.inputs["Index"]) thickness = group.nodes.new("GeometryNodeSwitch") thickness.input_type = "FLOAT" links.new(uniform, thickness.inputs["Switch"]) links.new(total, thickness.inputs["False"]) links.new(control.outputs["Uniform Thickness"], thickness.inputs["True"]) - carrier, captured = capture_fields( - group, geometry, "POINT", - edge_weight=("FLOAT", weight), front_delta=("FLOAT", delta), - thickness=("FLOAT", thickness.outputs[0]), normal=("VECTOR", sample.outputs[0]), - ) + rounding = { + "edge_weight": weight, "front_delta": delta, + "thickness": thickness.outputs[0], "normal": sample.outputs[0], + } displacement = group.nodes.new("ShaderNodeCombineXYZ") - links.new(captured["front_delta"], displacement.inputs["Z"]) + links.new(delta, displacement.inputs["Z"]) front = group.nodes.new("GeometryNodeSetPosition") - links.new(carrier, front.inputs["Geometry"]) + links.new(geometry, front.inputs["Geometry"]) links.new(displacement.outputs[0], front.inputs["Offset"]) choose = group.nodes.new("GeometryNodeSwitch") choose.input_type = "GEOMETRY" links.new(uniform, choose.inputs["Switch"]) links.new(front.outputs[0], choose.inputs["False"]) - links.new(carrier, choose.inputs["True"]) - return choose.outputs[0], captured + links.new(geometry, choose.inputs["True"]) + return choose.outputs[0], rounding -def build_front_fields(group, rim, front_normal): +def build_front_fields(group, rim, front_normal, normal_weight=1): """Fit structural amplitude over neighbouring points before creating the shell.""" links = group.links @@ -162,10 +194,8 @@ def build_front_fields(group, rim, front_normal): smooth.data_type = "FLOAT_VECTOR" links.new(normal, smooth.inputs["Value"]) links.new(spread, smooth.inputs["Iterations"]) - normalize = group.nodes.new("ShaderNodeVectorMath") - normalize.operation = "NORMALIZE" - links.new(smooth.outputs[0], normalize.inputs[0]) - return {"structure": structure, "inflation_normal": normalize.outputs[0]} + normalized = blend_normal(group, normal, smooth.outputs[0], normal_weight) + return {"structure": structure, "inflation_normal": normalized} def build_inflation_position(group, position, distance, uniform, fields): @@ -220,7 +250,7 @@ def build_inflation_position(group, position, distance, uniform, fields): return result.outputs[0] -def build_front_normal(group): +def build_front_normal(group, normal_weight): links = group.links normal = group.nodes.new("GeometryNodeInputNormal") control = group.nodes.new("NodeGroupInput") @@ -228,10 +258,8 @@ def build_front_normal(group): blur.data_type = "FLOAT_VECTOR" links.new(normal.outputs[0], blur.inputs["Value"]) links.new(control.outputs["Normal Smooth"], blur.inputs["Iterations"]) - normalize = group.nodes.new("ShaderNodeVectorMath") - normalize.operation = "NORMALIZE" - links.new(blur.outputs[0], normalize.inputs[0]) - return evaluate_field(group, normalize.outputs[0], "FLOAT_VECTOR", "POINT") + normalized = blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight) + return evaluate_field(group, normalized, "FLOAT_VECTOR", "POINT") def build_shell_position(group, position, front, uniform, fields): @@ -316,36 +344,49 @@ def build_shell_position(group, position, front, uniform, fields): def _build_depth_surface(group, geometry, controls): - """Project the cleaned mesh, capture surface fields, and connect the shell.""" + """Project the mesh and sample front-surface fields while shaping the shell.""" links = group.links split = controls.outputs["Split Threshold"] image = controls.outputs["Depth Image"] scale = controls.outputs["Uniform Scale"] + original = geometry geometry, corner_camera, cut_vertex = split_depth_surface( group, geometry, sample_face_camera(group, image), split, scale, triangles=True, ) + geometry = remove_boundary_triangles(group, geometry) camera = build_surface_camera(group, _position_field(group, image), corner_camera, cut_vertex, split) + camera = smooth_boundary_camera(group, camera) projection = project_depth_surface( - group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"], + group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"], split, ) + projection = smooth_cut_boundary(group, projection) + projection = taper_split_profile(group, original, projection) # The mode field is shared by the geometry and thickness choices. mode = menu_switch(group, "Mode", ("Balloon", "Uniform"), "BOOLEAN") mode.inputs["Balloon"].default_value = False mode.inputs["Uniform"].default_value = True uniform = mode.outputs[0] volume = build_volume_fields(group) - normal = build_front_normal(group) - front = build_front_fields(group, volume["rim"], normal) - shared, fields = capture_fields( - group, projection, "POINT", - center=("FLOAT", volume["center"]), front_normal=("VECTOR", normal), - structure=("FLOAT", front["structure"]), inflation_normal=("VECTOR", front["inflation_normal"]), - ) - rounded, rounding = build_volume_rounding(group, shared, {**volume, **fields}, uniform) - fields.update(rounding) + normal_weight = build_normal_weight(group, uniform) + normal = build_front_normal(group, normal_weight) + front = build_front_fields(group, volume["rim"], normal, normal_weight) + rounded, rounding = build_volume_rounding(group, projection, volume, uniform, normal_weight) + thickness = sample_field(group, projection, rounding["thickness"], "FLOAT") shell = thicken_depth_surface( - group, rounded, fields["thickness"], back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True, + group, rounded, thickness, back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True, ) + fields = { + name: sample_field(group, projection, value, data_type, index=shell["source_index"]) + for name, value, data_type in ( + ("center", volume["center"], "FLOAT"), ("front_normal", normal, "FLOAT_VECTOR"), + ("structure", front["structure"], "FLOAT"), ("inflation_normal", front["inflation_normal"], "FLOAT_VECTOR"), + ) + } + fields.update({ + name: sample_field(group, projection, value, "FLOAT_VECTOR" if name == "normal" else "FLOAT", + index=shell["source_index"]) + for name, value in rounding.items() + }) oriented = build_shell_position(group, shell["position"], shell["front"], uniform, fields) links.new(oriented, shell["position_input"]) amount = group.nodes.new("GeometryNodeSwitch") @@ -364,17 +405,20 @@ def build_image_depth_cutout_group(): group = bpy.data.node_groups.new(name, 'GeometryNodeTree') group.is_modifier, group.use_fake_user = (True, True) group.color_tag = 'GEOMETRY' - group['anyimage_asset_revision'] = 11 + group['anyimage_asset_revision'] = 18 interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry') interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu') float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet' float_input(group, 'Uniform Thickness', 0.0, subtype='DISTANCE').description = 'Distance used only by Uniform thickness mode' float_input(group, 'Depth Scale', 1.0) - float_input(group, 'Split Threshold', 0.0, maximum=1, subtype='FACTOR') + float_input(group, 'Split Threshold', 0.1, maximum=1, subtype='FACTOR') iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt') iterations.default_value = 0 iterations.min_value, iterations.max_value = (0, 20) options = group.interface.new_panel(name='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 = 5, 0, 20 + boundary_smooth.description = 'Smooth boundary depth and geometry along the original outline and split edges' float_input(group, 'Cleanup Threshold', 0.0, parent=options) float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options) float_input(group, 'Edge Round', 0.2, maximum=1, subtype='FACTOR', parent=options) diff --git a/tools/nodes/groups/image_depth_panorama.py b/tools/nodes/groups/image_depth_panorama.py index dffd491..abf3d3f 100644 --- a/tools/nodes/groups/image_depth_panorama.py +++ b/tools/nodes/groups/image_depth_panorama.py @@ -4,12 +4,15 @@ import math import bpy +from ..common.validity import delete_invalid_points + from ..common.depth_surface import ( - _math, split_depth_surface, build_surface_camera, + _math, split_depth_surface, build_surface_camera, CUT_ATTRIBUTE, ) -from ..common.nodes import float_input, group_input, interface_socket, prepare_node_group, evaluate_field +from ..common.nodes import float_input, group_input, interface_socket, prepare_node_group, evaluate_field, remove_attributes from ..common.material import inherit_material_slots from ..common.smoothing import expand_smooth +from ..common.boundary_smoothing import smooth_boundary_depth, smooth_cut_boundary def _quad_sphere(group, segments): @@ -36,6 +39,7 @@ def _quad_sphere(group, segments): def build_image_depth_panorama_group(): group = bpy.data.node_groups.new("O Image Depth Panorama", "GeometryNodeTree") group.is_modifier = group.use_fake_user = True + group["anyimage_asset_revision"] = 14 interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") subdivide.default_value, subdivide.min_value, subdivide.max_value = 7, 0, 8 @@ -45,6 +49,9 @@ def build_image_depth_panorama_group(): smooth.default_value, smooth.min_value, smooth.max_value = 0, 0, 20 interface_socket(group, "Valid Only", "INPUT", "NodeSocketBool").default_value = True 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 = 5, 0, 20 + boundary_smooth.description = "Smooth boundary distance and cut-edge geometry on the sphere" float_input(group, "Smooth Weight", 1, maximum=1, subtype="FACTOR", parent=options) float_input(group, "Validity Threshold", 0.9, maximum=1, subtype="FACTOR", parent=options) float_input(group, "Invalid Distance", 100, minimum=1e-5, subtype="DISTANCE", parent=options) @@ -133,18 +140,20 @@ def build_image_depth_panorama_group(): any_invalid = _math(group, "GREATER_THAN", face_invalid, 0) mixed = _math(group, "MULTIPLY", any_invalid, _math(group, "LESS_THAN", face_invalid, 1)) - selection = nodes.new("GeometryNodeSwitch") - selection.input_type = "BOOLEAN" - links.new(group_input(nodes, {"Valid Only"}).outputs["Valid Only"], selection.inputs["Switch"]) - links.new(mixed, selection.inputs["False"]) - links.new(any_invalid, selection.inputs["True"]) + protected = uv_map.outputs["Geometry"] + culled = delete_invalid_points(group, protected, any_invalid) delete = nodes.new("GeometryNodeDeleteGeometry") delete.domain, delete.mode = "FACE", "ALL" - links.new(uv_map.outputs["Geometry"], delete.inputs["Geometry"]) - links.new(selection.outputs[0], delete.inputs["Selection"]) + links.new(protected, delete.inputs["Geometry"]) + links.new(mixed, delete.inputs["Selection"]) + selection = nodes.new("GeometryNodeSwitch") + selection.input_type = "GEOMETRY" + links.new(group_input(nodes, {"Valid Only"}).outputs["Valid Only"], selection.inputs["Switch"]) + links.new(delete.outputs[0], selection.inputs["False"]) + links.new(culled, selection.inputs["True"]) controls = group_input(nodes, {"Split Threshold", "Reference Distance"}) strength = controls.outputs["Split Threshold"] - geometry, corner_camera, cut_vertex = split_depth_surface(group, delete.outputs[0], depth(combine(face_u, face_v))[0], strength, + geometry, corner_camera, cut_vertex = split_depth_surface(group, selection.outputs[0], depth(combine(face_u, face_v))[0], strength, _math(group, "DIVIDE", 1, controls.outputs["Reference Distance"]), face_center=face.outputs["Value"]) distance = build_surface_camera(group, pixel, corner_camera, cut_vertex, strength) @@ -153,7 +162,7 @@ def build_image_depth_panorama_group(): distance = nodes.new("GeometryNodeSwitch") distance.input_type = "FLOAT" links.new(face_invalid, distance.inputs["Switch"]) - links.new(axes.outputs["X"], distance.inputs["False"]) + links.new(smooth_boundary_depth(group, axes.outputs["X"], face_invalid), distance.inputs["False"]) links.new(group_input(nodes, {"Invalid Distance"}).outputs["Invalid Distance"], distance.inputs["True"]) controls = group_input(nodes, {"Depth Scale"}) radius = _math(group, "ADD", 1, _math(group, "MULTIPLY", controls.outputs["Depth Scale"], _math(group, "SUBTRACT", distance.outputs[0], 1))) @@ -164,7 +173,12 @@ def build_image_depth_panorama_group(): deform = nodes.new("GeometryNodeSetPosition") links.new(geometry, deform.inputs["Geometry"]) links.new(radial.outputs[0], deform.inputs["Position"]) - geometry = expand_smooth(group, deform.outputs[0], iterations_name="Smooth", weight_name="Smooth Weight") + geometry = remove_attributes( + group, deform.outputs[0], CUT_ATTRIBUTE, + enabled=_math(group, "GREATER_THAN", strength, 0.0), + ) + geometry = smooth_cut_boundary(group, geometry) + geometry = expand_smooth(group, geometry, iterations_name="Smooth", weight_name="Smooth Weight") flip = nodes.new("GeometryNodeFlipFaces") links.new(geometry, flip.inputs["Mesh"]) smooth = nodes.new("GeometryNodeSetShadeSmooth") diff --git a/tools/nodes/groups/image_depth_plane.py b/tools/nodes/groups/image_depth_plane.py index 7c29efd..7b787bd 100644 --- a/tools/nodes/groups/image_depth_plane.py +++ b/tools/nodes/groups/image_depth_plane.py @@ -2,11 +2,14 @@ import bpy +from ..common.validity import delete_invalid_points + from ..common.nodes import group_input as _group_input, interface_socket as _interface_socket -from ..common.nodes import prepare_node_group, float_input +from ..common.nodes import prepare_node_group, float_input, evaluate_field from ..common.smoothing import expand_smooth +from ..common.boundary_smoothing import smooth_boundary_camera, smooth_cut_boundary from ..common.depth_surface import ( - project_depth_surface, thicken_depth_surface, + _math, project_depth_surface, thicken_depth_surface, split_depth_surface, sample_face_camera, build_surface_camera, ) @@ -22,6 +25,7 @@ def build_image_depth_plane_group(image_plane_group): "GeometryNodeTree", ) group.is_modifier = True + group["anyimage_asset_revision"] = 14 geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") geometry.structure_type = "SINGLE" subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") @@ -43,6 +47,9 @@ def build_image_depth_plane_group(image_plane_group): valid_only = _interface_socket(group, "Valid Only", "INPUT", "NodeSocketBool") valid_only.default_value = True options = group.interface.new_panel(name="Options", default_closed=True) + boundary_smooth = _interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", parent=options) + boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 5, 0, 20 + boundary_smooth.description = "Smooth boundary depth and cut-edge geometry while preserving the original image border" base_plane_depth = _interface_socket( group, "Reference Depth", @@ -111,16 +118,21 @@ def build_image_depth_plane_group(image_plane_group): invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN" links.new(threshold_input.outputs["Validity Threshold"], invalid.inputs["B"]) links.new(channels.outputs["Alpha"], invalid.inputs["A"]) - delete = nodes.new("GeometryNodeDeleteGeometry") - delete.domain, delete.mode = "FACE", "ALL" - links.new(plane.outputs["Geometry"], delete.inputs["Geometry"]) - links.new(invalid.outputs["Result"], delete.inputs["Selection"]) + # Image Plane's normalized UV rectangle retains its original outline after cuts. + uv_axes = nodes.new("ShaderNodeSeparateXYZ") + links.new(uv.outputs["Attribute"], uv_axes.inputs[0]) + u, v = uv_axes.outputs["X"], uv_axes.outputs["Y"] + margin = _math(group, "MINIMUM", _math(group, "MINIMUM", u, v), + _math(group, "MINIMUM", _math(group, "SUBTRACT", 1, u), _math(group, "SUBTRACT", 1, v))) + original_boundary = evaluate_field(group, _math(group, "LESS_THAN", margin, 1e-6), "BOOLEAN", "POINT") + protected = plane.outputs["Geometry"] + culled = delete_invalid_points(group, protected, invalid.outputs["Result"]) cull_controls = _group_input(nodes, {"Valid Only"}) cull = nodes.new("GeometryNodeSwitch") cull.input_type = "GEOMETRY" links.new(cull_controls.outputs["Valid Only"], cull.inputs["Switch"]) - links.new(delete.outputs["Geometry"], cull.inputs["True"]) - links.new(plane.outputs["Geometry"], cull.inputs["False"]) + links.new(culled, cull.inputs["True"]) + links.new(protected, cull.inputs["False"]) split_input = _group_input(nodes, {"Split Threshold"}) geometry, corner_camera, cut_vertex = split_depth_surface( group, cull.outputs["Output"], sample_face_camera(group, texture_input.outputs["Depth Image"]), @@ -131,11 +143,13 @@ def build_image_depth_plane_group(image_plane_group): split_input.outputs["Split Threshold"], ) depth_input = _group_input(nodes, {"Reference Depth", "Depth Scale"}) + camera = smooth_boundary_camera(group, camera, original_boundary) surface = project_depth_surface( group, geometry, camera, scale_input.outputs["Uniform Scale"], depth_input.outputs["Reference Depth"], - depth_input.outputs["Depth Scale"], + depth_input.outputs["Depth Scale"], split_input.outputs["Split Threshold"], ) + surface = smooth_cut_boundary(group, surface, original_boundary) shell_input = _group_input(nodes, {"Thickness", "Normal Smooth"}) shell = thicken_depth_surface( group, surface, shell_input.outputs["Thickness"], diff --git a/uv.lock b/uv.lock index d1080f1..b01105c 100644 --- a/uv.lock +++ b/uv.lock @@ -117,7 +117,7 @@ name = "blendjob" version = "0.1.17" source = { path = "src/anyimage/wheels/blendjob-0.1.17-py3-none-any.whl" } wheels = [ - { filename = "blendjob-0.1.17-py3-none-any.whl", hash = "sha256:9173320611ae77ce8852c6f9e96ddcec1b455ac1e2c0d1f886041e5a33e0cc86" }, + { filename = "blendjob-0.1.17-py3-none-any.whl", hash = "sha256:a300ff10972953e1e930072edf33eed5e0f9342c83b30311341c7a6581c98e95" }, ] [package.metadata]