diff --git a/AGENTS.md b/AGENTS.md index 2554af7..5682e06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,25 +75,14 @@ uv lock --refresh - 新增或修改 Blender 类型时同步检查 `src/anyimage/__init__.py` 的注册与逆序注销,并补充对应测试 - 依赖版本、wheel 或 Manifest 改变时同步检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 和打包测试 -## 执行流程与复用 -- 按数据依赖安排步骤:先完成会改变后续输入或分析范围的处理,再执行依赖该结果的计算 -- 对相同输入和参数的昂贵确定性计算只执行一次,从同一中间结果派生多个产物 -- 实现路径由当前功能的输入范围和质量要求决定,不为无实际用途的全局兼容选项增加分支 -- 名称、参数和进度文案应反映当前阶段的真实职责,并明确跨进程流程中每一侧负责的最终操作 -- 临时资源的创建与清理由同一所有者管理;共享输入解析和生命周期逻辑,不在各消费者中重复实现 +## Blender 节点组规范 -## 清理、测试与重构验证 +资产脚本必须由 Blender 执行;具体入口与验证范围见 [节点资产](docs/internals/node-assets.md),不要用普通 Python 解释器运行。 -- 删除前先用 `rg` 查找生产调用方、注册入口、测试、文档和打包清单。只有注册或测试、没有真实功能消费者的入口视为孤立代码,应连同协议一起删除 -- 测试应保护当前功能、公开协议、关键算法和已发生过的回归;不要为了保留旧文件结构、无消费者兼容层或实现细节而写测试 -- 删除功能时删除对应测试;移动原子能力时把测试迁到新的职责模块。测试名称也要使用当前业务术语,不能继续出现已废弃的模块名或 Job ID -- 打包测试应验证最终产物包含必要文件且不包含已删除结构;文件移动后同步更新 -- 结构重构完成后至少执行旧名称与旧路径搜索、差异检查、完整测试和文档构建;涉及 Blender 注册、运行时行为或节点资产时,再使用目标 Blender 验证 -- 不混用开发环境、独立运行环境和 Blender 自带 Python;每类验证使用项目声明的对应解释器 -- 重构应减少模块、分支、协议和重复计算的数量。新抽象只有在职责稳定或存在真实复用时才成立 +`tools/` 中的脚本按维护对象分组,并统一使用能够区分“构建”和“验证”等动作的命名。每个脚本都应有明确入口、文档或测试消费者;没有实际用途的脚本应删除。 -## Blender 节点图规范 +- 生产运行时使用的可复用节点组必须构建并存储在 `src/anyimage/assets/O_AnyImage.blend`,由统一资产加载入口按名称加载;不得在 `src/anyimage/` 中通过 `bpy.data.node_groups.new()` 动态创建。节点组新增、修改或删除时同步更新资产构建、验证、测试和节点资产文档 - 不修改节点的 `name`,也不使用 `label` 覆盖节点标题;保留 Blender 为节点类型生成的默认名称和外观,避免读图时无法判断节点原型。用途应通过节点类型、参数、空间布局和必要的少量注释表达 diff --git a/README.md b/README.md index 47936ce..e81502d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ AI 推理运行在 Blender 之外的独立 Python 3.12 Environment 中。Blender 普通 Plane、基础 Image 编辑,以及未启用 **Fit to Foreground** / **Generate Normal Map** 的 Cutout Surface 与 Balloon 无需 AI Environment。Cutout 的 Depth 与 Normal Map 统一使用 MoGe-2;两者同时启用时共享同一次 Prediction,不会重复运行模型。Cutout 的 **Generate Normal Map** 是启用开关;开启后,Depth Surface 自动使用 Object Space,其余 Shape 自动使用 Tangent Space。 +Balloon 与 Depth Balloon 会用 `o_balloon` 驱动 `O Image Layer` 已有的 **Normal Scale**,使 Normal Map 强度在轮廓处回落。 + ## 快速使用 选中 Image Empty 后,右键 **AnyImage** 菜单提供: @@ -52,6 +54,8 @@ src/anyimage/ └── __init__.py # Blender 注册入口 ``` +生产功能使用的可复用节点组统一构建到 `assets/O_AnyImage.blend`,运行时只按名称加载,不动态创建节点组。 + 程序边界、功能文件结构和调用流程见[内部实现总览](docs/internals/index.md);开发新功能前先查看[Common 公共函数](docs/internals/common.md),复用已经存在的 Image、Depth 产物、Material 和对象替换入口。文档按开始使用、功能指南、参考和内部实现分层,不在 README 重复内部算法细节。 ## 开发 diff --git a/docs/guides/depth-plane.md b/docs/guides/depth-plane.md index 67f5a78..7cee899 100644 --- a/docs/guides/depth-plane.md +++ b/docs/guides/depth-plane.md @@ -18,7 +18,7 @@ Depth Plane 将一张静态图片转换为带深度位移的网格,适合快 ## 调整结果 -生成对象使用运行时创建的 `O Image Depth Plane` Geometry Nodes 修改器: +生成对象使用扩展资产中的 `O Image Depth Plane` Geometry Nodes 修改器: - **Subdivide**:控制基础平面细分级别; - **Thickness**:增加背面和侧壁,`0` 输出单面; diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md index 8b20663..d7fe960 100644 --- a/docs/internals/node-assets.md +++ b/docs/internals/node-assets.md @@ -1,19 +1,21 @@ # 节点资产 -AnyImage 将稳定、可复用的 Geometry Nodes 和 Shader 节点组构建到 `O_AnyImage.blend`,运行时按名称加载。Depth Plane 节点图是例外:它由 Python 在当前 `.blend` 中按需创建。 +AnyImage 将生产功能使用的可复用 Geometry Nodes 和 Shader 节点组统一构建到 `O_AnyImage.blend`,运行时只按名称加载,不动态创建节点组。 ## 对应文件 ```text src/anyimage/ ├── assets/O_AnyImage.blend -├── operators/common/mesh_result.py # 运行时资产加载与 Modifier 输入 -└── operators/convert_to_plane/nodes.py # Depth Plane 运行时节点图 +├── operators/common/mesh_result.py # 运行时资产加载与 Modifier 输入 +└── operators/convert_to_plane/nodes.py # Plane 节点资产名称与加载入口 tools/node_assets/ ├── build.py # 统一构建入口 ├── node_graph.py # Cutout 资产构建专用节点图函数 ├── build_mesh_plane.py +├── build_depth_plane.py +├── build_material_layers.py ├── build_shadeless.py ├── build_cutout.py ├── validate.py # 资产清单与基础接口 @@ -42,11 +44,11 @@ BLENDER_BIN="/c/Program Files/Blender Foundation/Blender 4.5/blender.exe" "$BLENDER_BIN" --background --python tools/node_assets/validate_cutout.py ``` -`build.py` 从空 Factory 文件开始,依次构建 `O Mesh Plane`、`O Shadeless`、四个 Cutout 内部节点组与公开修改器节点组 `O Image Cutout`,再保存唯一资产文件。`O Image Cutout` 用 Menu Switch 包装四个内部组;运行时 Loader 只加载这个稳定入口,Blender 4.5 根据 Shape 菜单依赖只显示当前分支实际使用的输入。 +`build.py` 从空 Factory 文件开始,依次构建 `O Mesh Plane`、`O Image Depth Plane`、`O Image Layer`、`O Image Depth Layer`、`O Shadeless`、四个 Cutout 内部节点组与公开修改器节点组 `O Image Cutout`,再保存唯一资产文件。`O Image Cutout` 用 Menu Switch 包装四个内部组;运行时 Loader 只加载这个稳定入口,Blender 4.5 根据 Shape 菜单依赖只显示当前分支实际使用的输入。 ## 验证范围 -基础验证检查发布资产清单、节点树类型、Modifier 标记、接口顺序和 Fake User。专项验证继续检查: +基础验证检查完整发布资产清单、节点树类型、Modifier 标记、接口顺序和 Fake User。专项验证继续检查: - Mesh Plane 的接口、细分、Thickness 和几何求值; - `O Image Cutout` 的公开接口、Shape 菜单和四个内部组的依赖关系; diff --git a/docs/internals/operators/cutout-tool.md b/docs/internals/operators/cutout-tool.md index ce78a63..a51dcd6 100644 --- a/docs/internals/operators/cutout-tool.md +++ b/docs/internals/operators/cutout-tool.md @@ -89,3 +89,5 @@ Job 完成后,`main.py` 在 Blender 主线程读取可选的 RegionMask,直 Depth Shape 的 Base Plane Depth,以及 Depth Balloon 的 Direction,都由 `object.py` 调用 `geometry.py` 的标定函数,根据最终 Region Alpha 计算。透明像素达到 `5%` 时使用有效 Region 自适应标定,否则使用稳定的固定基准。 Normal Map 是材质细节,不改变上述四种 Shape 的几何分类。关闭时材质使用几何法线;开启时,`object.py` 把生成的图片和已经确定的 Normal Space 交给材质节点。最后 `main.py` 提交 Undo,整条 Cutout 流程结束。 + +Balloon 与 Depth Balloon 的材质会创建 Geometry Attribute 节点,把 `o_balloon` 的 Float 输出直接连接到 `O Image Layer` 已有的 **Normal Scale**;Surface 与 Depth Surface 不建立这条连接。 diff --git a/docs/reference/features.md b/docs/reference/features.md index 1298690..40ba950 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -21,7 +21,7 @@ ### Plane -普通 Plane 使用内置 `O Mesh Plane` Geometry Nodes,提供 Subdivide、Thickness 和 Material。Depth Plane 使用运行时生成的 `O Image Depth Plane`,增加 Depth Amount 与内部 Depth Image。 +普通 Plane 使用资产中的 `O Mesh Plane` Geometry Nodes,提供 Subdivide、Thickness 和 Material。Depth Plane 使用同一资产文件中的 `O Image Depth Plane`,增加 Depth Amount 与内部 Depth Image。 ### Cutout Shape diff --git a/mkdocs.yml b/mkdocs.yml index 3add937..f4207f6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,28 +50,35 @@ nav: theme: name: material + favicon: assets/logo.png + logo: assets/logo.png language: zh font: text: Open Sans code: Cascadia Code palette: + # Palette toggle for automatic mode - media: "(prefers-color-scheme)" primary: black toggle: icon: fontawesome/solid/circle-half-stroke - name: 切换到浅色模式 + name: Switch to light mode + + # Palette toggle for light mode - media: "(prefers-color-scheme: light)" scheme: default primary: black toggle: icon: fontawesome/regular/moon - name: 切换到深色模式 + name: Switch to dark mode + + # Palette toggle for dark mode - media: "(prefers-color-scheme: dark)" scheme: slate primary: black toggle: icon: fontawesome/solid/moon - name: 跟随系统外观 + name: Switch to system preference features: - header.autohide - navigation.footer @@ -79,10 +86,8 @@ theme: - navigation.sections - navigation.tabs - navigation.tabs.sticky - - navigation.top - - search.highlight - - search.suggest - content.code.copy + - content.code.select extra: version: @@ -99,12 +104,16 @@ plugins: markdown_extensions: - attr_list - md_in_html + - mkdocs-click - admonition + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets - pymdownx.details - - pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.superfences - pymdownx.tabbed: alternate_style: true + diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 105392e..1d57865 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:6d85f77daf41fe2b8d6b35d6597577d366bfda466a83ac818ca7c99b51bede0b -size 1446139 +oid sha256:9de415a7fb052b4c998e2263e41a5cd5e421200bed8413bce4b957405ac1c07b +size 1638353 diff --git a/src/anyimage/operators/common/mesh_result.py b/src/anyimage/operators/common/mesh_result.py index 6c3856c..cb83f86 100644 --- a/src/anyimage/operators/common/mesh_result.py +++ b/src/anyimage/operators/common/mesh_result.py @@ -84,10 +84,6 @@ def set_modifier_input(modifier, identifier, value): DISPLACEMENT_STRENGTH = 1.0 DISPLACEMENT_MIDLEVEL = 0.0 -BUMP_BASE_DISTANCE = 0.001 -MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement" -BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth" -DEPTH_SCALE_ATTRIBUTE = "o_depth_scale" IMAGE_MATERIAL_NODE_GROUP_NAME = "O Image Layer" DEPTH_MATERIAL_NODE_GROUP_NAME = "O Image Depth Layer" SHADELESS_NODE_GROUP_NAME = "O Shadeless" @@ -143,186 +139,7 @@ def material_node_group(depth_plane=False): if depth_plane else IMAGE_MATERIAL_NODE_GROUP_NAME ) - existing = bpy.data.node_groups.get(group_name) - if existing is not None: - for item in existing.interface.items_tree: - if ( - item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Normal" - ): - item.default_value = (0.5, 0.5, 1.0, 1.0) - return existing - group = bpy.data.node_groups.new(group_name, "ShaderNodeTree") - interface = group.interface - input_sockets = [ - ("Color", "NodeSocketColor"), - ("Alpha", "NodeSocketFloat"), - ("Bump Scale", "NodeSocketFloat"), - ] - normal_group = not depth_plane - if normal_group: - input_sockets.insert(2, ("Normal", "NodeSocketColor")) - input_sockets.insert(3, ("Normal Scale", "NodeSocketFloat")) - input_sockets.insert(4, ("Object Space", "NodeSocketBool")) - if depth_plane: - input_sockets.insert(2, ("Depth", "NodeSocketFloat")) - for name, socket_type in input_sockets: - socket = interface.new_socket( - name=name, - in_out="INPUT", - socket_type=socket_type, - ) - if name == "Alpha": - socket.default_value = 1.0 - socket.min_value = 0.0 - socket.max_value = 1.0 - socket.subtype = "FACTOR" - elif name == "Bump Scale": - socket.default_value = 0.0 - socket.min_value = -10000.0 - socket.max_value = 10000.0 - elif name == "Normal": - socket.default_value = (0.5, 0.5, 1.0, 1.0) - elif name == "Normal Scale": - socket.default_value = 1.0 - socket.min_value = 0.0 - socket.max_value = 10.0 - elif name == "Object Space": - socket.default_value = False - output_sockets = [ - ("Color", "NodeSocketColor"), - ("Roughness", "NodeSocketFloat"), - ("Alpha", "NodeSocketFloat"), - ("Normal", "NodeSocketVector"), - ] - if depth_plane: - output_sockets.append(("Displacement", "NodeSocketVector")) - for name, socket_type in output_sockets: - interface.new_socket( - name=name, - in_out="OUTPUT", - socket_type=socket_type, - ) - - nodes = group.nodes - links = group.links - group_input = nodes.new("NodeGroupInput") - group_input.location = (-500.0, 100.0) - group_output = nodes.new("NodeGroupOutput") - group_output.location = (300.0, 100.0) - roughness_curve = nodes.new("ShaderNodeFloatCurve") - roughness_curve.mapping.initialize() - curve_points = roughness_curve.mapping.curves[0].points - curve_points[0].location = (0.0, 0.5) - curve_points[-1].location = (1.0, 0.1) - roughness_curve.mapping.update() - roughness_curve.location = (-250.0, 350.0) - bump = nodes.new("ShaderNodeBump") - bump.location = (100.0, 0.0) - if normal_group: - tangent_normal = nodes.new("ShaderNodeNormalMap") - tangent_normal.space = "TANGENT" - tangent_normal.location = (-500.0, -100.0) - object_normal = nodes.new("ShaderNodeNormalMap") - object_normal.space = "OBJECT" - object_normal.location = (-500.0, -350.0) - choose_normal = nodes.new("ShaderNodeMix") - choose_normal.data_type = "VECTOR" - choose_normal.location = (-150.0, -150.0) - for normal_map in (tangent_normal, object_normal): - links.new(group_input.outputs["Normal"], normal_map.inputs["Color"]) - links.new( - group_input.outputs["Normal Scale"], - normal_map.inputs["Strength"], - ) - links.new(group_input.outputs["Object Space"], choose_normal.inputs[0]) - links.new(tangent_normal.outputs["Normal"], choose_normal.inputs[4]) - links.new(object_normal.outputs["Normal"], choose_normal.inputs[5]) - links.new(choose_normal.outputs["Result"], bump.inputs["Normal"]) - displacement = None - - processed_alpha = group_input.outputs["Alpha"] - bump_depth = group_input.outputs["Color"] - if depth_plane: - displacement = nodes.new("ShaderNodeDisplacement") - displacement.inputs["Midlevel"].default_value = 0.0 - displacement.inputs["Scale"].default_value = 1.0 - group_input.location = (-1200.0, 100.0) - group_output.location = (1050.0, 100.0) - roughness_curve.location = (-300.0, 450.0) - bump.location = (750.0, 50.0) - displacement.location = (750.0, -300.0) - background_attribute = nodes.new("ShaderNodeAttribute") - background_attribute.attribute_type = "GEOMETRY" - background_attribute.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE - background_attribute.location = (-950.0, -500.0) - material_attribute = nodes.new("ShaderNodeAttribute") - material_attribute.attribute_type = "GEOMETRY" - material_attribute.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE - material_attribute.location = (-50.0, -650.0) - strength_attribute = nodes.new("ShaderNodeAttribute") - strength_attribute.attribute_type = "GEOMETRY" - strength_attribute.attribute_name = DEPTH_SCALE_ATTRIBUTE - strength_attribute.location = (200.0, -650.0) - foreground_mask = nodes.new("ShaderNodeMath") - foreground_mask.operation = "GREATER_THAN" - foreground_mask.inputs[1].default_value = 0.5 - foreground_mask.location = (-950.0, -250.0) - clamp_foreground = nodes.new("ShaderNodeMath") - clamp_foreground.operation = "MAXIMUM" - clamp_foreground.location = (-650.0, -400.0) - masked_depth = nodes.new("ShaderNodeMix") - masked_depth.data_type = "FLOAT" - masked_depth.location = (-350.0, -350.0) - enabled_depth = nodes.new("ShaderNodeMath") - enabled_depth.operation = "MULTIPLY" - enabled_depth.location = (250.0, -350.0) - scaled_depth = nodes.new("ShaderNodeMath") - scaled_depth.operation = "MULTIPLY" - scaled_depth.location = (450.0, -350.0) - links.new(group_input.outputs["Alpha"], foreground_mask.inputs[0]) - links.new(group_input.outputs["Depth"], clamp_foreground.inputs[0]) - links.new(background_attribute.outputs["Fac"], clamp_foreground.inputs[1]) - links.new(foreground_mask.outputs[0], masked_depth.inputs[0]) - links.new(background_attribute.outputs["Fac"], masked_depth.inputs[2]) - links.new(clamp_foreground.outputs[0], masked_depth.inputs[3]) - links.new(masked_depth.outputs["Result"], enabled_depth.inputs[0]) - links.new(material_attribute.outputs["Fac"], enabled_depth.inputs[1]) - links.new(enabled_depth.outputs[0], scaled_depth.inputs[0]) - links.new(strength_attribute.outputs["Fac"], scaled_depth.inputs[1]) - processed_alpha = foreground_mask.outputs[0] - processed_depth = scaled_depth.outputs[0] - - links.new(group_input.outputs["Color"], group_output.inputs["Color"]) - links.new(group_input.outputs["Color"], roughness_curve.inputs["Value"]) - links.new( - roughness_curve.outputs["Value"], - group_output.inputs["Roughness"], - ) - links.new(processed_alpha, group_output.inputs["Alpha"]) - links.new(bump_depth, bump.inputs["Height"]) - bump_distance = nodes.new("ShaderNodeMath") - bump_distance.operation = "MULTIPLY" - bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE - bump_distance.location = (500.0, 100.0) if depth_plane else (-250.0, 150.0) - links.new(group_input.outputs["Bump Scale"], bump_distance.inputs[0]) - links.new(bump_distance.outputs[0], bump.inputs["Distance"]) - links.new(bump.outputs["Normal"], group_output.inputs["Normal"]) - if displacement is not None: - links.new(processed_depth, displacement.inputs["Height"]) - links.new( - displacement.outputs["Displacement"], - group_output.inputs["Displacement"], - ) - group.color_tag = "SHADER" - group.description = ( - "Prepare image depth material outputs." - if depth_plane - else f"Prepare {group_name} material outputs." - ) - group.use_fake_user = True - return group + return load_asset_node_group(group_name) def create_image_material( diff --git a/src/anyimage/operators/convert_to_plane/nodes.py b/src/anyimage/operators/convert_to_plane/nodes.py index 7d891c0..a5aa217 100644 --- a/src/anyimage/operators/convert_to_plane/nodes.py +++ b/src/anyimage/operators/convert_to_plane/nodes.py @@ -1,198 +1,18 @@ -"""Runtime Geometry Nodes for Depth Plane.""" - -import bpy +"""Asset-backed Geometry Nodes for Plane conversion.""" from ..common.mesh_result import load_asset_node_group + MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane" +DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane" def mesh_plane_node_group(): return load_asset_node_group(MESH_PLANE_NODE_GROUP_NAME) -def _interface_socket(group, name, in_out, socket_type): - socket = group.interface.new_socket( - name=name, - in_out=in_out, - socket_type=socket_type, - ) - if in_out == "INPUT" and hasattr(socket, "structure_type"): - socket.structure_type = "SINGLE" - return socket - - -def _group_input(nodes, location, visible_names): - node = nodes.new("NodeGroupInput") - node.location = location - visible = set(visible_names) - for output in node.outputs: - output.hide = output.name not in visible - return node - def depth_plane_node_group(): - group_name = "O Image Depth Plane" - group = bpy.data.node_groups.get(group_name) - if group is not None: - return group - if group is None: - group = bpy.data.node_groups.new(group_name, "GeometryNodeTree") - else: - group.nodes.clear() - group.interface.clear() - group.is_modifier = True - _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") - subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") - subdivide.default_value = 7 - subdivide.min_value = 0 - subdivide.max_value = 10 - thickness = _interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat") - thickness.default_value = 0.1 - thickness.min_value = 0.0 - thickness.subtype = "DISTANCE" - depth_amount = _interface_socket(group, "Depth Amount", "INPUT", "NodeSocketFloat") - depth_amount.default_value = 1.0 - depth_amount.min_value = 0.0 - reference_depth = _interface_socket( - group, "Reference Depth", "INPUT", "NodeSocketFloat" - ) - reference_depth.hide_in_modifier = True - uniform_scale = _interface_socket( - group, "Uniform Scale", "INPUT", "NodeSocketFloat" - ) - uniform_scale.hide_in_modifier = True - depth_image = _interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage") - depth_image.hide_in_modifier = True - material = _interface_socket(group, "Material", "INPUT", "NodeSocketMaterial") - material.hide_in_modifier = True - _interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") - - nodes = group.nodes - links = group.links - geometry_input = _group_input( - nodes, - (-1850.0, 450.0), - {"Geometry", "Subdivide", "Material"}, - ) - shape_input = _group_input( - nodes, - (-1250.0, 850.0), - {"Thickness", "Depth Amount"}, - ) - image_input = _group_input( - nodes, - (-1050.0, 50.0), - {"Depth Image", "Reference Depth", "Uniform Scale"}, - ) - cleanup_input = _group_input(nodes, (650.0, -200.0), {"Thickness"}) - group_output = nodes.new("NodeGroupOutput") - group_output.location = (1850.0, 450.0) - mesh_plane = nodes.new("GeometryNodeGroup") - mesh_plane.node_tree = mesh_plane_node_group() - mesh_plane.inputs["Thickness"].default_value = 0.0 - mesh_plane.location = (-1550.0, 450.0) - links.new(geometry_input.outputs["Geometry"], mesh_plane.inputs["Geometry"]) - links.new(geometry_input.outputs["Subdivide"], mesh_plane.inputs["Subdivide"]) - links.new(geometry_input.outputs["Material"], mesh_plane.inputs["Material"]) - - extrude = nodes.new("GeometryNodeExtrudeMesh") - extrude.mode = "FACES" - extrude.inputs["Offset"].default_value = (0.0, 0.0, -1.0) - extrude.inputs["Individual"].default_value = False - extrude.location = (-1200.0, 450.0) - links.new(mesh_plane.outputs["Geometry"], extrude.inputs["Mesh"]) - links.new(shape_input.outputs["Thickness"], extrude.inputs["Offset Scale"]) - geometry = extrude.outputs["Mesh"] - - uv = nodes.new("GeometryNodeInputNamedAttribute") - uv.data_type = "FLOAT_VECTOR" - uv.inputs["Name"].default_value = "UVMap" - uv.location = (-1000.0, 50.0) - texture = nodes.new("GeometryNodeImageTexture") - texture.location = (-800.0, 50.0) - links.new(image_input.outputs["Depth Image"], texture.inputs["Image"]) - links.new(uv.outputs["Attribute"], texture.inputs["Vector"]) - separate = nodes.new("ShaderNodeSeparateXYZ") - separate.location = (-600.0, 50.0) - links.new(texture.outputs["Color"], separate.inputs["Vector"]) - relative_depth = nodes.new("ShaderNodeMath") - relative_depth.operation = "SUBTRACT" - relative_depth.location = (-600.0, -150.0) - links.new(image_input.outputs["Reference Depth"], relative_depth.inputs[0]) - links.new(separate.outputs["X"], relative_depth.inputs[1]) - local_depth = nodes.new("ShaderNodeMath") - local_depth.operation = "MULTIPLY" - local_depth.location = (-350.0, -150.0) - links.new(relative_depth.outputs[0], local_depth.inputs[0]) - links.new(image_input.outputs["Uniform Scale"], local_depth.inputs[1]) - raw_depth = nodes.new("ShaderNodeMath") - raw_depth.operation = "MULTIPLY" - raw_depth.location = (-100.0, 150.0) - links.new(local_depth.outputs[0], raw_depth.inputs[0]) - links.new(shape_input.outputs["Depth Amount"], raw_depth.inputs[1]) - inward_limit = nodes.new("ShaderNodeMath") - inward_limit.operation = "MULTIPLY" - inward_limit.inputs[1].default_value = -0.98 - inward_limit.location = (-100.0, -50.0) - links.new(shape_input.outputs["Thickness"], inward_limit.inputs[0]) - clamp = nodes.new("ShaderNodeMath") - clamp.operation = "MAXIMUM" - clamp.location = (150.0, 50.0) - links.new(raw_depth.outputs[0], clamp.inputs[0]) - links.new(inward_limit.outputs[0], clamp.inputs[1]) - has_thickness = nodes.new("ShaderNodeMath") - has_thickness.operation = "GREATER_THAN" - has_thickness.inputs[1].default_value = 1e-6 - has_thickness.location = (-100.0, -350.0) - links.new(shape_input.outputs["Thickness"], has_thickness.inputs[0]) - choose_depth = nodes.new("GeometryNodeSwitch") - choose_depth.input_type = "FLOAT" - choose_depth.location = (400.0, 50.0) - links.new(has_thickness.outputs[0], choose_depth.inputs["Switch"]) - links.new(raw_depth.outputs[0], choose_depth.inputs["False"]) - links.new(clamp.outputs[0], choose_depth.inputs["True"]) - combine = nodes.new("ShaderNodeCombineXYZ") - combine.location = (600.0, 50.0) - links.new(choose_depth.outputs["Output"], combine.inputs["Z"]) - front = nodes.new("FunctionNodeBooleanMath") - front.operation = "NOT" - front.location = (400.0, 300.0) - links.new(extrude.outputs["Top"], front.inputs[0]) - set_position = nodes.new("GeometryNodeSetPosition") - set_position.location = (800.0, 450.0) - links.new(geometry, set_position.inputs["Geometry"]) - links.new(front.outputs[0], set_position.inputs["Selection"]) - links.new(combine.outputs["Vector"], set_position.inputs["Offset"]) - geometry = set_position.outputs["Geometry"] - - zero_thickness = nodes.new("ShaderNodeMath") - zero_thickness.operation = "LESS_THAN" - zero_thickness.inputs[1].default_value = 1e-6 - zero_thickness.location = (1000.0, -150.0) - links.new(cleanup_input.outputs["Thickness"], zero_thickness.inputs[0]) - back_or_side = nodes.new("FunctionNodeBooleanMath") - back_or_side.operation = "OR" - back_or_side.location = (1000.0, 150.0) - links.new(extrude.outputs["Top"], back_or_side.inputs[0]) - links.new(extrude.outputs["Side"], back_or_side.inputs[1]) - delete_selection = nodes.new("FunctionNodeBooleanMath") - delete_selection.operation = "AND" - delete_selection.location = (1200.0, 50.0) - links.new(zero_thickness.outputs[0], delete_selection.inputs[0]) - links.new(back_or_side.outputs[0], delete_selection.inputs[1]) - delete_geometry = nodes.new("GeometryNodeDeleteGeometry") - delete_geometry.domain = "FACE" - delete_geometry.location = (1350.0, 450.0) - links.new(geometry, delete_geometry.inputs["Geometry"]) - links.new(delete_selection.outputs[0], delete_geometry.inputs["Selection"]) - shade_smooth = nodes.new("GeometryNodeSetShadeSmooth") - shade_smooth.domain = "FACE" - shade_smooth.inputs["Shade Smooth"].default_value = True - shade_smooth.location = (1600.0, 450.0) - links.new(delete_geometry.outputs["Geometry"], shade_smooth.inputs["Geometry"]) - links.new(shade_smooth.outputs["Geometry"], group_output.inputs["Geometry"]) - group.use_fake_user = True - return group + return load_asset_node_group(DEPTH_PLANE_NODE_GROUP_NAME) def plane_node_group(shape): diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index 351eba8..a47da09 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -10,6 +10,7 @@ from ..common.image_data import ( ) from ..common.depth_data import camera_depth_values, depth_uniform_scale from ..common.mesh_result import ( + IMAGE_MATERIAL_NODE_GROUP_NAME, create_image_material, finalize_object_result, load_asset_node_group, @@ -30,6 +31,7 @@ from .geometry import ( ) from .shape import ( BALLOON_ATTRIBUTE_NAME, + BALLOON_CUTOUT_SHAPES, CUTOUT_NODE_GROUP_NAME, CUTOUT_SHAPE_INDICES, DEPTH_CUTOUT_SHAPES, @@ -124,6 +126,24 @@ def load_cutout_node_group(): return load_asset_node_group(CUTOUT_NODE_GROUP_NAME) +def _connect_balloon_normal_scale(material): + nodes = material.node_tree.nodes + image_layer = next( + node + for node in nodes + if node.bl_idname == "ShaderNodeGroup" + and node.node_tree is not None + and node.node_tree.name == IMAGE_MATERIAL_NODE_GROUP_NAME + ) + attribute = nodes.new("ShaderNodeAttribute") + attribute.attribute_type = "GEOMETRY" + attribute.attribute_name = BALLOON_ATTRIBUTE_NAME + attribute.location = (-550.0, -250.0) + material.node_tree.links.new( + attribute.outputs["Fac"], image_layer.inputs["Normal Scale"] + ) + + def create_shape_object( context, source_object, @@ -178,6 +198,8 @@ def create_shape_object( scene=context.scene, ) depth_reference = None + if shape in BALLOON_CUTOUT_SHAPES: + _connect_balloon_normal_scale(material) uniform_scale = None depth_direction = None if shape in DEPTH_CUTOUT_SHAPES: diff --git a/src/anyimage/operators/cutout_tool/shape.py b/src/anyimage/operators/cutout_tool/shape.py index 1682f78..1e33e69 100644 --- a/src/anyimage/operators/cutout_tool/shape.py +++ b/src/anyimage/operators/cutout_tool/shape.py @@ -19,3 +19,4 @@ CUTOUT_SHAPE_INDICES = { } CUTOUT_SHAPES = frozenset(CUTOUT_NODE_GROUP_NAMES) DEPTH_CUTOUT_SHAPES = frozenset({"DEPTH_BALLOON", "DEPTH_SURFACE"}) +BALLOON_CUTOUT_SHAPES = frozenset({"BALLOON", "DEPTH_BALLOON"}) diff --git a/tests/test_mesh_blender.py b/tests/test_mesh_blender.py index 70866dc..64e7c4c 100644 --- a/tests/test_mesh_blender.py +++ b/tests/test_mesh_blender.py @@ -495,6 +495,23 @@ def test_balloon_build_starts_from_the_unified_single_sheet(): profile = np.empty(len(result.data.vertices), dtype=np.float32) profile_attribute.data.foreach_get("value", profile) assert profile.min() == 0.0 and profile.max() > 0.0 + material_tree = result.data.materials[0].node_tree + normal_scale_attribute = next( + node + for node in material_tree.nodes + if node.bl_idname == "ShaderNodeAttribute" + ) + assert normal_scale_attribute.attribute_type == "GEOMETRY" + assert normal_scale_attribute.attribute_name == shapes.BALLOON_ATTRIBUTE_NAME + image_layer = _group_node(material.material_node_group(), material_tree) + normal_scale_link = next( + link + for link in material_tree.links + if link.to_node == image_layer + and link.to_socket == image_layer.inputs["Normal Scale"] + ) + assert normal_scale_link.from_node == normal_scale_attribute + assert normal_scale_link.from_socket == normal_scale_attribute.outputs["Fac"] assert result.modifiers[0].node_group.name == shapes.CUTOUT_NODE_GROUP_NAME inward = result.matrix_world.to_3x3() @ Vector((1.0, 0.0, 0.0)) assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6) @@ -523,6 +540,10 @@ def test_teddy_preference_writes_the_shared_balloon_attribute(): profile = np.empty(len(result.data.vertices), dtype=np.float32) attribute.data.foreach_get("value", profile) assert profile.min() == 0.0 and profile.max() > 0.0 + assert not any( + node.bl_idname == "ShaderNodeAttribute" + for node in result.data.materials[0].node_tree.nodes + ) def test_cutout_material_uses_ior_1_2(): @@ -1228,3 +1249,38 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): assert np.allclose( copied[:, :, 3], np.flipud(rgba[top:bottom, left:right, 3]) ) + + +def test_balloon_normal_scale_uses_existing_image_layer_input(): + bpy.ops.wm.read_factory_settings(use_empty=True) + assert shapes.BALLOON_CUTOUT_SHAPES == frozenset( + {"BALLOON", "DEPTH_BALLOON"} + ) + group = bpy.data.node_groups.new( + material.IMAGE_MATERIAL_NODE_GROUP_NAME, + "ShaderNodeTree", + ) + group.interface.new_socket( + name="Normal Scale", + in_out="INPUT", + socket_type="NodeSocketFloat", + ) + image_material = bpy.data.materials.new("Balloon") + image_material.use_nodes = True + image_material.node_tree.nodes.clear() + image_layer = image_material.node_tree.nodes.new("ShaderNodeGroup") + image_layer.node_tree = group + + cutout_object._connect_balloon_normal_scale(image_material) + + assert _inputs(group) == ["Normal Scale"] + attribute = next( + node + for node in image_material.node_tree.nodes + if node != image_layer + ) + assert attribute.bl_idname == "ShaderNodeAttribute" + assert attribute.attribute_name == shapes.BALLOON_ATTRIBUTE_NAME + link = next(iter(image_material.node_tree.links)) + assert link.from_socket == attribute.outputs["Fac"] + assert link.to_socket == image_layer.inputs["Normal Scale"] diff --git a/tests/test_node_assets.py b/tests/test_node_assets.py new file mode 100644 index 0000000..d4e7327 --- /dev/null +++ b/tests/test_node_assets.py @@ -0,0 +1,32 @@ +import unittest +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage" + + +class NodeAssetTest(unittest.TestCase): + def test_production_code_does_not_create_node_groups(self): + offenders = [] + for path in SOURCE_ROOT.rglob("*.py"): + if "bpy.data.node_groups.new" in path.read_text(encoding="utf-8"): + offenders.append(path.relative_to(PROJECT_ROOT).as_posix()) + self.assertEqual(offenders, []) + + def test_runtime_node_group_entries_load_assets(self): + material_source = ( + SOURCE_ROOT / "operators" / "common" / "mesh_result.py" + ).read_text(encoding="utf-8") + plane_source = ( + SOURCE_ROOT / "operators" / "convert_to_plane" / "nodes.py" + ).read_text(encoding="utf-8") + self.assertIn("return load_asset_node_group(group_name)", material_source) + self.assertIn( + "return load_asset_node_group(DEPTH_PLANE_NODE_GROUP_NAME)", + plane_source, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/node_assets/build.py b/tools/node_assets/build.py index 0b1d4e5..2c0a2b1 100644 --- a/tools/node_assets/build.py +++ b/tools/node_assets/build.py @@ -17,6 +17,8 @@ from build_cutout import ( build_cutout_modifier_group, build_node_group, ) +from build_depth_plane import build_depth_plane_group +from build_material_layers import build_material_layer_group from build_mesh_plane import build_mesh_plane_group from build_shadeless import build_shadeless_group OUTPUT_PATH = ( @@ -31,7 +33,10 @@ OUTPUT_PATH = ( def main(): bpy.ops.wm.read_factory_settings(use_empty=True) bpy.context.preferences.filepaths.save_version = 0 - build_mesh_plane_group() + mesh_plane = build_mesh_plane_group() + build_depth_plane_group(mesh_plane) + build_material_layer_group() + build_material_layer_group(depth_plane=True) build_shadeless_group() shape_groups = { shape: build_node_group(shape) diff --git a/tools/node_assets/build_depth_plane.py b/tools/node_assets/build_depth_plane.py new file mode 100644 index 0000000..f5c577f --- /dev/null +++ b/tools/node_assets/build_depth_plane.py @@ -0,0 +1,190 @@ +"""Build the Depth Plane Geometry Nodes group.""" + +import bpy + + +DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane" + + +def _interface_socket(group, name, in_out, socket_type): + socket = group.interface.new_socket( + name=name, + in_out=in_out, + socket_type=socket_type, + ) + if in_out == "INPUT" and hasattr(socket, "structure_type"): + socket.structure_type = "SINGLE" + return socket + + +def _group_input(nodes, location, visible_names): + node = nodes.new("NodeGroupInput") + node.location = location + visible = set(visible_names) + for output in node.outputs: + output.hide = output.name not in visible + return node + + +def build_depth_plane_group(mesh_plane_group): + group = bpy.data.node_groups.get(DEPTH_PLANE_NODE_GROUP_NAME) + if group is not None: + return group + group = bpy.data.node_groups.new( + DEPTH_PLANE_NODE_GROUP_NAME, + "GeometryNodeTree", + ) + group.is_modifier = True + _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") + subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") + subdivide.default_value = 7 + subdivide.min_value = 0 + subdivide.max_value = 10 + thickness = _interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat") + thickness.default_value = 0.1 + thickness.min_value = 0.0 + thickness.subtype = "DISTANCE" + depth_amount = _interface_socket(group, "Depth Amount", "INPUT", "NodeSocketFloat") + depth_amount.default_value = 1.0 + depth_amount.min_value = 0.0 + reference_depth = _interface_socket( + group, "Reference Depth", "INPUT", "NodeSocketFloat" + ) + reference_depth.hide_in_modifier = True + uniform_scale = _interface_socket( + group, "Uniform Scale", "INPUT", "NodeSocketFloat" + ) + uniform_scale.hide_in_modifier = True + depth_image = _interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage") + depth_image.hide_in_modifier = True + material = _interface_socket(group, "Material", "INPUT", "NodeSocketMaterial") + material.hide_in_modifier = True + _interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") + + nodes = group.nodes + links = group.links + geometry_input = _group_input( + nodes, + (-1850.0, 450.0), + {"Geometry", "Subdivide", "Material"}, + ) + shape_input = _group_input( + nodes, + (-1250.0, 850.0), + {"Thickness", "Depth Amount"}, + ) + image_input = _group_input( + nodes, + (-1050.0, 50.0), + {"Depth Image", "Reference Depth", "Uniform Scale"}, + ) + cleanup_input = _group_input(nodes, (650.0, -200.0), {"Thickness"}) + group_output = nodes.new("NodeGroupOutput") + group_output.location = (1850.0, 450.0) + mesh_plane = nodes.new("GeometryNodeGroup") + mesh_plane.node_tree = mesh_plane_group + mesh_plane.inputs["Thickness"].default_value = 0.0 + mesh_plane.location = (-1550.0, 450.0) + links.new(geometry_input.outputs["Geometry"], mesh_plane.inputs["Geometry"]) + links.new(geometry_input.outputs["Subdivide"], mesh_plane.inputs["Subdivide"]) + links.new(geometry_input.outputs["Material"], mesh_plane.inputs["Material"]) + + extrude = nodes.new("GeometryNodeExtrudeMesh") + extrude.mode = "FACES" + extrude.inputs["Offset"].default_value = (0.0, 0.0, -1.0) + extrude.inputs["Individual"].default_value = False + extrude.location = (-1200.0, 450.0) + links.new(mesh_plane.outputs["Geometry"], extrude.inputs["Mesh"]) + links.new(shape_input.outputs["Thickness"], extrude.inputs["Offset Scale"]) + geometry = extrude.outputs["Mesh"] + + uv = nodes.new("GeometryNodeInputNamedAttribute") + uv.data_type = "FLOAT_VECTOR" + uv.inputs["Name"].default_value = "UVMap" + uv.location = (-1000.0, 50.0) + texture = nodes.new("GeometryNodeImageTexture") + texture.location = (-800.0, 50.0) + links.new(image_input.outputs["Depth Image"], texture.inputs["Image"]) + links.new(uv.outputs["Attribute"], texture.inputs["Vector"]) + separate = nodes.new("ShaderNodeSeparateXYZ") + separate.location = (-600.0, 50.0) + links.new(texture.outputs["Color"], separate.inputs["Vector"]) + relative_depth = nodes.new("ShaderNodeMath") + relative_depth.operation = "SUBTRACT" + relative_depth.location = (-600.0, -150.0) + links.new(image_input.outputs["Reference Depth"], relative_depth.inputs[0]) + links.new(separate.outputs["X"], relative_depth.inputs[1]) + local_depth = nodes.new("ShaderNodeMath") + local_depth.operation = "MULTIPLY" + local_depth.location = (-350.0, -150.0) + links.new(relative_depth.outputs[0], local_depth.inputs[0]) + links.new(image_input.outputs["Uniform Scale"], local_depth.inputs[1]) + raw_depth = nodes.new("ShaderNodeMath") + raw_depth.operation = "MULTIPLY" + raw_depth.location = (-100.0, 150.0) + links.new(local_depth.outputs[0], raw_depth.inputs[0]) + links.new(shape_input.outputs["Depth Amount"], raw_depth.inputs[1]) + inward_limit = nodes.new("ShaderNodeMath") + inward_limit.operation = "MULTIPLY" + inward_limit.inputs[1].default_value = -0.98 + inward_limit.location = (-100.0, -50.0) + links.new(shape_input.outputs["Thickness"], inward_limit.inputs[0]) + clamp = nodes.new("ShaderNodeMath") + clamp.operation = "MAXIMUM" + clamp.location = (150.0, 50.0) + links.new(raw_depth.outputs[0], clamp.inputs[0]) + links.new(inward_limit.outputs[0], clamp.inputs[1]) + has_thickness = nodes.new("ShaderNodeMath") + has_thickness.operation = "GREATER_THAN" + has_thickness.inputs[1].default_value = 1e-6 + has_thickness.location = (-100.0, -350.0) + links.new(shape_input.outputs["Thickness"], has_thickness.inputs[0]) + choose_depth = nodes.new("GeometryNodeSwitch") + choose_depth.input_type = "FLOAT" + choose_depth.location = (400.0, 50.0) + links.new(has_thickness.outputs[0], choose_depth.inputs["Switch"]) + links.new(raw_depth.outputs[0], choose_depth.inputs["False"]) + links.new(clamp.outputs[0], choose_depth.inputs["True"]) + combine = nodes.new("ShaderNodeCombineXYZ") + combine.location = (600.0, 50.0) + links.new(choose_depth.outputs["Output"], combine.inputs["Z"]) + front = nodes.new("FunctionNodeBooleanMath") + front.operation = "NOT" + front.location = (400.0, 300.0) + links.new(extrude.outputs["Top"], front.inputs[0]) + set_position = nodes.new("GeometryNodeSetPosition") + set_position.location = (800.0, 450.0) + links.new(geometry, set_position.inputs["Geometry"]) + links.new(front.outputs[0], set_position.inputs["Selection"]) + links.new(combine.outputs["Vector"], set_position.inputs["Offset"]) + geometry = set_position.outputs["Geometry"] + + zero_thickness = nodes.new("ShaderNodeMath") + zero_thickness.operation = "LESS_THAN" + zero_thickness.inputs[1].default_value = 1e-6 + zero_thickness.location = (1000.0, -150.0) + links.new(cleanup_input.outputs["Thickness"], zero_thickness.inputs[0]) + back_or_side = nodes.new("FunctionNodeBooleanMath") + back_or_side.operation = "OR" + back_or_side.location = (1000.0, 150.0) + links.new(extrude.outputs["Top"], back_or_side.inputs[0]) + links.new(extrude.outputs["Side"], back_or_side.inputs[1]) + delete_selection = nodes.new("FunctionNodeBooleanMath") + delete_selection.operation = "AND" + delete_selection.location = (1200.0, 50.0) + links.new(zero_thickness.outputs[0], delete_selection.inputs[0]) + links.new(back_or_side.outputs[0], delete_selection.inputs[1]) + delete_geometry = nodes.new("GeometryNodeDeleteGeometry") + delete_geometry.domain = "FACE" + delete_geometry.location = (1350.0, 450.0) + links.new(geometry, delete_geometry.inputs["Geometry"]) + links.new(delete_selection.outputs[0], delete_geometry.inputs["Selection"]) + shade_smooth = nodes.new("GeometryNodeSetShadeSmooth") + shade_smooth.domain = "FACE" + shade_smooth.inputs["Shade Smooth"].default_value = True + shade_smooth.location = (1600.0, 450.0) + links.new(delete_geometry.outputs["Geometry"], shade_smooth.inputs["Geometry"]) + links.new(shade_smooth.outputs["Geometry"], group_output.inputs["Geometry"]) + group.use_fake_user = True + return group + diff --git a/tools/node_assets/build_material_layers.py b/tools/node_assets/build_material_layers.py new file mode 100644 index 0000000..b265695 --- /dev/null +++ b/tools/node_assets/build_material_layers.py @@ -0,0 +1,195 @@ +"""Build the image material Shader node groups.""" + +import bpy + +from anyimage.operators.common.mesh_result import ( + DEPTH_MATERIAL_NODE_GROUP_NAME, + IMAGE_MATERIAL_NODE_GROUP_NAME, +) + + +BUMP_BASE_DISTANCE = 0.001 +MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement" +BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth" +DEPTH_SCALE_ATTRIBUTE = "o_depth_scale" + + +def build_material_layer_group(depth_plane=False): + group_name = ( + DEPTH_MATERIAL_NODE_GROUP_NAME + if depth_plane + else IMAGE_MATERIAL_NODE_GROUP_NAME + ) + existing = bpy.data.node_groups.get(group_name) + if existing is not None: + return existing + group = bpy.data.node_groups.new(group_name, "ShaderNodeTree") + interface = group.interface + input_sockets = [ + ("Color", "NodeSocketColor"), + ("Alpha", "NodeSocketFloat"), + ("Bump Scale", "NodeSocketFloat"), + ] + normal_group = not depth_plane + if normal_group: + input_sockets.insert(2, ("Normal", "NodeSocketColor")) + input_sockets.insert(3, ("Normal Scale", "NodeSocketFloat")) + input_sockets.insert(4, ("Object Space", "NodeSocketBool")) + if depth_plane: + input_sockets.insert(2, ("Depth", "NodeSocketFloat")) + for name, socket_type in input_sockets: + socket = interface.new_socket( + name=name, + in_out="INPUT", + socket_type=socket_type, + ) + if name == "Alpha": + socket.default_value = 1.0 + socket.min_value = 0.0 + socket.max_value = 1.0 + socket.subtype = "FACTOR" + elif name == "Bump Scale": + socket.default_value = 0.0 + socket.min_value = -10000.0 + socket.max_value = 10000.0 + elif name == "Normal": + socket.default_value = (0.5, 0.5, 1.0, 1.0) + elif name == "Normal Scale": + socket.default_value = 1.0 + socket.min_value = 0.0 + socket.max_value = 10.0 + elif name == "Object Space": + socket.default_value = False + output_sockets = [ + ("Color", "NodeSocketColor"), + ("Roughness", "NodeSocketFloat"), + ("Alpha", "NodeSocketFloat"), + ("Normal", "NodeSocketVector"), + ] + if depth_plane: + output_sockets.append(("Displacement", "NodeSocketVector")) + for name, socket_type in output_sockets: + interface.new_socket( + name=name, + in_out="OUTPUT", + socket_type=socket_type, + ) + + nodes = group.nodes + links = group.links + group_input = nodes.new("NodeGroupInput") + group_input.location = (-500.0, 100.0) + group_output = nodes.new("NodeGroupOutput") + group_output.location = (300.0, 100.0) + roughness_curve = nodes.new("ShaderNodeFloatCurve") + roughness_curve.mapping.initialize() + curve_points = roughness_curve.mapping.curves[0].points + curve_points[0].location = (0.0, 0.5) + curve_points[-1].location = (1.0, 0.1) + roughness_curve.mapping.update() + roughness_curve.location = (-250.0, 350.0) + bump = nodes.new("ShaderNodeBump") + bump.location = (100.0, 0.0) + if normal_group: + tangent_normal = nodes.new("ShaderNodeNormalMap") + tangent_normal.space = "TANGENT" + tangent_normal.location = (-500.0, -100.0) + object_normal = nodes.new("ShaderNodeNormalMap") + object_normal.space = "OBJECT" + object_normal.location = (-500.0, -350.0) + choose_normal = nodes.new("ShaderNodeMix") + choose_normal.data_type = "VECTOR" + choose_normal.location = (-150.0, -150.0) + for normal_map in (tangent_normal, object_normal): + links.new(group_input.outputs["Normal"], normal_map.inputs["Color"]) + links.new( + group_input.outputs["Normal Scale"], + normal_map.inputs["Strength"], + ) + links.new(group_input.outputs["Object Space"], choose_normal.inputs[0]) + links.new(tangent_normal.outputs["Normal"], choose_normal.inputs[4]) + links.new(object_normal.outputs["Normal"], choose_normal.inputs[5]) + links.new(choose_normal.outputs["Result"], bump.inputs["Normal"]) + displacement = None + + processed_alpha = group_input.outputs["Alpha"] + bump_depth = group_input.outputs["Color"] + if depth_plane: + displacement = nodes.new("ShaderNodeDisplacement") + displacement.inputs["Midlevel"].default_value = 0.0 + displacement.inputs["Scale"].default_value = 1.0 + group_input.location = (-1200.0, 100.0) + group_output.location = (1050.0, 100.0) + roughness_curve.location = (-300.0, 450.0) + bump.location = (750.0, 50.0) + displacement.location = (750.0, -300.0) + background_attribute = nodes.new("ShaderNodeAttribute") + background_attribute.attribute_type = "GEOMETRY" + background_attribute.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE + background_attribute.location = (-950.0, -500.0) + material_attribute = nodes.new("ShaderNodeAttribute") + material_attribute.attribute_type = "GEOMETRY" + material_attribute.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE + material_attribute.location = (-50.0, -650.0) + strength_attribute = nodes.new("ShaderNodeAttribute") + strength_attribute.attribute_type = "GEOMETRY" + strength_attribute.attribute_name = DEPTH_SCALE_ATTRIBUTE + strength_attribute.location = (200.0, -650.0) + foreground_mask = nodes.new("ShaderNodeMath") + foreground_mask.operation = "GREATER_THAN" + foreground_mask.inputs[1].default_value = 0.5 + foreground_mask.location = (-950.0, -250.0) + clamp_foreground = nodes.new("ShaderNodeMath") + clamp_foreground.operation = "MAXIMUM" + clamp_foreground.location = (-650.0, -400.0) + masked_depth = nodes.new("ShaderNodeMix") + masked_depth.data_type = "FLOAT" + masked_depth.location = (-350.0, -350.0) + enabled_depth = nodes.new("ShaderNodeMath") + enabled_depth.operation = "MULTIPLY" + enabled_depth.location = (250.0, -350.0) + scaled_depth = nodes.new("ShaderNodeMath") + scaled_depth.operation = "MULTIPLY" + scaled_depth.location = (450.0, -350.0) + links.new(group_input.outputs["Alpha"], foreground_mask.inputs[0]) + links.new(group_input.outputs["Depth"], clamp_foreground.inputs[0]) + links.new(background_attribute.outputs["Fac"], clamp_foreground.inputs[1]) + links.new(foreground_mask.outputs[0], masked_depth.inputs[0]) + links.new(background_attribute.outputs["Fac"], masked_depth.inputs[2]) + links.new(clamp_foreground.outputs[0], masked_depth.inputs[3]) + links.new(masked_depth.outputs["Result"], enabled_depth.inputs[0]) + links.new(material_attribute.outputs["Fac"], enabled_depth.inputs[1]) + links.new(enabled_depth.outputs[0], scaled_depth.inputs[0]) + links.new(strength_attribute.outputs["Fac"], scaled_depth.inputs[1]) + processed_alpha = foreground_mask.outputs[0] + processed_depth = scaled_depth.outputs[0] + + links.new(group_input.outputs["Color"], group_output.inputs["Color"]) + links.new(group_input.outputs["Color"], roughness_curve.inputs["Value"]) + links.new( + roughness_curve.outputs["Value"], + group_output.inputs["Roughness"], + ) + links.new(processed_alpha, group_output.inputs["Alpha"]) + links.new(bump_depth, bump.inputs["Height"]) + bump_distance = nodes.new("ShaderNodeMath") + bump_distance.operation = "MULTIPLY" + bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE + bump_distance.location = (500.0, 100.0) if depth_plane else (-250.0, 150.0) + links.new(group_input.outputs["Bump Scale"], bump_distance.inputs[0]) + links.new(bump_distance.outputs[0], bump.inputs["Distance"]) + links.new(bump.outputs["Normal"], group_output.inputs["Normal"]) + if displacement is not None: + links.new(processed_depth, displacement.inputs["Height"]) + links.new( + displacement.outputs["Displacement"], + group_output.inputs["Displacement"], + ) + group.color_tag = "SHADER" + group.description = ( + "Prepare image depth material outputs." + if depth_plane + else f"Prepare {group_name} material outputs." + ) + group.use_fake_user = True + return group diff --git a/tools/node_assets/validate.py b/tools/node_assets/validate.py index cc10f72..fa16b92 100644 --- a/tools/node_assets/validate.py +++ b/tools/node_assets/validate.py @@ -11,8 +11,12 @@ sys.path.insert( ) from anyimage.operators.common.mesh_result import ( + DEPTH_MATERIAL_NODE_GROUP_NAME, + IMAGE_MATERIAL_NODE_GROUP_NAME, load_asset_node_group, + node_asset_path, ) +from anyimage.operators.convert_to_plane.nodes import DEPTH_PLANE_NODE_GROUP_NAME from anyimage.operators.cutout_tool.shape import ( CUTOUT_NODE_GROUP_NAME, CUTOUT_NODE_GROUP_NAMES, @@ -30,11 +34,25 @@ def input_names(group): def main(): bpy.ops.wm.read_factory_settings(use_empty=True) mesh_plane = load_asset_node_group("O Mesh Plane") + depth_plane = load_asset_node_group(DEPTH_PLANE_NODE_GROUP_NAME) + image_layer = load_asset_node_group(IMAGE_MATERIAL_NODE_GROUP_NAME) + depth_layer = load_asset_node_group(DEPTH_MATERIAL_NODE_GROUP_NAME) shadeless = load_asset_node_group("O Shadeless") cutout = load_asset_node_group(CUTOUT_NODE_GROUP_NAME) cutout_groups = { name: bpy.data.node_groups[name] for name in CUTOUT_NODE_GROUP_NAMES.values() } + expected_names = { + "O Mesh Plane", + DEPTH_PLANE_NODE_GROUP_NAME, + IMAGE_MATERIAL_NODE_GROUP_NAME, + DEPTH_MATERIAL_NODE_GROUP_NAME, + "O Shadeless", + CUTOUT_NODE_GROUP_NAME, + *CUTOUT_NODE_GROUP_NAMES.values(), + } + with bpy.data.libraries.load(str(node_asset_path()), link=False) as (source, _): + assert set(source.node_groups) == expected_names assert mesh_plane.name == "O Mesh Plane" assert mesh_plane.bl_idname == "GeometryNodeTree" @@ -45,6 +63,34 @@ def main(): "Thickness", "Material", ] + assert depth_plane.bl_idname == "GeometryNodeTree" + assert depth_plane.is_modifier + assert input_names(depth_plane) == [ + "Geometry", + "Subdivide", + "Thickness", + "Depth Amount", + "Reference Depth", + "Uniform Scale", + "Depth Image", + "Material", + ] + assert image_layer.bl_idname == "ShaderNodeTree" + assert input_names(image_layer) == [ + "Color", + "Alpha", + "Normal", + "Normal Scale", + "Object Space", + "Bump Scale", + ] + assert depth_layer.bl_idname == "ShaderNodeTree" + assert input_names(depth_layer) == [ + "Color", + "Alpha", + "Depth", + "Bump Scale", + ] assert shadeless.name == "O Shadeless" assert shadeless.bl_idname == "ShaderNodeTree" assert input_names(shadeless) == ["Color"] @@ -59,7 +105,14 @@ def main(): assert not group.is_modifier assert input_names(group)[0] == "Geometry" - public_groups = {mesh_plane, shadeless, cutout} + public_groups = { + mesh_plane, + depth_plane, + image_layer, + depth_layer, + shadeless, + cutout, + } loaded = {*public_groups, *cutout_groups.values()} assert all(group.use_fake_user for group in public_groups) assert all(group.users > 0 for group in cutout_groups.values())