Add comprehensive tests for image processing nodes and refactor node imports
- Introduced new test files for image layer, image plane, normal scale, object normal rotation, shadeless, and temporary attributes. - Implemented tests to validate the behavior of alpha curves, depth layers, and normal scales in various rendering engines. - Refactored import paths for node groups to align with the new directory structure. - Added checks for temporary attribute lifetimes and ensured proper cleanup without warnings. - Enhanced CLI commands to require the Blender group and validate the presence of generated node assets before packaging. - Updated packaging scripts to enforce the existence of necessary assets, preventing build failures.
This commit is contained in:
@@ -4,7 +4,7 @@ from .branches import calculation_blocks
|
||||
from .dimensions import estimate_node_dimensions, visible_socket
|
||||
from .flow import origin_socket, socket_links
|
||||
from .layout import plan_layout
|
||||
from ..common.nodes import prepare_node_group, replace_link_source
|
||||
from nodes.common.nodes import prepare_node_group, replace_link_source
|
||||
|
||||
|
||||
def _remove_routes(node_group):
|
||||
|
||||
@@ -4,16 +4,16 @@ from pathlib import Path
|
||||
import bpy
|
||||
|
||||
from .arrangement.arrange import arrange_nodes
|
||||
from .groups.image_plane import build_image_plane_group
|
||||
from .groups.image_depth_plane import build_image_depth_plane_group
|
||||
from .groups.image_relief_plane import build_image_relief_plane_group
|
||||
from .groups.image_cutout import build_image_cutout_group
|
||||
from .groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from .groups.image_cutout_symmetry import build_image_cutout_symmetry_group
|
||||
from .groups.image_layer import build_image_layer_group
|
||||
from .groups.image_depth_layer import build_image_depth_layer_group
|
||||
from .groups.shadeless import build_shadeless_group
|
||||
from .groups.image_depth_panorama import build_image_depth_panorama_group
|
||||
from nodes.groups.image_plane import build_image_plane_group
|
||||
from nodes.groups.image_depth_plane import build_image_depth_plane_group
|
||||
from nodes.groups.image_relief_plane import build_image_relief_plane_group
|
||||
from nodes.groups.image_cutout import build_image_cutout_group
|
||||
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
|
||||
from nodes.groups.image_layer import build_image_layer_group
|
||||
from nodes.groups.image_depth_layer import build_image_depth_layer_group
|
||||
from nodes.groups.shadeless import build_shadeless_group
|
||||
from nodes.groups.image_depth_panorama import build_image_depth_panorama_group
|
||||
|
||||
|
||||
OUTPUT_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyImage.blend"
|
||||
|
||||
@@ -1,81 +1,29 @@
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory, mkdtemp
|
||||
from zipfile import ZipFile
|
||||
from tempfile import mkdtemp
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def project_env_value(name, path=None):
|
||||
env_path = path or PROJECT_ROOT / ".env"
|
||||
if not env_path.is_file():
|
||||
return None
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("export "):
|
||||
line = line.removeprefix("export ").lstrip()
|
||||
key, separator, value = line.partition("=")
|
||||
if separator and key.strip() == name:
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
value = value[1:-1]
|
||||
return value or None
|
||||
return None
|
||||
|
||||
|
||||
def blender_path(explicit=None):
|
||||
candidate = (
|
||||
explicit
|
||||
or os.environ.get("BLENDER_BIN")
|
||||
or project_env_value("BLENDER_BIN")
|
||||
or shutil.which("blender")
|
||||
)
|
||||
if not candidate:
|
||||
raise RuntimeError(
|
||||
"Blender not found: pass --blender, set BLENDER_BIN, add it to .env, "
|
||||
"or add blender to PATH"
|
||||
)
|
||||
path = Path(candidate).expanduser()
|
||||
if not path.is_file():
|
||||
raise RuntimeError(f"Blender executable does not exist: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def run(command):
|
||||
print("+", subprocess.list2cmdline([str(part) for part in command]), flush=True)
|
||||
subprocess.run(command, cwd=PROJECT_ROOT, check=True)
|
||||
|
||||
|
||||
def run_blender_modules(executable, modules, arguments=()):
|
||||
with TemporaryDirectory(prefix="anyimage-node-dependencies-") as directory:
|
||||
for wheel in (PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("scipy-*.whl"):
|
||||
with ZipFile(wheel) as archive:
|
||||
archive.extractall(Path(directory) / wheel.name.split("-")[2])
|
||||
paths = [str(PROJECT_ROOT), str(PROJECT_ROOT / "src"),
|
||||
str(next((PROJECT_ROOT / "src/anyimage/wheels").glob("blendjob-*.whl")))]
|
||||
bootstrap = (
|
||||
f"import sys, runpy; sys.path[:0] = {paths!r}; "
|
||||
f"sys.path.insert(0, {directory!r} + '/cp' + str(sys.version_info.major) + str(sys.version_info.minor)); "
|
||||
def require_bpy():
|
||||
if importlib.util.find_spec("bpy") is None:
|
||||
raise RuntimeError(
|
||||
"bpy is required; run with: uv run --group blender node-group ..."
|
||||
)
|
||||
for module_name in modules:
|
||||
run(
|
||||
(
|
||||
executable,
|
||||
"--factory-startup",
|
||||
"--background",
|
||||
"--python-exit-code", "1",
|
||||
"--python-expr", bootstrap + f"sys.argv = {[module_name, *arguments]!r}; "
|
||||
+ f"runpy.run_module({module_name!r}, run_name='__main__')",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def run_module(module_name, arguments=()):
|
||||
run((sys.executable, "-m", module_name, *arguments))
|
||||
|
||||
|
||||
def main():
|
||||
@@ -87,7 +35,6 @@ def main():
|
||||
("preview", "Preview node groups from current source"),
|
||||
):
|
||||
command = commands.add_parser(name, help=help_text)
|
||||
command.add_argument("--blender", help="Blender executable; overrides BLENDER_BIN, .env, and PATH")
|
||||
if name == "preview":
|
||||
command.add_argument("--output", type=Path, help="Output HTML path; defaults to a temporary directory")
|
||||
command.add_argument("--no-open", action="store_true", help="Save without opening a browser")
|
||||
@@ -98,21 +45,22 @@ def main():
|
||||
if options.command is None:
|
||||
parser.print_help()
|
||||
return
|
||||
executable = blender_path(options.blender)
|
||||
require_bpy()
|
||||
if options.command == "preview":
|
||||
output = (options.output or Path(mkdtemp(prefix="anyimage-node-preview-")) / "nodes.html").resolve()
|
||||
arguments = ["--output", str(output)]
|
||||
if options.fragment:
|
||||
arguments.append("--fragment")
|
||||
run_blender_modules(executable, ("tools.nodes.preview.export",), arguments)
|
||||
run_module("tools.nodes.preview.export", arguments)
|
||||
print(output)
|
||||
if not options.no_open and not options.fragment:
|
||||
webbrowser.open(output.as_uri())
|
||||
return
|
||||
modules = ("tools.nodes.build", "tools.nodes.check") if options.command == "build" else ("tools.nodes.check",)
|
||||
run_blender_modules(executable, modules)
|
||||
for module_name in modules:
|
||||
run_module(module_name)
|
||||
if not options.skip_tests:
|
||||
run((sys.executable, "-m", "pytest"))
|
||||
run((sys.executable, "-m", "pytest", "tests/nodes", "tests/tools/nodes"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Apply boundary-preserving smoothing within a narrow cut-edge band."""
|
||||
|
||||
from .nodes import boolean_node, compare_node, evaluate_field, group_input
|
||||
from .depth_surface import _math
|
||||
from .smoothing import edge_boundary_field, pinned_smooth
|
||||
|
||||
|
||||
def _gated(group, value, blocked, *, default):
|
||||
"""Return the value unless the blocked control is true."""
|
||||
if blocked is False:
|
||||
return value
|
||||
node = group.nodes.new("GeometryNodeSwitch")
|
||||
node.input_type = "FLOAT"
|
||||
node.inputs["True"].default_value = default
|
||||
if isinstance(blocked, bool):
|
||||
node.inputs["Switch"].default_value = blocked
|
||||
else:
|
||||
group.links.new(blocked, node.inputs["Switch"])
|
||||
group.links.new(value, node.inputs["False"])
|
||||
return node.outputs[0]
|
||||
|
||||
|
||||
def boundary_influence(group, protected=False, *, boundary=None):
|
||||
"""Fade a boundary field through two rings while excluding protected points."""
|
||||
nodes, links = group.nodes, group.links
|
||||
if boundary is None:
|
||||
boundary = edge_boundary_field(nodes, links)
|
||||
boundary = evaluate_field(group, boundary, "FLOAT", "POINT")
|
||||
cut = _gated(group, boundary, protected, default=0.0)
|
||||
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"])
|
||||
mixed = _math(group, "MAXIMUM", cut, blur.outputs["Value"])
|
||||
return _gated(group, mixed, protected, default=0.0)
|
||||
|
||||
|
||||
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)
|
||||
return reconstruct_camera_depth(group, camera, depth, smoothed)
|
||||
|
||||
|
||||
def reconstruct_camera_depth(group, camera, depth, smoothed):
|
||||
"""Move each valid camera point along its original projection ray."""
|
||||
nodes, links = group.nodes, group.links
|
||||
changed = compare_node(group, "GREATER_THAN", _math(group, "ABSOLUTE", _math(group, "SUBTRACT", smoothed, depth)), 0)
|
||||
enabled = boolean_node(group, "AND", changed, compare_node(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 cut_boundary_influence(group, split_boundary, protected=False, *, outline_scale=0.1, boundary=None):
|
||||
"""Give split edges full influence and outline edges a configured share."""
|
||||
nodes, links = group.nodes, group.links
|
||||
if boundary is None:
|
||||
boundary = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
|
||||
split = _gated(group, boundary, boolean_node(group, "NOT", split_boundary), default=0.0)
|
||||
outline = _math(group, "MAXIMUM", _math(group, "SUBTRACT", boundary, split), 0)
|
||||
split_influence = boundary_influence(group, protected, boundary=split)
|
||||
outline_influence = _math(
|
||||
group, "MULTIPLY", boundary_influence(group, protected, boundary=outline), outline_scale,
|
||||
)
|
||||
return _math(group, "MAXIMUM", split_influence, outline_influence)
|
||||
|
||||
|
||||
def smooth_cut_boundary(group, geometry, split_boundary=False, *, protected=False, outline_scale=0.1, boundary=None):
|
||||
"""Move boundary vertices toward the pinned position through split and outline influence."""
|
||||
influence = cut_boundary_influence(
|
||||
group, split_boundary, protected, outline_scale=outline_scale, boundary=boundary,
|
||||
)
|
||||
# The support band depends on topology, which stays unchanged in the loop.
|
||||
iterations = group_input(group.nodes, {"Boundary Smooth"}).outputs["Boundary Smooth"]
|
||||
return pinned_smooth(group, geometry, iterations, influence)
|
||||
@@ -1,139 +0,0 @@
|
||||
"""Build geometry calculations shared by Cutout assets."""
|
||||
from .nodes import compare_node, math_node
|
||||
from anyimage.operators.cutout_tool.shape import BALLOON_ATTRIBUTE_NAME
|
||||
|
||||
|
||||
def _profile_field(group, geometry):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
profile = nodes.new("GeometryNodeInputNamedAttribute")
|
||||
profile.data_type = "FLOAT"
|
||||
profile.inputs["Name"].default_value = BALLOON_ATTRIBUTE_NAME
|
||||
profile_max = nodes.new("GeometryNodeAttributeStatistic")
|
||||
profile_max.data_type = "FLOAT"
|
||||
profile_max.domain = "POINT"
|
||||
links.new(geometry, profile_max.inputs["Geometry"])
|
||||
links.new(profile.outputs["Attribute"], profile_max.inputs["Attribute"])
|
||||
safe_max = math_node(nodes, "MAXIMUM")
|
||||
safe_max.inputs[1].default_value = 1e-6
|
||||
links.new(profile_max.outputs["Max"], safe_max.inputs[0])
|
||||
normalized = math_node(nodes, "DIVIDE")
|
||||
links.new(profile.outputs["Attribute"], normalized.inputs[0])
|
||||
links.new(safe_max.outputs[0], normalized.inputs[1])
|
||||
smooth = nodes.new("ShaderNodeMapRange")
|
||||
smooth.data_type = "FLOAT"
|
||||
smooth.interpolation_type = "SMOOTHERSTEP"
|
||||
smooth.clamp = True
|
||||
smooth.inputs["From Min"].default_value = 0.0
|
||||
smooth.inputs["From Max"].default_value = 1.0
|
||||
smooth.inputs["To Min"].default_value = 0.0
|
||||
smooth.inputs["To Max"].default_value = 1.0
|
||||
links.new(normalized.outputs[0], smooth.inputs["Value"])
|
||||
return profile.outputs["Attribute"], smooth.outputs["Result"]
|
||||
|
||||
|
||||
def _extrude_layers(group, geometry):
|
||||
node = group.nodes.new("GeometryNodeExtrudeMesh")
|
||||
node.mode = "FACES"
|
||||
node.inputs["Individual"].default_value = False
|
||||
node.inputs["Offset"].default_value = (0.0, 1.0, 0.0)
|
||||
# Keep distinct front and back vertices before overriding their Y positions.
|
||||
node.inputs["Offset Scale"].default_value = 1.0
|
||||
group.links.new(geometry, node.inputs["Mesh"])
|
||||
return node
|
||||
|
||||
|
||||
def _offset_geometry_y(group, geometry, y_value):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
offset = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(y_value, offset.inputs["Y"])
|
||||
set_position = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(geometry, set_position.inputs["Geometry"])
|
||||
links.new(offset.outputs["Vector"], set_position.inputs["Offset"])
|
||||
return set_position.outputs["Geometry"]
|
||||
|
||||
|
||||
def _set_layer_y(group, geometry, extrude, front_y, back_y):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
choose_y = nodes.new("GeometryNodeSwitch")
|
||||
choose_y.input_type = "FLOAT"
|
||||
links.new(extrude.outputs["Top"], choose_y.inputs["Switch"])
|
||||
links.new(front_y, choose_y.inputs["False"])
|
||||
links.new(back_y, choose_y.inputs["True"])
|
||||
position = nodes.new("GeometryNodeInputPosition")
|
||||
separate = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(position.outputs["Position"], separate.inputs["Vector"])
|
||||
combine = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(separate.outputs["X"], combine.inputs["X"])
|
||||
links.new(choose_y.outputs["Output"], combine.inputs["Y"])
|
||||
links.new(separate.outputs["Z"], combine.inputs["Z"])
|
||||
set_position = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(extrude.outputs["Mesh"], set_position.inputs["Geometry"])
|
||||
links.new(combine.outputs["Vector"], set_position.inputs["Position"])
|
||||
|
||||
side = nodes.new("GeometryNodeSeparateGeometry")
|
||||
side.domain = "FACE"
|
||||
links.new(set_position.outputs["Geometry"], side.inputs["Geometry"])
|
||||
links.new(extrude.outputs["Side"], side.inputs["Selection"])
|
||||
flip_side = nodes.new("GeometryNodeFlipFaces")
|
||||
links.new(side.outputs["Selection"], flip_side.inputs["Mesh"])
|
||||
front = _offset_geometry_y(
|
||||
group, geometry, front_y
|
||||
)
|
||||
back = _offset_geometry_y(
|
||||
group, geometry, back_y
|
||||
)
|
||||
flip_back = nodes.new("GeometryNodeFlipFaces")
|
||||
links.new(back, flip_back.inputs["Mesh"])
|
||||
|
||||
join = nodes.new("GeometryNodeJoinGeometry")
|
||||
links.new(front, join.inputs["Geometry"])
|
||||
links.new(flip_back.outputs["Mesh"], join.inputs["Geometry"])
|
||||
links.new(flip_side.outputs["Mesh"], join.inputs["Geometry"])
|
||||
merge = nodes.new("GeometryNodeMergeByDistance")
|
||||
merge.mode = "ALL"
|
||||
merge.inputs["Distance"].default_value = 1e-6
|
||||
links.new(join.outputs["Geometry"], merge.inputs["Geometry"])
|
||||
return merge.outputs["Geometry"]
|
||||
|
||||
|
||||
def _remove_zero_thickness(
|
||||
group,
|
||||
zero_geometry,
|
||||
shaped_geometry,
|
||||
thickness,
|
||||
):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
zero = compare_node(group, "LESS_THAN", thickness, 1e-6)
|
||||
choose = nodes.new("GeometryNodeSwitch")
|
||||
choose.input_type = "GEOMETRY"
|
||||
links.new(zero, choose.inputs["Switch"])
|
||||
links.new(shaped_geometry, choose.inputs["False"])
|
||||
links.new(zero_geometry, choose.inputs["True"])
|
||||
return choose.outputs["Output"]
|
||||
|
||||
|
||||
def _position_field(group, depth_image):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
uv = nodes.new("GeometryNodeInputNamedAttribute")
|
||||
uv.data_type = "FLOAT_VECTOR"
|
||||
uv.inputs["Name"].default_value = "UVMap"
|
||||
position_texture = nodes.new("GeometryNodeImageTexture")
|
||||
position_texture.interpolation = "Linear"
|
||||
position_texture.extension = "EXTEND"
|
||||
links.new(depth_image, position_texture.inputs["Image"])
|
||||
links.new(uv.outputs["Attribute"], position_texture.inputs["Vector"])
|
||||
return position_texture.outputs["Color"]
|
||||
|
||||
|
||||
def _shade_output(group, geometry):
|
||||
shade = group.nodes.new("GeometryNodeSetShadeSmooth")
|
||||
shade.domain = "FACE"
|
||||
shade.inputs["Shade Smooth"].default_value = True
|
||||
output = group.nodes.new("NodeGroupOutput")
|
||||
group.links.new(geometry, shade.inputs["Geometry"])
|
||||
group.links.new(shade.outputs["Geometry"], output.inputs["Geometry"])
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Clean cutout boundary triangles and taper the split thickness profile."""
|
||||
|
||||
from .depth_surface import _math
|
||||
from .nodes import boolean_node, compare_node, evaluate_field, read_float_attribute, 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 = compare_node(group, "GREATER_THAN", evaluate_field(group, rim, "FLOAT", "FACE"), 0.99999)
|
||||
count = nodes.new("GeometryNodeInputMeshFaceNeighbors").outputs["Vertex Count"]
|
||||
selected = boolean_node(group, "AND", all_boundary, compare_node(group, "EQUAL", count, 3, data_type="INT"))
|
||||
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, geometry, cut_boundary):
|
||||
"""Taper the balloon profile to zero across the Depth Split cuts."""
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
retained = _math(group, "SUBTRACT", 1, boundary_influence(group, boundary=cut_boundary))
|
||||
profile = _math(group, "MULTIPLY", read_float_attribute(group, "o_balloon"), retained)
|
||||
return store_float_attribute(group, geometry, "o_balloon", profile)
|
||||
@@ -1,473 +0,0 @@
|
||||
"""Face separation and connected thickness for Depth Surface assets."""
|
||||
|
||||
from .nodes import (
|
||||
boolean_node, compare_node, evaluate_field, store_boolean_attribute,
|
||||
remove_attribute_pattern, sample_field,
|
||||
)
|
||||
from .smoothing import edge_boundary_field
|
||||
|
||||
|
||||
CUT_ATTRIBUTE = "_o_depth_cut"
|
||||
|
||||
|
||||
def _math(group, operation, a, b=None):
|
||||
node = group.nodes.new("ShaderNodeMath")
|
||||
node.operation = operation
|
||||
for index, value in enumerate((a, b)):
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (int, float)):
|
||||
node.inputs[index].default_value = value
|
||||
else:
|
||||
group.links.new(value, node.inputs[index])
|
||||
return node.outputs[0]
|
||||
|
||||
|
||||
def _remove_strip_faces(group, geometry, *, triangles=False):
|
||||
"""Delete boundary strips from one unchanged topology snapshot."""
|
||||
if triangles:
|
||||
return _remove_triangle_strip_faces(group, geometry)
|
||||
return _remove_quad_strip_faces(group, geometry)
|
||||
|
||||
|
||||
def _remove_quad_strip_faces(group, geometry):
|
||||
"""Delete boundary quad strips while preserving open boundary edges."""
|
||||
nodes, links = group.nodes, group.links
|
||||
|
||||
def equals(value, count):
|
||||
return compare_node(group, "EQUAL", value, count, data_type="INT")
|
||||
|
||||
boundaries = []
|
||||
for offset in range(4):
|
||||
index = nodes.new("GeometryNodeInputIndex")
|
||||
corners = nodes.new("GeometryNodeCornersOfFace")
|
||||
links.new(index.outputs[0], corners.inputs["Face Index"])
|
||||
corner = nodes.new("GeometryNodeOffsetCornerInFace")
|
||||
corner.inputs["Offset"].default_value = offset
|
||||
links.new(corners.outputs["Corner Index"], corner.inputs["Corner Index"])
|
||||
edge = nodes.new("GeometryNodeEdgesOfCorner")
|
||||
links.new(corner.outputs["Corner Index"], edge.inputs["Corner Index"])
|
||||
sample = nodes.new("GeometryNodeFieldAtIndex")
|
||||
sample.data_type, sample.domain = "INT", "EDGE"
|
||||
links.new(edge.outputs["Next Edge Index"], sample.inputs["Index"])
|
||||
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
links.new(neighbors.outputs["Face Count"], sample.inputs["Value"])
|
||||
boundaries.append(equals(sample.outputs["Value"], 1))
|
||||
opposite = boolean_node(
|
||||
group, "OR",
|
||||
boolean_node(group, "AND", boundaries[0], boundaries[2]),
|
||||
boolean_node(group, "AND", boundaries[1], boundaries[3]),
|
||||
)
|
||||
face = nodes.new("GeometryNodeInputMeshFaceNeighbors")
|
||||
selected = boolean_node(group, "AND", equals(face.outputs["Vertex Count"], 4), opposite)
|
||||
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 _remove_triangle_strip_faces(group, geometry):
|
||||
"""Delete symmetric triangle strip pairs and all-boundary ears."""
|
||||
nodes, links = group.nodes, group.links
|
||||
|
||||
def equals(value, count):
|
||||
return compare_node(group, "EQUAL", value, count, data_type="INT")
|
||||
|
||||
def sample(value, index, domain, data_type="INT"):
|
||||
node = nodes.new("GeometryNodeFieldAtIndex")
|
||||
node.data_type, node.domain = data_type, domain
|
||||
links.new(value, node.inputs["Value"])
|
||||
links.new(index, node.inputs["Index"])
|
||||
return node.outputs["Value"]
|
||||
|
||||
def offset_corner(corner, offset):
|
||||
node = nodes.new("GeometryNodeOffsetCornerInFace")
|
||||
links.new(corner, node.inputs["Corner Index"])
|
||||
node.inputs["Offset"].default_value = offset
|
||||
return node.outputs["Corner Index"]
|
||||
|
||||
def vertex(corner):
|
||||
node = nodes.new("GeometryNodeVertexOfCorner")
|
||||
links.new(corner, node.inputs["Corner Index"])
|
||||
return node.outputs["Vertex Index"]
|
||||
|
||||
def boundary(corner):
|
||||
edge = nodes.new("GeometryNodeEdgesOfCorner")
|
||||
links.new(corner, edge.inputs["Corner Index"])
|
||||
count = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
return equals(sample(count.outputs["Face Count"], edge.outputs["Next Edge Index"], "EDGE"), 1)
|
||||
|
||||
edge_index = nodes.new("GeometryNodeInputIndex")
|
||||
sides = []
|
||||
for side in (0, 1):
|
||||
corners = nodes.new("GeometryNodeCornersOfEdge")
|
||||
links.new(edge_index.outputs[0], corners.inputs["Edge Index"])
|
||||
corners.inputs["Sort Index"].default_value = side
|
||||
corner = corners.outputs["Corner Index"]
|
||||
face_index = nodes.new("GeometryNodeFaceOfCorner")
|
||||
links.new(corner, face_index.inputs["Corner Index"])
|
||||
face_size = nodes.new("GeometryNodeInputMeshFaceNeighbors")
|
||||
triangle = equals(sample(face_size.outputs["Vertex Count"], face_index.outputs["Face Index"], "FACE"), 3)
|
||||
end, tip = offset_corner(corner, 1), offset_corner(corner, 2)
|
||||
sides.append((triangle, vertex(corner), vertex(tip), boundary(end), boundary(tip)))
|
||||
a, b = sides
|
||||
same_direction = equals(a[1], b[1])
|
||||
same_order = boolean_node(
|
||||
group, "OR",
|
||||
boolean_node(group, "AND", a[3], b[3]),
|
||||
boolean_node(group, "AND", a[4], b[4]),
|
||||
)
|
||||
reverse_order = boolean_node(
|
||||
group, "OR",
|
||||
boolean_node(group, "AND", a[3], b[4]),
|
||||
boolean_node(group, "AND", a[4], b[3]),
|
||||
)
|
||||
pair = boolean_node(
|
||||
group, "OR",
|
||||
boolean_node(group, "AND", same_direction, reverse_order),
|
||||
boolean_node(group, "AND", boolean_node(group, "NOT", same_direction), same_order),
|
||||
)
|
||||
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
for condition in (
|
||||
equals(neighbors.outputs["Face Count"], 2),
|
||||
a[0],
|
||||
b[0],
|
||||
boolean_node(group, "NOT", equals(a[2], b[2])),
|
||||
):
|
||||
pair = boolean_node(group, "AND", pair, condition)
|
||||
|
||||
face_index = nodes.new("GeometryNodeInputIndex")
|
||||
corners = nodes.new("GeometryNodeCornersOfFace")
|
||||
links.new(face_index.outputs[0], corners.inputs["Face Index"])
|
||||
boundary_count, paired = None, None
|
||||
for offset in range(3):
|
||||
corner = offset_corner(corners.outputs["Corner Index"], offset)
|
||||
edge = nodes.new("GeometryNodeEdgesOfCorner")
|
||||
links.new(corner, edge.inputs["Corner Index"])
|
||||
match = sample(pair, edge.outputs["Next Edge Index"], "EDGE", "BOOLEAN")
|
||||
paired = match if paired is None else boolean_node(group, "OR", paired, match)
|
||||
rim = boundary(corner)
|
||||
if boundary_count is None:
|
||||
boundary_count = rim
|
||||
else:
|
||||
add = nodes.new("FunctionNodeIntegerMath")
|
||||
add.operation = "ADD"
|
||||
links.new(boundary_count, add.inputs[0])
|
||||
links.new(rim, add.inputs[1])
|
||||
boundary_count = add.outputs[0]
|
||||
selected = boolean_node(
|
||||
group, "AND",
|
||||
equals(corners.outputs["Total"], 3),
|
||||
boolean_node(group, "OR", paired, equals(boundary_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 sample_face_camera(group, image):
|
||||
"""Sample camera XYZ at each planar face's UV center."""
|
||||
nodes, links = group.nodes, group.links
|
||||
uv = nodes.new("GeometryNodeInputNamedAttribute")
|
||||
uv.data_type = "FLOAT_VECTOR"
|
||||
uv.inputs["Name"].default_value = "UVMap"
|
||||
face_uv = nodes.new("GeometryNodeFieldOnDomain")
|
||||
face_uv.data_type = "FLOAT_VECTOR"
|
||||
face_uv.domain = "FACE"
|
||||
links.new(uv.outputs["Attribute"], face_uv.inputs["Value"])
|
||||
texture = nodes.new("GeometryNodeImageTexture")
|
||||
texture.interpolation = "Linear"
|
||||
texture.extension = "EXTEND"
|
||||
links.new(image, texture.inputs["Image"])
|
||||
links.new(face_uv.outputs["Value"], texture.inputs["Vector"])
|
||||
return texture.outputs["Color"]
|
||||
|
||||
|
||||
def ray_distance(group, camera):
|
||||
"""Measure the distance from the camera origin to a camera-space point."""
|
||||
length = group.nodes.new("ShaderNodeVectorMath")
|
||||
length.operation = "LENGTH"
|
||||
group.links.new(camera, length.inputs[0])
|
||||
return length.outputs["Value"]
|
||||
|
||||
|
||||
def split_depth_surface(group, geometry, face_camera, strength, reference_depth, depth_scale, *,
|
||||
face_distance=None, face_center=None, triangles=False):
|
||||
"""Split relative ray-distance jumps over the base face-center spacing.
|
||||
|
||||
The jump is scaled by Depth Scale, so a flat projection keeps its faces.
|
||||
"""
|
||||
nodes, links = group.nodes, group.links
|
||||
if face_center is None:
|
||||
face_center = nodes.new("GeometryNodeInputPosition").outputs[0]
|
||||
face_camera = evaluate_field(group, face_camera, "FLOAT_VECTOR", "FACE")
|
||||
if face_distance is None:
|
||||
face_distance = ray_distance(group, face_camera)
|
||||
centers, depths = [], []
|
||||
for side in (0, 1):
|
||||
corner = nodes.new("GeometryNodeCornersOfEdge")
|
||||
corner.inputs["Sort Index"].default_value = side
|
||||
face = nodes.new("GeometryNodeFaceOfCorner")
|
||||
links.new(corner.outputs["Corner Index"], face.inputs["Corner Index"])
|
||||
for data_type, field, results in (
|
||||
("FLOAT_VECTOR", face_center, centers),
|
||||
("FLOAT", face_distance, depths),
|
||||
):
|
||||
sample = nodes.new("GeometryNodeFieldAtIndex")
|
||||
sample.data_type = data_type
|
||||
sample.domain = "FACE"
|
||||
links.new(face.outputs["Face Index"], sample.inputs["Index"])
|
||||
links.new(field, sample.inputs["Value"])
|
||||
results.append(sample.outputs["Value"])
|
||||
distance = nodes.new("ShaderNodeVectorMath")
|
||||
distance.operation = "DISTANCE"
|
||||
for index, center in enumerate(centers):
|
||||
links.new(center, distance.inputs[index])
|
||||
delta = _math(group, "ABSOLUTE", _math(group, "SUBTRACT", *depths))
|
||||
# Depth Scale is the display amplitude of that jump on the projected surface.
|
||||
delta = _math(group, "MULTIPLY", delta, depth_scale)
|
||||
local_distance = _math(group, "MAXIMUM", _math(group, "MULTIPLY", _math(group, "ADD", *depths), 0.5), 1e-8)
|
||||
reference = _math(group, "MAXIMUM", reference_depth, 1e-8)
|
||||
# Uniform Scale cancels between the distance jump and local spacing correction.
|
||||
# Cross multiplication also avoids dividing by the local distance.
|
||||
selected = compare_node(
|
||||
group,
|
||||
"GREATER_THAN",
|
||||
_math(group, "MULTIPLY", _math(group, "MULTIPLY", delta, reference), strength),
|
||||
_math(group, "MULTIPLY", _math(group, "MULTIPLY", distance.outputs["Value"], local_distance), 2.5),
|
||||
)
|
||||
selected = boolean_node(
|
||||
group,
|
||||
"AND",
|
||||
selected,
|
||||
compare_node(group, "GREATER_THAN", distance.outputs["Value"], 0.0),
|
||||
)
|
||||
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
selected = boolean_node(
|
||||
group,
|
||||
"AND",
|
||||
selected,
|
||||
compare_node(group, "EQUAL", neighbors.outputs["Face Count"], 2, data_type="INT"),
|
||||
)
|
||||
cut_edges = nodes.new("GeometryNodeFieldOnDomain")
|
||||
cut_edges.data_type = "FLOAT"
|
||||
cut_edges.domain = "EDGE"
|
||||
links.new(selected, cut_edges.inputs["Value"])
|
||||
cut_vertices = nodes.new("GeometryNodeFieldOnDomain")
|
||||
cut_vertices.data_type = "FLOAT"
|
||||
cut_vertices.domain = "POINT"
|
||||
links.new(cut_edges.outputs["Value"], cut_vertices.inputs["Value"])
|
||||
corners, cut = store_boolean_attribute(
|
||||
group, geometry, CUT_ATTRIBUTE,
|
||||
compare_node(group, "GREATER_THAN", cut_vertices.outputs["Value"], 0.0), domain="CORNER",
|
||||
)
|
||||
split = nodes.new("GeometryNodeSplitEdges")
|
||||
links.new(corners, split.inputs["Mesh"])
|
||||
links.new(selected, split.inputs["Selection"])
|
||||
enabled = boolean_node(
|
||||
group,
|
||||
"AND",
|
||||
compare_node(group, "GREATER_THAN", strength, 0.0),
|
||||
compare_node(group, "GREATER_THAN", depth_scale, 0.0),
|
||||
)
|
||||
choose = nodes.new("GeometryNodeSwitch")
|
||||
choose.input_type = "GEOMETRY"
|
||||
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"], face_camera, cut
|
||||
|
||||
|
||||
def build_surface_camera(group, original, separated, cut_vertex, strength):
|
||||
"""Take cut depth from the adjacent face sample along each unchanged ray.
|
||||
|
||||
Depth Split edges and every open boundary left by Depth Mask deletion share
|
||||
one replacement, so each cut edge sits on the surface it belongs to.
|
||||
"""
|
||||
nodes, links = group.nodes, group.links
|
||||
cut_boundary = evaluate_field(group, edge_boundary_field(nodes, links), "BOOLEAN", "POINT")
|
||||
enabled = nodes.new("GeometryNodeSwitch")
|
||||
enabled.input_type = "BOOLEAN"
|
||||
links.new(compare_node(group, "GREATER_THAN", strength, 0.0), enabled.inputs["Switch"])
|
||||
enabled.inputs["False"].default_value = False
|
||||
links.new(cut_vertex, enabled.inputs["True"])
|
||||
combined = nodes.new("FunctionNodeBooleanMath")
|
||||
combined.operation = "OR"
|
||||
links.new(enabled.outputs["Output"], combined.inputs[0])
|
||||
links.new(cut_boundary, combined.inputs[1])
|
||||
enabled = combined.outputs["Boolean"]
|
||||
original_axes = group.nodes.new("ShaderNodeSeparateXYZ")
|
||||
separated_axes = group.nodes.new("ShaderNodeSeparateXYZ")
|
||||
group.links.new(original, original_axes.inputs[0])
|
||||
group.links.new(separated, separated_axes.inputs[0])
|
||||
ratio = _math(
|
||||
group, "DIVIDE", separated_axes.outputs["Z"], original_axes.outputs["Z"]
|
||||
)
|
||||
projected = group.nodes.new("ShaderNodeVectorMath")
|
||||
projected.operation = "SCALE"
|
||||
group.links.new(original, projected.inputs[0])
|
||||
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(original, choose.inputs["False"])
|
||||
group.links.new(projected.outputs[0], choose.inputs["True"])
|
||||
return evaluate_field(group, choose.outputs["Output"], "FLOAT_VECTOR", "POINT")
|
||||
|
||||
|
||||
def thicken_depth_surface(group, geometry, thickness, normal_smooth=None):
|
||||
"""Connect caps with single quads by source vertex and cap identity."""
|
||||
nodes, links = group.nodes, group.links
|
||||
position = nodes.new("GeometryNodeInputPosition")
|
||||
index = nodes.new("GeometryNodeInputIndex")
|
||||
# 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", "Z")):
|
||||
part = nodes.new("FunctionNodeIntegerMath")
|
||||
part.operation = operation
|
||||
part.inputs[1].default_value = 4096
|
||||
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["Z"], 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"
|
||||
extrude.inputs["Individual"].default_value = False
|
||||
extrusion_offset = nodes.new("ShaderNodeCombineXYZ")
|
||||
extrusion_offset.inputs["Y"].default_value = -1.0
|
||||
links.new(extrusion_offset.outputs[0], extrude.inputs["Offset"])
|
||||
links.new(saved, extrude.inputs["Mesh"])
|
||||
sides = nodes.new("GeometryNodeSeparateGeometry")
|
||||
sides.domain = "FACE"
|
||||
links.new(extrude.outputs["Mesh"], sides.inputs["Geometry"])
|
||||
links.new(extrude.outputs["Side"], sides.inputs["Selection"])
|
||||
offset = nodes.new("ShaderNodeCombineXYZ")
|
||||
offset.inputs["Y"].default_value = -1.0
|
||||
back = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(saved, back.inputs["Geometry"])
|
||||
links.new(offset.outputs[0], back.inputs["Offset"])
|
||||
flip_back = nodes.new("GeometryNodeFlipFaces")
|
||||
links.new(back.outputs["Geometry"], flip_back.inputs["Mesh"])
|
||||
flip_sides = nodes.new("GeometryNodeFlipFaces")
|
||||
links.new(sides.outputs["Selection"], flip_sides.inputs["Mesh"])
|
||||
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"]
|
||||
for value in (
|
||||
saved,
|
||||
rear,
|
||||
):
|
||||
links.new(value, join.inputs["Geometry"])
|
||||
construction = join.outputs["Geometry"]
|
||||
merge = nodes.new("GeometryNodeMergeByDistance")
|
||||
merge.mode = "ALL"
|
||||
merge.inputs["Distance"].default_value = 0.1
|
||||
links.new(construction, merge.inputs["Geometry"])
|
||||
displacement = nodes.new("ShaderNodeCombineXYZ")
|
||||
distance = _math(group, "MULTIPLY", layer_axes.outputs["Y"], source_thickness)
|
||||
links.new(distance, displacement.inputs["Y"])
|
||||
restored = nodes.new("ShaderNodeVectorMath")
|
||||
restored.operation = "ADD"
|
||||
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"])
|
||||
if normal_smooth is not None:
|
||||
normal = nodes.new("GeometryNodeInputNormal")
|
||||
blur = nodes.new("GeometryNodeBlurAttribute")
|
||||
blur.data_type = "FLOAT_VECTOR"
|
||||
blur.inputs["Iterations"].default_value = normal_smooth
|
||||
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(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": source_position,
|
||||
"source_index": source_index,
|
||||
"position": restored.outputs[0],
|
||||
"position_input": result.inputs["Position"],
|
||||
}
|
||||
|
||||
|
||||
def _projection_math(nodes, operation):
|
||||
node = nodes.new("ShaderNodeMath")
|
||||
node.operation = operation
|
||||
return node
|
||||
|
||||
|
||||
def project_depth_surface(
|
||||
group, geometry, camera_position, uniform_scale, reference_depth, depth_scale
|
||||
):
|
||||
"""Interpolate the rectangular plane into the model camera coordinates."""
|
||||
nodes, links = group.nodes, group.links
|
||||
scaled = nodes.new("ShaderNodeVectorMath")
|
||||
scaled.operation = "SCALE"
|
||||
links.new(camera_position, scaled.inputs[0])
|
||||
links.new(uniform_scale, scaled.inputs[3])
|
||||
separate = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(scaled.outputs["Vector"], separate.inputs["Vector"])
|
||||
relative_depth = _projection_math(nodes, "SUBTRACT")
|
||||
links.new(separate.outputs["Z"], relative_depth.inputs[0])
|
||||
links.new(reference_depth, relative_depth.inputs[1])
|
||||
front = _projection_math(nodes, "MULTIPLY")
|
||||
links.new(relative_depth.outputs[0], front.inputs[0])
|
||||
links.new(depth_scale, front.inputs[1])
|
||||
|
||||
negative_z = _projection_math(nodes, "MULTIPLY")
|
||||
negative_z.inputs[1].default_value = -1.0
|
||||
links.new(separate.outputs["Y"], negative_z.inputs[0])
|
||||
projected = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(separate.outputs["X"], projected.inputs["X"])
|
||||
links.new(front.outputs[0], projected.inputs["Y"])
|
||||
links.new(negative_z.outputs[0], projected.inputs["Z"])
|
||||
current_position = nodes.new("GeometryNodeInputPosition")
|
||||
blend = nodes.new("ShaderNodeMix")
|
||||
blend.data_type = "VECTOR"
|
||||
blend.clamp_factor = False
|
||||
links.new(depth_scale, blend.inputs[0])
|
||||
links.new(current_position.outputs["Position"], blend.inputs[4])
|
||||
links.new(projected.outputs["Vector"], blend.inputs[5])
|
||||
blended_axes = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(blend.outputs["Result"], blended_axes.inputs["Vector"])
|
||||
surface_position = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(blended_axes.outputs["X"], surface_position.inputs["X"])
|
||||
links.new(front.outputs[0], surface_position.inputs["Y"])
|
||||
links.new(blended_axes.outputs["Z"], surface_position.inputs["Z"])
|
||||
project_geometry = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(geometry, project_geometry.inputs["Geometry"])
|
||||
links.new(surface_position.outputs["Vector"], project_geometry.inputs["Position"])
|
||||
|
||||
return remove_attribute_pattern(group, project_geometry.outputs["Geometry"], "_o_*")
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Build nodes that decode legacy Direction parameters into canonical space."""
|
||||
|
||||
|
||||
def legacy_direction_to_canonical(group, legacy):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
axes = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(legacy, axes.inputs[0])
|
||||
negative_x = nodes.new("ShaderNodeMath")
|
||||
negative_x.operation = "MULTIPLY"
|
||||
negative_x.inputs[1].default_value = -1.0
|
||||
links.new(axes.outputs["X"], negative_x.inputs[0])
|
||||
negative_y = nodes.new("ShaderNodeMath")
|
||||
negative_y.operation = "MULTIPLY"
|
||||
negative_y.inputs[1].default_value = -1.0
|
||||
links.new(axes.outputs["Y"], negative_y.inputs[0])
|
||||
direction = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(negative_x.outputs[0], direction.inputs["X"])
|
||||
links.new(axes.outputs["Z"], direction.inputs["Y"])
|
||||
links.new(negative_y.outputs[0], direction.inputs["Z"])
|
||||
return direction.outputs["Vector"]
|
||||
|
||||
|
||||
def symmetry_legacy_direction_to_canonical(group, legacy):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
axes = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(legacy, axes.inputs[0])
|
||||
direction = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(axes.outputs["X"], direction.inputs["X"])
|
||||
links.new(axes.outputs["Z"], direction.inputs["Y"])
|
||||
links.new(axes.outputs["Y"], direction.inputs["Z"])
|
||||
return direction.outputs["Vector"]
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Carry the input mesh's material slots onto generated geometry."""
|
||||
|
||||
|
||||
def inherit_material_slots(group, geometry, source):
|
||||
nodes, links = group.nodes, group.links
|
||||
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(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(source_vertices.outputs["Result"], delete.inputs["Selection"])
|
||||
return delete.outputs["Geometry"]
|
||||
@@ -1,213 +0,0 @@
|
||||
"""Build interfaces and basic nodes shared by asset definitions."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def prepare_node_group(node_group):
|
||||
"""Hide unused outputs and discard disconnected interface nodes."""
|
||||
for node in list(node_group.nodes):
|
||||
for socket in node.outputs:
|
||||
socket.hide = not socket.is_linked
|
||||
if node.bl_idname == "NodeGroupInput" and not any(socket.is_linked for socket in node.outputs):
|
||||
node_group.nodes.remove(node)
|
||||
|
||||
|
||||
def replace_link_source(node_group, link, source):
|
||||
"""Replace a source while preserving multi-input socket order."""
|
||||
multi = link.to_socket.is_multi_input
|
||||
replacement = node_group.links.new(source, link.to_socket)
|
||||
if multi:
|
||||
replacement.swap_multi_input_sort_id(link)
|
||||
node_group.links.remove(link)
|
||||
|
||||
|
||||
def interface_socket(group, name, in_out, socket_type, parent=None):
|
||||
socket = group.interface.new_socket(
|
||||
name=name,
|
||||
in_out=in_out,
|
||||
socket_type=socket_type,
|
||||
parent=parent,
|
||||
)
|
||||
if in_out == "INPUT" and hasattr(socket, "structure_type"):
|
||||
socket.structure_type = "SINGLE"
|
||||
return socket
|
||||
|
||||
|
||||
def group_input(nodes, visible_names):
|
||||
node = nodes.new("NodeGroupInput")
|
||||
visible = set(visible_names)
|
||||
for output in node.outputs:
|
||||
output.hide = output.name not in visible
|
||||
return node
|
||||
|
||||
|
||||
def float_input(
|
||||
group,
|
||||
name,
|
||||
default,
|
||||
minimum=0.0,
|
||||
maximum=None,
|
||||
subtype=None,
|
||||
parent=None,
|
||||
):
|
||||
socket = interface_socket(group, name, "INPUT", "NodeSocketFloat", parent)
|
||||
socket.default_value = default
|
||||
socket.min_value = minimum
|
||||
if maximum is not None:
|
||||
socket.max_value = maximum
|
||||
if subtype is not None:
|
||||
socket.subtype = subtype
|
||||
return socket
|
||||
|
||||
|
||||
def bool_input(group, name, default=False, parent=None):
|
||||
socket = interface_socket(group, name, "INPUT", "NodeSocketBool", parent)
|
||||
socket.default_value = default
|
||||
return socket
|
||||
|
||||
|
||||
def vector_input(group, name, default, subtype=None, parent=None):
|
||||
socket = interface_socket(group, name, "INPUT", "NodeSocketVector", parent)
|
||||
socket.default_value = default
|
||||
if subtype is not None:
|
||||
socket.subtype = subtype
|
||||
return socket
|
||||
|
||||
|
||||
def math_node(nodes, operation):
|
||||
node = nodes.new("ShaderNodeMath")
|
||||
node.operation = operation
|
||||
return node
|
||||
|
||||
|
||||
_COMPARE_INPUTS = {
|
||||
"FLOAT": ("A", "B"),
|
||||
"INT": ("A_INT", "B_INT"),
|
||||
"VECTOR": ("A_VEC3", "B_VEC3"),
|
||||
"RGBA": ("A_COL", "B_COL"),
|
||||
}
|
||||
|
||||
|
||||
def compare_node(group, operation, a, b, data_type="FLOAT"):
|
||||
"""Compare two inputs on the given data type and return the boolean result."""
|
||||
node = group.nodes.new("FunctionNodeCompare")
|
||||
node.data_type, node.operation = data_type, operation
|
||||
for value, identifier in zip((a, b), _COMPARE_INPUTS[data_type]):
|
||||
socket = next(item for item in node.inputs if item.identifier == identifier)
|
||||
if isinstance(value, (bool, int, float)):
|
||||
socket.default_value = value
|
||||
else:
|
||||
group.links.new(value, socket)
|
||||
return node.outputs["Result"]
|
||||
|
||||
|
||||
def boolean_node(group, operation, a=None, b=None):
|
||||
"""Combine boolean inputs and return the boolean result."""
|
||||
node = group.nodes.new("FunctionNodeBooleanMath")
|
||||
node.operation = operation
|
||||
for value, index in ((a, 0), (b, 1)):
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
node.inputs[index].default_value = value
|
||||
else:
|
||||
group.links.new(value, node.inputs[index])
|
||||
return node.outputs["Boolean"]
|
||||
|
||||
|
||||
def menu_switch(group, control, names, kind):
|
||||
selector = next((node for node in group.nodes if node.bl_idname == "GeometryNodeMenuSwitch" and node.inputs["Menu"].is_linked and node.inputs["Menu"].links[0].from_socket.name == control), None)
|
||||
if selector is None:
|
||||
selector = group.nodes.new("GeometryNodeMenuSwitch")
|
||||
selector.data_type = "BOOLEAN"
|
||||
for item, name in zip(selector.enum_items, names):
|
||||
item.name = name
|
||||
selector.inputs[names[0]].default_value = False
|
||||
selector.inputs[names[1]].default_value = True
|
||||
local = group_input(group.nodes, {control})
|
||||
group.links.new(local.outputs[control], selector.inputs["Menu"])
|
||||
node = group.nodes.new("GeometryNodeSwitch")
|
||||
node.input_type = kind
|
||||
group.links.new(selector.outputs[0], node.inputs["Switch"])
|
||||
return SimpleNamespace(inputs={names[0]: node.inputs["False"], names[1]: node.inputs["True"]}, outputs=node.outputs)
|
||||
|
||||
|
||||
def read_float_attribute(group, name):
|
||||
node = group.nodes.new("GeometryNodeInputNamedAttribute")
|
||||
node.data_type = "FLOAT"
|
||||
node.inputs["Name"].default_value = name
|
||||
return node.outputs["Attribute"]
|
||||
|
||||
|
||||
def read_vector_attribute(group, name):
|
||||
node = group.nodes.new("GeometryNodeInputNamedAttribute")
|
||||
node.data_type = "FLOAT_VECTOR"
|
||||
node.inputs["Name"].default_value = name
|
||||
return node.outputs["Attribute"]
|
||||
|
||||
|
||||
def store_float_attribute(group, geometry, name, value, domain="POINT"):
|
||||
node = group.nodes.new("GeometryNodeStoreNamedAttribute")
|
||||
node.domain, node.data_type = domain, "FLOAT"
|
||||
node.inputs["Name"].default_value = name
|
||||
group.links.new(geometry, node.inputs["Geometry"])
|
||||
if isinstance(value, (int, float)):
|
||||
node.inputs["Value"].default_value = value
|
||||
else:
|
||||
group.links.new(value, node.inputs["Value"])
|
||||
return node.outputs["Geometry"]
|
||||
|
||||
|
||||
def store_vector_attribute(group, geometry, name, value, domain="POINT"):
|
||||
node = group.nodes.new("GeometryNodeStoreNamedAttribute")
|
||||
node.domain, node.data_type = domain, "FLOAT_VECTOR"
|
||||
node.inputs["Name"].default_value = name
|
||||
group.links.new(geometry, node.inputs["Geometry"])
|
||||
group.links.new(value, node.inputs["Value"])
|
||||
return node.outputs["Geometry"]
|
||||
|
||||
|
||||
def evaluate_field(group, value, data_type, domain):
|
||||
"""Evaluate a field on an explicit domain before downstream adaptation."""
|
||||
node = group.nodes.new("GeometryNodeFieldOnDomain")
|
||||
node.data_type, node.domain = data_type, domain
|
||||
group.links.new(value, node.inputs["Value"])
|
||||
return node.outputs["Value"]
|
||||
|
||||
|
||||
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"])
|
||||
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_attribute_pattern(group, geometry, pattern):
|
||||
"""Remove every attribute matching an internal wildcard pattern.
|
||||
|
||||
Always clean internal attributes with a pattern: naming an attribute that a
|
||||
branch never stored makes the Remove Named Attribute node warn.
|
||||
"""
|
||||
node = group.nodes.new("GeometryNodeRemoveAttribute")
|
||||
node.inputs["Name"].default_value = pattern
|
||||
node.pattern_mode = "WILDCARD"
|
||||
group.links.new(geometry, node.inputs["Geometry"])
|
||||
return node.outputs["Geometry"]
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Share persistent face attributes between depth symmetry and image materials.
|
||||
|
||||
Rotation stores pre-inversion Euler angles, face kind is 1/2/3 for front/back/wall,
|
||||
and axis is 1 for the final +X orientation. Missing attributes mean identity.
|
||||
These FACE attributes stay on the output mesh for the material consumer.
|
||||
"""
|
||||
from mathutils import Matrix
|
||||
|
||||
from anyimage.common.object import blender_frame_rotation
|
||||
|
||||
ROTATION_ATTRIBUTE = 'o_depth_rotation'
|
||||
FACE_ATTRIBUTE = 'o_depth_face'
|
||||
AXIS_ATTRIBUTE = 'o_depth_axis'
|
||||
|
||||
|
||||
def transform_object_normal_color(group, color, strength):
|
||||
"""Rotate encoded object normals, reflect backs and disable wall normal maps."""
|
||||
nodes, links = group.nodes, group.links
|
||||
|
||||
def attribute(name):
|
||||
node = nodes.new('ShaderNodeAttribute')
|
||||
node.attribute_type = 'GEOMETRY'
|
||||
node.attribute_name = name
|
||||
return node
|
||||
|
||||
def math(operation, value, constant):
|
||||
node = nodes.new('ShaderNodeMath')
|
||||
node.operation = operation
|
||||
links.new(value, node.inputs[0])
|
||||
node.inputs[1].default_value = constant
|
||||
return node.outputs[0]
|
||||
|
||||
rotation = attribute(ROTATION_ATTRIBUTE)
|
||||
face = attribute(FACE_ATTRIBUTE)
|
||||
axis = attribute(AXIS_ATTRIBUTE)
|
||||
decode = nodes.new('ShaderNodeVectorMath')
|
||||
decode.operation = 'MULTIPLY_ADD'
|
||||
decode.inputs[1].default_value = (2., 2., 2.)
|
||||
decode.inputs[2].default_value = (-1., -1., -1.)
|
||||
links.new(color, decode.inputs[0])
|
||||
base = nodes.new('ShaderNodeVectorRotate')
|
||||
base.rotation_type = 'EULER_XYZ'
|
||||
base.inputs['Rotation'].default_value = Matrix(blender_frame_rotation()).to_euler()
|
||||
links.new(decode.outputs[0], base.inputs['Vector'])
|
||||
rotate = nodes.new('ShaderNodeVectorRotate')
|
||||
rotate.rotation_type = 'EULER_XYZ'
|
||||
rotate.invert = True
|
||||
links.new(base.outputs[0], rotate.inputs['Vector'])
|
||||
links.new(rotation.outputs['Vector'], rotate.inputs['Rotation'])
|
||||
separate = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(rotate.outputs[0], separate.inputs[0])
|
||||
back = math('COMPARE', face.outputs['Fac'], 2.)
|
||||
# Reflect the normal's Y component on the back.
|
||||
difference = math('MULTIPLY', separate.outputs['Y'], -2.)
|
||||
correction = nodes.new('ShaderNodeMath')
|
||||
correction.operation = 'MULTIPLY_ADD'
|
||||
links.new(back, correction.inputs[0])
|
||||
links.new(difference, correction.inputs[1])
|
||||
links.new(separate.outputs['Y'], correction.inputs[2])
|
||||
combine = nodes.new('ShaderNodeCombineXYZ')
|
||||
links.new(separate.outputs['X'], combine.inputs['X'])
|
||||
links.new(correction.outputs[0], combine.inputs['Y'])
|
||||
links.new(separate.outputs['Z'], combine.inputs['Z'])
|
||||
axis_rotation = nodes.new('ShaderNodeVectorMath')
|
||||
axis_rotation.operation = 'SCALE'
|
||||
axis_rotation.inputs[0].default_value = Matrix.Rotation(1.5707963267948966, 4, 'Z').to_euler()
|
||||
links.new(axis.outputs['Fac'], axis_rotation.inputs['Scale'])
|
||||
orient = nodes.new('ShaderNodeVectorRotate')
|
||||
orient.rotation_type = 'EULER_XYZ'
|
||||
links.new(combine.outputs[0], orient.inputs['Vector'])
|
||||
links.new(axis_rotation.outputs[0], orient.inputs['Rotation'])
|
||||
encode = nodes.new('ShaderNodeVectorMath')
|
||||
encode.operation = 'MULTIPLY_ADD'
|
||||
encode.inputs[1].default_value = (.5, .5, .5)
|
||||
encode.inputs[2].default_value = (.5, .5, .5)
|
||||
links.new(orient.outputs[0], encode.inputs[0])
|
||||
mapped = math('LESS_THAN', face.outputs['Fac'], 2.5)
|
||||
weight = nodes.new('ShaderNodeMath')
|
||||
weight.operation = 'MULTIPLY'
|
||||
links.new(mapped, weight.inputs[0])
|
||||
links.new(strength, weight.inputs[1])
|
||||
return encode.outputs[0], weight.outputs[0]
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Build the direction-adjusted reference depth field."""
|
||||
from .nodes import math_node
|
||||
from .direction import legacy_direction_to_canonical
|
||||
|
||||
|
||||
def reference_plane_depth(group, direction, reference_depth):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
normal = nodes.new("ShaderNodeVectorMath")
|
||||
normal.operation = "NORMALIZE"
|
||||
links.new(direction, normal.inputs[0])
|
||||
remapped = legacy_direction_to_canonical(group, normal.outputs["Vector"])
|
||||
normal_components = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(remapped, normal_components.inputs["Vector"])
|
||||
position = nodes.new("GeometryNodeInputPosition")
|
||||
position_components = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(position.outputs["Position"], position_components.inputs["Vector"])
|
||||
x_term = math_node(nodes, "MULTIPLY")
|
||||
links.new(position_components.outputs["X"], x_term.inputs[0])
|
||||
links.new(normal_components.outputs["X"], x_term.inputs[1])
|
||||
z_term = math_node(nodes, "MULTIPLY")
|
||||
links.new(position_components.outputs["Z"], z_term.inputs[0])
|
||||
links.new(normal_components.outputs["Z"], z_term.inputs[1])
|
||||
point_dot = math_node(nodes, "ADD")
|
||||
links.new(x_term.outputs[0], point_dot.inputs[0])
|
||||
links.new(z_term.outputs[0], point_dot.inputs[1])
|
||||
divide = math_node(nodes, "DIVIDE")
|
||||
links.new(point_dot.outputs[0], divide.inputs[0])
|
||||
links.new(normal_components.outputs["Y"], divide.inputs[1])
|
||||
plane = math_node(nodes, "ADD")
|
||||
links.new(reference_depth, plane.inputs[0])
|
||||
links.new(divide.outputs[0], plane.inputs[1])
|
||||
return plane.outputs[0]
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Build shared material inputs, roughness, and bump outputs."""
|
||||
BUMP_BASE_DISTANCE = 0.001
|
||||
|
||||
|
||||
def build_material_nodes(group):
|
||||
nodes = group.nodes
|
||||
group_input = nodes.new("NodeGroupInput")
|
||||
group_output = nodes.new("NodeGroupOutput")
|
||||
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()
|
||||
bump = nodes.new("ShaderNodeBump")
|
||||
return group_input, group_output, roughness_curve, bump
|
||||
|
||||
|
||||
def connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha):
|
||||
nodes, links = group.nodes, group.links
|
||||
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(group_input.outputs["Color"], bump.inputs["Height"])
|
||||
bump_distance = nodes.new("ShaderNodeMath")
|
||||
bump_distance.operation = "MULTIPLY"
|
||||
bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE
|
||||
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"])
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Build shared boundary fields and pinned position smoothing."""
|
||||
from .nodes import evaluate_field, sample_field
|
||||
|
||||
|
||||
def edge_boundary_field(nodes, links):
|
||||
"""Report the points sitting on an open or loose boundary."""
|
||||
vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors")
|
||||
loose_vertex = nodes.new("FunctionNodeCompare")
|
||||
loose_vertex.data_type = "INT"
|
||||
loose_vertex.operation = "EQUAL"
|
||||
loose_vertex_a = next(
|
||||
socket for socket in loose_vertex.inputs if socket.identifier == "A_INT"
|
||||
)
|
||||
loose_vertex_b = next(
|
||||
socket for socket in loose_vertex.inputs if socket.identifier == "B_INT"
|
||||
)
|
||||
loose_vertex_b.default_value = 1
|
||||
links.new(vertex_neighbors.outputs["Vertex Count"], loose_vertex_a)
|
||||
|
||||
edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
boundary_edge = nodes.new("FunctionNodeCompare")
|
||||
boundary_edge.data_type = "INT"
|
||||
boundary_edge.operation = "EQUAL"
|
||||
boundary_edge_a = next(
|
||||
socket for socket in boundary_edge.inputs if socket.identifier == "A_INT"
|
||||
)
|
||||
boundary_edge_b = next(
|
||||
socket for socket in boundary_edge.inputs if socket.identifier == "B_INT"
|
||||
)
|
||||
boundary_edge_b.default_value = 1
|
||||
links.new(edge_neighbors.outputs["Face Count"], boundary_edge_a)
|
||||
edge_domain = nodes.new("GeometryNodeFieldOnDomain")
|
||||
edge_domain.data_type = "BOOLEAN"
|
||||
edge_domain.domain = "EDGE"
|
||||
links.new(boundary_edge.outputs["Result"], edge_domain.inputs["Value"])
|
||||
|
||||
is_boundary = nodes.new("FunctionNodeBooleanMath")
|
||||
is_boundary.operation = "OR"
|
||||
links.new(edge_domain.outputs["Value"], is_boundary.inputs[0])
|
||||
links.new(loose_vertex.outputs["Result"], is_boundary.inputs[1])
|
||||
return is_boundary.outputs["Boolean"]
|
||||
|
||||
|
||||
def _multiply(group, a, b):
|
||||
node = group.nodes.new("ShaderNodeMath")
|
||||
node.operation = "MULTIPLY"
|
||||
for index, value in enumerate((a, b)):
|
||||
if isinstance(value, (int, float)):
|
||||
node.inputs[index].default_value = value
|
||||
else:
|
||||
group.links.new(value, node.inputs[index])
|
||||
return node.outputs[0]
|
||||
|
||||
|
||||
def _boundary_target(group, geometry, current, boundary_points):
|
||||
"""Blur the boundary strip alone and sample it back onto every point."""
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
boundary_geometry = nodes.new("GeometryNodeSeparateGeometry")
|
||||
boundary_geometry.domain = "POINT"
|
||||
links.new(current, boundary_geometry.inputs["Geometry"])
|
||||
links.new(boundary_points, boundary_geometry.inputs["Selection"])
|
||||
boundary_position = nodes.new("GeometryNodeInputPosition")
|
||||
smooth_boundary = nodes.new("GeometryNodeBlurAttribute")
|
||||
smooth_boundary.data_type = "FLOAT_VECTOR"
|
||||
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"
|
||||
# Both boundary meshes keep the same compact index order: only positions change,
|
||||
# so the lookup can stay anchored on the untouched input geometry.
|
||||
source_boundary = nodes.new("GeometryNodeSeparateGeometry")
|
||||
source_boundary.domain = "POINT"
|
||||
links.new(geometry, source_boundary.inputs["Geometry"])
|
||||
links.new(boundary_points, source_boundary.inputs["Selection"])
|
||||
links.new(source_boundary.outputs["Selection"], nearest_boundary.inputs["Geometry"])
|
||||
fixed_position = sample_field(
|
||||
group, geometry, boundary_position.outputs["Position"], "FLOAT_VECTOR"
|
||||
)
|
||||
links.new(fixed_position, nearest_boundary.inputs["Sample Position"])
|
||||
|
||||
sample_boundary = nodes.new("GeometryNodeSampleIndex")
|
||||
sample_boundary.data_type = "FLOAT_VECTOR"
|
||||
sample_boundary.domain = "POINT"
|
||||
sample_boundary.clamp = False
|
||||
links.new(boundary_geometry.outputs["Selection"], sample_boundary.inputs["Geometry"])
|
||||
links.new(smooth_boundary.outputs["Value"], sample_boundary.inputs["Value"])
|
||||
links.new(nearest_boundary.outputs["Index"], sample_boundary.inputs["Index"])
|
||||
return sample_boundary.outputs["Value"]
|
||||
|
||||
|
||||
def pinned_smooth(
|
||||
group,
|
||||
geometry,
|
||||
iterations,
|
||||
region,
|
||||
weight=1.0,
|
||||
pin_boundary=True,
|
||||
pin_sharp=True,
|
||||
):
|
||||
"""Smooth positions toward a pinned target inside an influence region.
|
||||
|
||||
``weight`` is the smoothing weight: each point moves that share of the way
|
||||
to the target every iteration, scaled further by the sharpness of the
|
||||
normal field and the influence region.
|
||||
``pin_boundary`` sends boundary points to a boundary-only blur so an outline
|
||||
is never dragged inward. ``pin_sharp`` lowers the weight of creases through
|
||||
the sharpness of the normal field. Junction bands keep the sharpness weight
|
||||
without the boundary rule, where the influenced points are not a boundary.
|
||||
"""
|
||||
nodes, links = group.nodes, group.links
|
||||
repeat_output = nodes.new("GeometryNodeRepeatOutput")
|
||||
repeat_input = nodes.new("GeometryNodeRepeatInput")
|
||||
repeat_input.pair_with_output(repeat_output)
|
||||
links.new(iterations, repeat_input.inputs["Iterations"])
|
||||
links.new(geometry, repeat_input.inputs["Geometry"])
|
||||
current = repeat_input.outputs["Geometry"]
|
||||
|
||||
position = nodes.new("GeometryNodeInputPosition")
|
||||
smooth_all = nodes.new("GeometryNodeBlurAttribute")
|
||||
smooth_all.data_type = "FLOAT_VECTOR"
|
||||
smooth_all.inputs["Iterations"].default_value = 1
|
||||
smooth_all.inputs["Weight"].default_value = 1.0
|
||||
links.new(position.outputs["Position"], smooth_all.inputs["Value"])
|
||||
target = smooth_all.outputs["Value"]
|
||||
if pin_boundary:
|
||||
boundary_points = evaluate_field(
|
||||
group, edge_boundary_field(nodes, links), "BOOLEAN", "POINT"
|
||||
)
|
||||
choose = nodes.new("GeometryNodeSwitch")
|
||||
choose.input_type = "VECTOR"
|
||||
links.new(boundary_points, choose.inputs["Switch"])
|
||||
links.new(smooth_all.outputs["Value"], choose.inputs["False"])
|
||||
links.new(
|
||||
_boundary_target(group, geometry, current, boundary_points),
|
||||
choose.inputs["True"],
|
||||
)
|
||||
target = choose.outputs["Output"]
|
||||
|
||||
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"])
|
||||
coherence = nodes.new("ShaderNodeVectorMath")
|
||||
coherence.operation = "LENGTH"
|
||||
links.new(smooth_normal.outputs["Value"], coherence.inputs[0])
|
||||
weight = _multiply(group, weight, coherence.outputs["Value"])
|
||||
weight = _multiply(group, weight, region)
|
||||
|
||||
difference = nodes.new("ShaderNodeVectorMath")
|
||||
difference.operation = "SUBTRACT"
|
||||
links.new(target, difference.inputs[0])
|
||||
links.new(position.outputs["Position"], difference.inputs[1])
|
||||
offset = nodes.new("ShaderNodeVectorMath")
|
||||
offset.operation = "SCALE"
|
||||
links.new(difference.outputs[0], offset.inputs[0])
|
||||
links.new(weight, offset.inputs[3])
|
||||
set_position = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(current, set_position.inputs["Geometry"])
|
||||
links.new(offset.outputs[0], set_position.inputs["Offset"])
|
||||
links.new(set_position.outputs["Geometry"], repeat_output.inputs["Geometry"])
|
||||
|
||||
enabled = nodes.new("GeometryNodeSwitch")
|
||||
enabled.input_type = "GEOMETRY"
|
||||
links.new(iterations, enabled.inputs["Switch"])
|
||||
links.new(geometry, enabled.inputs["False"])
|
||||
links.new(repeat_output.outputs["Geometry"], enabled.inputs["True"])
|
||||
return enabled.outputs["Output"]
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Delete the faces the depth mask excludes."""
|
||||
|
||||
|
||||
def delete_invalid_faces(group, geometry, invalid):
|
||||
nodes, links = group.nodes, group.links
|
||||
delete = nodes.new("GeometryNodeDeleteGeometry")
|
||||
delete.domain, delete.mode = "FACE", "ALL"
|
||||
links.new(geometry, delete.inputs["Geometry"])
|
||||
links.new(invalid, delete.inputs["Selection"])
|
||||
return delete.outputs["Geometry"]
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
"""Build O Image Cutout."""
|
||||
import bpy
|
||||
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
from ..common.nodes import (
|
||||
interface_socket, group_input, compare_node, math_node, float_input,
|
||||
menu_switch, read_float_attribute, store_float_attribute, prepare_node_group,
|
||||
)
|
||||
from ..common.cutout import _extrude_layers, _offset_geometry_y, _set_layer_y, _shade_output
|
||||
|
||||
|
||||
NODE_GROUP_NAME = "O Image Cutout"
|
||||
|
||||
|
||||
def _set_segmented_layer_y(
|
||||
group,
|
||||
geometry,
|
||||
front_z,
|
||||
back_z,
|
||||
thickness,
|
||||
):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
|
||||
edge_vertices = nodes.new("GeometryNodeInputMeshEdgeVertices")
|
||||
edge_length = nodes.new("ShaderNodeVectorMath")
|
||||
edge_length.operation = "DISTANCE"
|
||||
links.new(edge_vertices.outputs["Position 1"], edge_length.inputs[0])
|
||||
links.new(edge_vertices.outputs["Position 2"], edge_length.inputs[1])
|
||||
edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
boundary = compare_node(group, "EQUAL", edge_neighbors.outputs["Face Count"], 1, data_type="INT")
|
||||
statistics = nodes.new("GeometryNodeAttributeStatistic")
|
||||
statistics.data_type = "FLOAT"
|
||||
statistics.domain = "EDGE"
|
||||
links.new(geometry, statistics.inputs["Geometry"])
|
||||
links.new(boundary, statistics.inputs["Selection"])
|
||||
links.new(edge_length.outputs["Value"], statistics.inputs["Attribute"])
|
||||
safe_edge = math_node(nodes, "MAXIMUM")
|
||||
safe_edge.inputs[1].default_value = 1e-6
|
||||
links.new(statistics.outputs["Median"], safe_edge.inputs[0])
|
||||
segment_ratio = math_node(nodes, "DIVIDE")
|
||||
links.new(thickness, segment_ratio.inputs[0])
|
||||
links.new(safe_edge.outputs[0], segment_ratio.inputs[1])
|
||||
segment_count = nodes.new("FunctionNodeFloatToInt")
|
||||
segment_count.rounding_mode = "CEILING"
|
||||
links.new(segment_ratio.outputs[0], segment_count.inputs["Float"])
|
||||
at_least_one = nodes.new("FunctionNodeIntegerMath")
|
||||
at_least_one.operation = "MAXIMUM"
|
||||
at_least_one.inputs[1].default_value = 1
|
||||
links.new(segment_count.outputs["Integer"], at_least_one.inputs[0])
|
||||
segment_limit = nodes.new("FunctionNodeIntegerMath")
|
||||
segment_limit.operation = "MINIMUM"
|
||||
segment_limit.inputs[1].default_value = 128
|
||||
links.new(at_least_one.outputs[0], segment_limit.inputs[0])
|
||||
step = math_node(nodes, "DIVIDE")
|
||||
links.new(thickness, step.inputs[0])
|
||||
links.new(segment_limit.outputs[0], step.inputs[1])
|
||||
|
||||
front = _offset_geometry_y(group, geometry, front_z)
|
||||
repeat_output = nodes.new("GeometryNodeRepeatOutput")
|
||||
repeat_input = nodes.new("GeometryNodeRepeatInput")
|
||||
repeat_input.pair_with_output(repeat_output)
|
||||
repeat_output.repeat_items.new("GEOMETRY", "Geometry")
|
||||
repeat_output.repeat_items.new("GEOMETRY", "Sides")
|
||||
links.new(segment_limit.outputs[0], repeat_input.inputs["Iterations"])
|
||||
links.new(geometry, repeat_input.inputs["Geometry"])
|
||||
negative_step = math_node(nodes, "MULTIPLY")
|
||||
negative_step.inputs[1].default_value = -1.0
|
||||
links.new(step.outputs[0], negative_step.inputs[0])
|
||||
extrude = nodes.new("GeometryNodeExtrudeMesh")
|
||||
extrude.mode = "FACES"
|
||||
extrude.inputs["Individual"].default_value = False
|
||||
extrude.inputs["Selection"].default_value = True
|
||||
extrude.inputs["Offset"].default_value = (0.0, 0.0, 0.0)
|
||||
links.new(negative_step.outputs[0], extrude.inputs["Offset Scale"])
|
||||
links.new(repeat_input.outputs["Geometry"], extrude.inputs["Mesh"])
|
||||
split_extrude = nodes.new("GeometryNodeSeparateGeometry")
|
||||
split_extrude.domain = "FACE"
|
||||
links.new(extrude.outputs["Mesh"], split_extrude.inputs["Geometry"])
|
||||
links.new(extrude.outputs["Side"], split_extrude.inputs["Selection"])
|
||||
join_sides = nodes.new("GeometryNodeJoinGeometry")
|
||||
links.new(repeat_input.outputs["Sides"], join_sides.inputs["Geometry"])
|
||||
links.new(split_extrude.outputs["Selection"], join_sides.inputs["Geometry"])
|
||||
links.new(split_extrude.outputs["Inverted"], repeat_output.inputs["Geometry"])
|
||||
links.new(join_sides.outputs["Geometry"], repeat_output.inputs["Sides"])
|
||||
|
||||
positioned_side = _offset_geometry_y(
|
||||
group,
|
||||
repeat_output.outputs["Sides"],
|
||||
front_z,
|
||||
)
|
||||
flip_side = nodes.new("GeometryNodeFlipFaces")
|
||||
links.new(positioned_side, flip_side.inputs["Mesh"])
|
||||
|
||||
back = _offset_geometry_y(group, geometry, back_z)
|
||||
flip_back = nodes.new("GeometryNodeFlipFaces")
|
||||
links.new(back, flip_back.inputs["Mesh"])
|
||||
join = nodes.new("GeometryNodeJoinGeometry")
|
||||
links.new(front, join.inputs["Geometry"])
|
||||
links.new(flip_back.outputs["Mesh"], join.inputs["Geometry"])
|
||||
links.new(flip_side.outputs["Mesh"], join.inputs["Geometry"])
|
||||
merge = nodes.new("GeometryNodeMergeByDistance")
|
||||
merge.mode = "ALL"
|
||||
merge.inputs["Distance"].default_value = 1e-6
|
||||
links.new(join.outputs["Geometry"], merge.inputs["Geometry"])
|
||||
return merge.outputs["Geometry"]
|
||||
|
||||
|
||||
def _build_shell(group, geometry, thickness):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
zero = compare_node(group, "LESS_THAN", thickness, 1e-6)
|
||||
choose = nodes.new("GeometryNodeSwitch")
|
||||
choose.input_type = "GEOMETRY"
|
||||
links.new(zero, choose.inputs["Switch"])
|
||||
links.new(geometry, choose.inputs["True"])
|
||||
front = math_node(nodes, "MULTIPLY")
|
||||
front.inputs[1].default_value = 0.0
|
||||
links.new(thickness, front.inputs[0])
|
||||
back = math_node(nodes, "MULTIPLY")
|
||||
back.inputs[1].default_value = 1.0
|
||||
links.new(thickness, back.inputs[0])
|
||||
shaped = _set_segmented_layer_y(
|
||||
group,
|
||||
geometry,
|
||||
front.outputs[0],
|
||||
back.outputs[0],
|
||||
thickness,
|
||||
)
|
||||
links.new(shaped, choose.inputs["False"])
|
||||
return choose.outputs[0]
|
||||
|
||||
|
||||
def _build_balloon(group, geometry, thickness):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
zero = compare_node(group, "LESS_THAN", thickness, 1e-6)
|
||||
choose = nodes.new("GeometryNodeSwitch")
|
||||
choose.input_type = "GEOMETRY"
|
||||
links.new(zero, choose.inputs["Switch"])
|
||||
links.new(geometry, choose.inputs["True"])
|
||||
profile = read_float_attribute(group, 'o_balloon')
|
||||
front_scale = math_node(nodes, "MULTIPLY")
|
||||
links.new(profile, front_scale.inputs[0])
|
||||
links.new(thickness, front_scale.inputs[1])
|
||||
negative_front = math_node(nodes, "MULTIPLY")
|
||||
negative_front.inputs[1].default_value = -1.0
|
||||
links.new(front_scale.outputs[0], negative_front.inputs[0])
|
||||
extrude = _extrude_layers(group, geometry)
|
||||
shaped = _set_layer_y(
|
||||
group,
|
||||
geometry,
|
||||
extrude,
|
||||
negative_front.outputs[0],
|
||||
front_scale.outputs[0],
|
||||
)
|
||||
links.new(shaped, choose.inputs["False"])
|
||||
return choose.outputs[0]
|
||||
|
||||
|
||||
def build_image_cutout_group():
|
||||
name = NODE_GROUP_NAME
|
||||
existing = bpy.data.node_groups.get(name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
group = bpy.data.node_groups.new(name, 'GeometryNodeTree')
|
||||
group.is_modifier, group.use_fake_user = (True, True)
|
||||
group.color_tag = 'GEOMETRY'
|
||||
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
|
||||
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
|
||||
float_input(group, 'Thickness', 1.0, maximum=2.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.'
|
||||
float_input(group, 'Shell Thickness', 0.0, maximum=1.0, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.'
|
||||
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
|
||||
controls = group_input(group.nodes, set())
|
||||
balloon = _build_balloon(group, controls.outputs['Geometry'], controls.outputs['Thickness'])
|
||||
shell = _build_shell(group, controls.outputs['Geometry'], controls.outputs['Shell Thickness'])
|
||||
choose = menu_switch(group, 'Mode', ('Balloon', 'Shell'), 'GEOMETRY')
|
||||
group.links.new(balloon, choose.inputs['Balloon'])
|
||||
group.links.new(shell, choose.inputs['Shell'])
|
||||
geometry = choose.outputs[0]
|
||||
profile = read_float_attribute(group, 'o_balloon')
|
||||
amount = group_input(group.nodes, {'Thickness'})
|
||||
enabled = compare_node(group, 'GREATER_THAN', amount.outputs['Thickness'], 1e-6)
|
||||
inflated = group.nodes.new('GeometryNodeSwitch')
|
||||
inflated.input_type = 'FLOAT'
|
||||
inflated.inputs['False'].default_value = 1
|
||||
group.links.new(enabled, inflated.inputs['Switch'])
|
||||
group.links.new(profile, inflated.inputs['True'])
|
||||
mode = menu_switch(group, 'Mode', ('Balloon', 'Shell'), 'FLOAT')
|
||||
mode.inputs['Shell'].default_value = 1
|
||||
group.links.new(inflated.outputs[0], mode.inputs['Balloon'])
|
||||
scale = mode.outputs[0]
|
||||
reduction = math_node(group.nodes, 'SUBTRACT')
|
||||
reduction.inputs[0].default_value = 1.0
|
||||
group.links.new(scale, reduction.inputs[1])
|
||||
geometry = store_float_attribute(group, geometry, NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction.outputs[0])
|
||||
_shade_output(group, geometry)
|
||||
prepare_node_group(group)
|
||||
for item in group.interface.items_tree:
|
||||
if item.item_type != 'SOCKET' or item.in_out != 'INPUT':
|
||||
continue
|
||||
if item.name == 'Mode':
|
||||
item.default_value = 'Balloon'
|
||||
elif item.name == 'Shell Thickness':
|
||||
item.name = 'Thickness'
|
||||
return group
|
||||
@@ -1,416 +0,0 @@
|
||||
"""Mirror completed depth geometry and fill matching boundaries."""
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
|
||||
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
|
||||
from ..common.nodes import (
|
||||
interface_socket, group_input, float_input, bool_input, vector_input,
|
||||
compare_node, boolean_node, evaluate_field,
|
||||
store_boolean_attribute, store_float_attribute, read_float_attribute,
|
||||
remove_attribute_pattern, prepare_node_group,
|
||||
)
|
||||
from ..common.depth_surface import _math
|
||||
from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
|
||||
from ..common.direction import symmetry_legacy_direction_to_canonical
|
||||
from ..common.smoothing import edge_boundary_field, pinned_smooth
|
||||
|
||||
NODE_GROUP_NAME = "O Image Cutout Symmetry"
|
||||
|
||||
# 切口环与补面衔接带各自向外覆盖的圈数。
|
||||
SMOOTH_RINGS = 4
|
||||
|
||||
# 补面标记:挤出之后按面记下补面,作为补面平滑的范围。
|
||||
WELD_ATTRIBUTE = '_o_symmetry_weld'
|
||||
|
||||
|
||||
# 对称面附近按中位边长的倍数降低法线贴图强度。
|
||||
SEAM_NORMAL_BAND = 4
|
||||
|
||||
|
||||
def _align_front(group, geometry, controls):
|
||||
"""Align existing geometry with the fixed XY symmetry plane."""
|
||||
nodes, links = group.nodes, group.links
|
||||
direction = symmetry_legacy_direction_to_canonical(group, controls['Direction'])
|
||||
align = nodes.new('FunctionNodeAlignEulerToVector')
|
||||
align.axis = 'Y'
|
||||
links.new(direction, align.inputs['Vector'])
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
rotate = nodes.new('ShaderNodeVectorRotate')
|
||||
rotate.rotation_type = 'EULER_XYZ'
|
||||
rotate.invert = True
|
||||
links.new(position.outputs[0], rotate.inputs['Vector'])
|
||||
links.new(align.outputs['Rotation'], rotate.inputs['Rotation'])
|
||||
offset = nodes.new('ShaderNodeCombineXYZ')
|
||||
offset_value = nodes.new('ShaderNodeMath')
|
||||
offset_value.operation = 'MULTIPLY'
|
||||
offset_value.inputs[1].default_value = -1.0
|
||||
links.new(controls['Offset'], offset_value.inputs[0])
|
||||
links.new(offset_value.outputs[0], offset.inputs['Y'])
|
||||
offset_position = nodes.new('ShaderNodeVectorMath')
|
||||
offset_position.operation = 'ADD'
|
||||
links.new(rotate.outputs[0], offset_position.inputs[0])
|
||||
links.new(offset.outputs[0], offset_position.inputs[1])
|
||||
scale_vector = nodes.new('ShaderNodeCombineXYZ')
|
||||
scale_vector.inputs['X'].default_value = 1
|
||||
scale_vector.inputs['Z'].default_value = 1
|
||||
links.new(controls['Scale'], scale_vector.inputs['Y'])
|
||||
scale = nodes.new('ShaderNodeVectorMath')
|
||||
scale.operation = 'MULTIPLY'
|
||||
links.new(offset_position.outputs[0], scale.inputs[0])
|
||||
links.new(scale_vector.outputs[0], scale.inputs[1])
|
||||
place = nodes.new('GeometryNodeSetPosition')
|
||||
links.new(geometry, place.inputs['Geometry'])
|
||||
links.new(scale.outputs[0], place.inputs['Position'])
|
||||
store = nodes.new('GeometryNodeStoreNamedAttribute')
|
||||
store.data_type, store.domain = 'FLOAT_VECTOR', 'FACE'
|
||||
store.inputs['Name'].default_value = ROTATION_ATTRIBUTE
|
||||
links.new(place.outputs[0], store.inputs['Geometry'])
|
||||
links.new(align.outputs['Rotation'], store.inputs['Value'])
|
||||
return store.outputs['Geometry']
|
||||
|
||||
|
||||
def _boundary_edge_field(group):
|
||||
"""Report one on edges that carry a single face."""
|
||||
neighbors = group.nodes.new('GeometryNodeInputMeshEdgeNeighbors')
|
||||
return compare_node(group, 'EQUAL', neighbors.outputs['Face Count'], 1, data_type='INT')
|
||||
|
||||
|
||||
def _beyond_plane_field(group, distance):
|
||||
"""Report the points sitting farther than the weld distance past the plane."""
|
||||
nodes = group.nodes
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
components = nodes.new('ShaderNodeSeparateXYZ')
|
||||
group.links.new(position.outputs[0], components.inputs[0])
|
||||
return compare_node(group, 'GREATER_THAN', components.outputs['Y'], distance)
|
||||
|
||||
|
||||
def _on_plane_field(group, distance):
|
||||
"""Report the points sitting within the weld distance of the plane."""
|
||||
nodes = group.nodes
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
components = nodes.new('ShaderNodeSeparateXYZ')
|
||||
group.links.new(position.outputs[0], components.inputs[0])
|
||||
return compare_node(group, 'LESS_THAN', _math(group, 'ABSOLUTE', components.outputs['Y']), distance)
|
||||
|
||||
|
||||
def _merge_points(group, geometry, selection, distance):
|
||||
"""Weld the selected points that share a position."""
|
||||
nodes, links = group.nodes, group.links
|
||||
merge = nodes.new('GeometryNodeMergeByDistance')
|
||||
links.new(geometry, merge.inputs['Geometry'])
|
||||
links.new(selection, merge.inputs['Selection'])
|
||||
if isinstance(distance, (int, float)):
|
||||
merge.inputs['Distance'].default_value = distance
|
||||
else:
|
||||
links.new(distance, merge.inputs['Distance'])
|
||||
return merge.outputs[0]
|
||||
|
||||
|
||||
def _delete_faces(group, geometry, selection):
|
||||
"""Delete the selected faces and the geometry only they used."""
|
||||
nodes, links = group.nodes, group.links
|
||||
delete = nodes.new('GeometryNodeDeleteGeometry')
|
||||
delete.domain, delete.mode = 'FACE', 'ALL'
|
||||
links.new(geometry, delete.inputs['Geometry'])
|
||||
links.new(selection, delete.inputs['Selection'])
|
||||
return delete.outputs[0]
|
||||
|
||||
|
||||
def _delete_beyond_plane(group, geometry, beyond):
|
||||
"""Delete the faces whose points all sit beyond the symmetry plane."""
|
||||
# 点域求值让面域选择按「全部点越界」删除,跨界的面连几何一起留给切口回拉。
|
||||
return _delete_faces(group, geometry, evaluate_field(group, beyond, 'BOOLEAN', 'POINT'))
|
||||
|
||||
|
||||
def _corner_edge(group, corner, offset, value):
|
||||
"""Read an edge value at one corner around a face."""
|
||||
nodes, links = group.nodes, group.links
|
||||
shifted = nodes.new('GeometryNodeOffsetCornerInFace')
|
||||
links.new(corner, shifted.inputs['Corner Index'])
|
||||
shifted.inputs['Offset'].default_value = offset
|
||||
edges = nodes.new('GeometryNodeEdgesOfCorner')
|
||||
links.new(shifted.outputs['Corner Index'], edges.inputs['Corner Index'])
|
||||
sample = nodes.new('GeometryNodeFieldAtIndex')
|
||||
sample.data_type, sample.domain = 'BOOLEAN', 'EDGE'
|
||||
links.new(value, sample.inputs['Value'])
|
||||
links.new(edges.outputs['Next Edge Index'], sample.inputs['Index'])
|
||||
return sample.outputs['Value']
|
||||
|
||||
|
||||
def _boundary_edge_count(group):
|
||||
"""Count the edges carrying a single face around each face."""
|
||||
nodes, links = group.nodes, group.links
|
||||
boundary = _boundary_edge_field(group)
|
||||
index = nodes.new('GeometryNodeInputIndex')
|
||||
corners = nodes.new('GeometryNodeCornersOfFace')
|
||||
links.new(index.outputs[0], corners.inputs['Face Index'])
|
||||
# 第四个偏移在三角形上绕回第一条边,按角数把它归零。
|
||||
quad = compare_node(group, 'GREATER_THAN', corners.outputs['Total'], 3, data_type='INT')
|
||||
count = None
|
||||
for offset in range(4):
|
||||
term = _corner_edge(group, corners.outputs['Corner Index'], offset, boundary)
|
||||
if offset == 3:
|
||||
masked = nodes.new('GeometryNodeSwitch')
|
||||
masked.input_type = 'FLOAT'
|
||||
masked.inputs['False'].default_value = 0.0
|
||||
links.new(quad, masked.inputs['Switch'])
|
||||
links.new(term, masked.inputs['True'])
|
||||
term = masked.outputs['Output']
|
||||
count = term if count is None else _math(group, 'ADD', count, term)
|
||||
return count
|
||||
|
||||
|
||||
def _delete_bridge_faces(group, geometry):
|
||||
"""Delete the faces left bridging two openings of a cut."""
|
||||
return _delete_faces(group, geometry, compare_node(group, 'GREATER_THAN', _boundary_edge_count(group), 1.5))
|
||||
|
||||
|
||||
def _delete_flat_faces(group, geometry, distance):
|
||||
"""Delete the faces lying on the symmetry plane; they mirror onto themselves."""
|
||||
nodes, links = group.nodes, group.links
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
components = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(position.outputs[0], components.inputs[0])
|
||||
normal = nodes.new('GeometryNodeInputNormal')
|
||||
normal_axes = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(normal.outputs[0], normal_axes.inputs[0])
|
||||
center = evaluate_field(group, _math(group, 'ABSOLUTE', components.outputs['Y']), 'FLOAT', 'FACE')
|
||||
return _delete_faces(
|
||||
group, geometry,
|
||||
boolean_node(
|
||||
group, 'AND',
|
||||
compare_node(group, 'LESS_THAN', center, distance),
|
||||
compare_node(group, 'GREATER_THAN', _math(group, 'ABSOLUTE', normal_axes.outputs['Y']), 1 - 1e-3),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _delete_crease_faces(group, geometry, distance):
|
||||
"""Delete the faces the fold leaves sharing an interior edge on the plane."""
|
||||
nodes, links = group.nodes, group.links
|
||||
heights = _edge_abs_y(group)
|
||||
plane = nodes.new('FunctionNodeBooleanMath')
|
||||
plane.operation = 'AND'
|
||||
links.new(compare_node(group, 'LESS_THAN', heights[0], distance), plane.inputs[0])
|
||||
links.new(compare_node(group, 'LESS_THAN', heights[1], distance), plane.inputs[1])
|
||||
neighbors = nodes.new('GeometryNodeInputMeshEdgeNeighbors')
|
||||
crease = nodes.new('FunctionNodeBooleanMath')
|
||||
crease.operation = 'AND'
|
||||
links.new(plane.outputs[0], crease.inputs[0])
|
||||
links.new(compare_node(group, 'EQUAL', neighbors.outputs['Face Count'], 2, data_type='INT'), crease.inputs[1])
|
||||
index = nodes.new('GeometryNodeInputIndex')
|
||||
corners = nodes.new('GeometryNodeCornersOfFace')
|
||||
links.new(index.outputs[0], corners.inputs['Face Index'])
|
||||
selected = None
|
||||
for offset in range(4):
|
||||
term = _corner_edge(group, corners.outputs['Corner Index'], offset, crease.outputs[0])
|
||||
selected = term if selected is None else boolean_node(group, 'OR', selected, term)
|
||||
return _delete_faces(group, geometry, selected)
|
||||
|
||||
|
||||
def _off_plane_edge_field(group, distance):
|
||||
"""Report one on edges that keep an endpoint farther than the weld distance."""
|
||||
heights = _edge_abs_y(group)
|
||||
return compare_node(group, 'GREATER_THAN', _math(group, 'MAXIMUM', heights[0], heights[1]), distance)
|
||||
|
||||
|
||||
def _edge_abs_y(group):
|
||||
"""Return both endpoint heights on the symmetry axis for an edge."""
|
||||
edge = group.nodes.new('GeometryNodeInputMeshEdgeVertices')
|
||||
heights = []
|
||||
for socket in ('Position 1', 'Position 2'):
|
||||
axis = group.nodes.new('ShaderNodeSeparateXYZ')
|
||||
group.links.new(edge.outputs[socket], axis.inputs[0])
|
||||
heights.append(_math(group, 'ABSOLUTE', axis.outputs['Y']))
|
||||
return heights
|
||||
|
||||
|
||||
def _mirror(group, geometry):
|
||||
mirror = group.nodes.new('GeometryNodeTransform')
|
||||
mirror.inputs['Scale'].default_value = (1, -1, 1)
|
||||
group.links.new(geometry, mirror.inputs['Geometry'])
|
||||
flip = group.nodes.new('GeometryNodeFlipFaces')
|
||||
group.links.new(mirror.outputs[0], flip.inputs['Mesh'])
|
||||
return flip.outputs[0]
|
||||
|
||||
|
||||
def _seam_field(group, distance):
|
||||
"""Report the cut ring the folded front leaves on the symmetry plane."""
|
||||
boundary = evaluate_field(group, edge_boundary_field(group.nodes, group.links), 'BOOLEAN', 'POINT')
|
||||
return boolean_node(group, 'AND', boundary, _on_plane_field(group, distance))
|
||||
|
||||
|
||||
def _falloff(group, center, rings):
|
||||
"""Blur a 0/1 center into a falloff over the requested rings."""
|
||||
blur = group.nodes.new('GeometryNodeBlurAttribute')
|
||||
blur.data_type = 'FLOAT'
|
||||
blur.inputs['Iterations'].default_value = rings
|
||||
group.links.new(center, blur.inputs['Value'])
|
||||
return _math(group, 'MAXIMUM', center, blur.outputs[0])
|
||||
|
||||
|
||||
def _snap_to_plane(group, geometry, selection):
|
||||
"""Move the selected points onto the symmetry plane."""
|
||||
nodes, links = group.nodes, group.links
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
components = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(position.outputs[0], components.inputs[0])
|
||||
target = nodes.new('ShaderNodeCombineXYZ')
|
||||
links.new(components.outputs['X'], target.inputs['X'])
|
||||
links.new(components.outputs['Z'], target.inputs['Z'])
|
||||
place = nodes.new('GeometryNodeSetPosition')
|
||||
links.new(geometry, place.inputs['Geometry'])
|
||||
links.new(selection, place.inputs['Selection'])
|
||||
links.new(target.outputs[0], place.inputs['Position'])
|
||||
return place.outputs[0]
|
||||
|
||||
|
||||
def _relax_seam(group, geometry, seam, retract, smooth):
|
||||
"""Relax the cut seam in three dimensions and keep the cut on the plane."""
|
||||
relaxed = pinned_smooth(
|
||||
group, geometry, smooth, _falloff(group, seam, SMOOTH_RINGS),
|
||||
pin_boundary=True,
|
||||
)
|
||||
return _snap_to_plane(group, relaxed, boolean_node(group, 'OR', seam, retract))
|
||||
|
||||
|
||||
def _relax_fill(group, geometry, fill, smooth):
|
||||
"""Relax the fill faces on the welded mesh and fade outward from them."""
|
||||
return pinned_smooth(
|
||||
group, geometry, smooth, _falloff(group, fill, SMOOTH_RINGS),
|
||||
weight=0.2, pin_boundary=False,
|
||||
)
|
||||
|
||||
|
||||
def _build_wall(group, front, boundary):
|
||||
"""Close the selected boundary edges onto the symmetry plane and mark the fill."""
|
||||
nodes, links = group.nodes, group.links
|
||||
extrude = nodes.new('GeometryNodeExtrudeMesh')
|
||||
extrude.mode = 'EDGES'
|
||||
extrude.inputs['Offset Scale'].default_value = 0
|
||||
links.new(front, extrude.inputs['Mesh'])
|
||||
links.new(boundary, extrude.inputs['Selection'])
|
||||
geometry = _snap_to_plane(group, extrude.outputs['Mesh'], extrude.outputs['Top'])
|
||||
# 挤出之后再标记补面:范围就是补面本身连同它的边界线,挤出不会改变它。
|
||||
fill = evaluate_field(group, extrude.outputs['Side'], 'BOOLEAN', 'POINT')
|
||||
geometry, fill = store_boolean_attribute(group, geometry, WELD_ATTRIBUTE, fill, domain='FACE')
|
||||
store = nodes.new('GeometryNodeStoreNamedAttribute')
|
||||
store.data_type, store.domain = 'FLOAT', 'FACE'
|
||||
store.inputs['Name'].default_value = FACE_ATTRIBUTE
|
||||
store.inputs['Value'].default_value = 3
|
||||
links.new(geometry, store.inputs['Geometry'])
|
||||
links.new(extrude.outputs['Side'], store.inputs['Selection'])
|
||||
return store.outputs[0], fill
|
||||
|
||||
|
||||
def build_image_cutout_symmetry_group():
|
||||
existing = bpy.data.node_groups.get(NODE_GROUP_NAME)
|
||||
if existing is not None:
|
||||
return existing
|
||||
group = bpy.data.node_groups.new(NODE_GROUP_NAME, 'GeometryNodeTree')
|
||||
group.is_modifier, group.use_fake_user = True, True
|
||||
group.color_tag = 'GEOMETRY'
|
||||
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
|
||||
vector_input(group, 'Direction', (0, 0, 1), subtype='DIRECTION').description = 'Align this direction with the local symmetry axis.'
|
||||
float_input(group, 'Offset', 0, minimum=-10, maximum=10, subtype='DISTANCE').description = 'Offset geometry along the aligned symmetry axis before scaling.'
|
||||
float_input(group, 'Scale', 1, maximum=2).description = 'Scale the offset geometry along the aligned symmetry axis.'
|
||||
options = group.interface.new_panel(name='Options', default_closed=True)
|
||||
bool_input(group, 'Fill Sides', True, parent=options).description = 'Connect matching front and mirrored boundaries.'
|
||||
smooth = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
smooth.default_value, smooth.min_value, smooth.max_value = 4, 0, 16
|
||||
smooth.description = 'Smooth the cut ring and the fill band around the outline and the fill, each over four rings. Zero keeps both raw.'
|
||||
merge = float_input(group, 'Merge Distance', 0.001, minimum=0, subtype='DISTANCE', parent=options)
|
||||
merge.description = 'Weld mirrored geometry within this distance of the symmetry plane; boundaries farther away get filled.'
|
||||
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
|
||||
nodes, links = group.nodes, group.links
|
||||
inputs = group_input(nodes, set()).outputs
|
||||
aligned = _align_front(group, inputs['Geometry'], inputs)
|
||||
edge = nodes.new('GeometryNodeInputMeshEdgeVertices')
|
||||
edge_length = nodes.new('ShaderNodeVectorMath')
|
||||
edge_length.operation = 'DISTANCE'
|
||||
links.new(edge.outputs['Position 1'], edge_length.inputs[0])
|
||||
links.new(edge.outputs['Position 2'], edge_length.inputs[1])
|
||||
statistics = nodes.new('GeometryNodeAttributeStatistic')
|
||||
statistics.data_type, statistics.domain = 'FLOAT', 'EDGE'
|
||||
links.new(aligned, statistics.inputs['Geometry'])
|
||||
links.new(edge_length.outputs['Value'], statistics.inputs['Attribute'])
|
||||
# 合并距离同时决定平面的贴合带和补面边界,两者一致才不会留下细小补面。
|
||||
merge_distance = _math(group, 'MAXIMUM', inputs['Merge Distance'], 1e-6)
|
||||
beyond = _beyond_plane_field(group, merge_distance)
|
||||
on_plane = _on_plane_field(group, merge_distance)
|
||||
retract = boolean_node(group, 'OR', on_plane, beyond)
|
||||
is_boundary = _boundary_edge_field(group)
|
||||
front = _delete_beyond_plane(group, aligned, beyond)
|
||||
front = _delete_bridge_faces(group, front)
|
||||
# 贴合带内和跨界保留的面都折回对称面,切口环因此落在平面上。
|
||||
front = _snap_to_plane(group, front, retract)
|
||||
seam_field = _seam_field(group, merge_distance)
|
||||
shade = nodes.new('GeometryNodeSetShadeSmooth')
|
||||
shade.domain = 'FACE'
|
||||
links.new(front, shade.inputs['Geometry'])
|
||||
front = store_float_attribute(group, shade.outputs[0], FACE_ATTRIBUTE, 1., domain='FACE')
|
||||
front = _relax_seam(group, front, seam_field, retract, inputs['Smooth'])
|
||||
# 松弛会把点压回平面,贴平的面和内部贴平边要在松弛之后再清一次。
|
||||
front = _delete_flat_faces(group, front, merge_distance)
|
||||
front = _delete_crease_faces(group, front, merge_distance)
|
||||
fill_iterations = nodes.new('GeometryNodeSwitch')
|
||||
fill_iterations.input_type = 'INT'
|
||||
fill_iterations.inputs['False'].default_value = 0
|
||||
links.new(inputs['Fill Sides'], fill_iterations.inputs['Switch'])
|
||||
links.new(inputs['Smooth'], fill_iterations.inputs['True'])
|
||||
# 切口环本身贴在对称面上并靠镜像焊住,只有不落在这条环上的边界边才需要补面。
|
||||
wall = nodes.new('FunctionNodeBooleanMath')
|
||||
wall.operation = 'AND'
|
||||
links.new(is_boundary, wall.inputs[0])
|
||||
links.new(_off_plane_edge_field(group, merge_distance), wall.inputs[1])
|
||||
walled, fill = _build_wall(group, front, wall.outputs[0])
|
||||
wall_shade = nodes.new('GeometryNodeSetShadeSmooth')
|
||||
wall_shade.domain = 'FACE'
|
||||
links.new(compare_node(group, 'EQUAL', read_float_attribute(group, FACE_ATTRIBUTE), 3), wall_shade.inputs['Selection'])
|
||||
links.new(walled, wall_shade.inputs['Geometry'])
|
||||
body = nodes.new('GeometryNodeSwitch')
|
||||
body.input_type = 'GEOMETRY'
|
||||
links.new(inputs['Fill Sides'], body.inputs['Switch'])
|
||||
links.new(front, body.inputs['False'])
|
||||
links.new(wall_shade.outputs[0], body.inputs['True'])
|
||||
mark_back = nodes.new('GeometryNodeStoreNamedAttribute')
|
||||
mark_back.data_type, mark_back.domain = 'FLOAT', 'FACE'
|
||||
mark_back.inputs['Name'].default_value = FACE_ATTRIBUTE
|
||||
mark_back.inputs['Value'].default_value = 2
|
||||
links.new(_mirror(group, body.outputs[0]), mark_back.inputs['Geometry'])
|
||||
links.new(compare_node(group, 'EQUAL', read_float_attribute(group, FACE_ATTRIBUTE), 1), mark_back.inputs['Selection'])
|
||||
join = nodes.new('GeometryNodeJoinGeometry')
|
||||
for geometry in (body.outputs[0], mark_back.outputs['Geometry']):
|
||||
links.new(geometry, join.inputs[0])
|
||||
# 平面附近的点已经折到平面上,接缝两侧位置重合;合并只作用在这条带内。
|
||||
merged = _merge_points(group, join.outputs[0], on_plane, merge_distance)
|
||||
# 先让镜像焊住接缝,再松弛补面和它外侧的衔接带。
|
||||
geometry = _relax_fill(group, merged, fill, fill_iterations.outputs[0])
|
||||
geometry = store_float_attribute(group, geometry, AXIS_ATTRIBUTE, 1., domain='FACE')
|
||||
orient = nodes.new('GeometryNodeTransform')
|
||||
orient.inputs['Rotation'].default_value = Matrix.Rotation(1.5707963267948966, 4, 'Z').to_euler()
|
||||
links.new(geometry, orient.inputs['Geometry'])
|
||||
# Fade the material normal map near the symmetry plane so the weld shades smoother.
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
components = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(position.outputs[0], components.inputs[0])
|
||||
ramp = nodes.new('ShaderNodeMapRange')
|
||||
ramp.interpolation_type = 'SMOOTHSTEP'
|
||||
ramp.clamp = True
|
||||
links.new(_math(group, 'ABSOLUTE', components.outputs['X']), ramp.inputs['Value'])
|
||||
ramp.inputs['From Min'].default_value = 0.0
|
||||
links.new(_math(group, 'MULTIPLY', statistics.outputs['Median'], SEAM_NORMAL_BAND), ramp.inputs['From Max'])
|
||||
ramp.inputs['To Min'].default_value = 1.0
|
||||
ramp.inputs['To Max'].default_value = 0.0
|
||||
reduction = _math(
|
||||
group, 'MAXIMUM',
|
||||
read_float_attribute(group, NORMAL_REDUCTION_ATTRIBUTE_NAME), ramp.outputs['Result'],
|
||||
)
|
||||
geometry = store_float_attribute(
|
||||
group, orient.outputs[0], NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction,
|
||||
)
|
||||
geometry = remove_attribute_pattern(group, geometry, '_o_*')
|
||||
links.new(geometry, nodes.new('NodeGroupOutput').inputs['Geometry'])
|
||||
prepare_node_group(group)
|
||||
return group
|
||||
@@ -1,336 +0,0 @@
|
||||
"""Build O Image Depth Cutout."""
|
||||
import bpy
|
||||
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
from ..common.nodes import (
|
||||
interface_socket, group_input, float_input, boolean_node, read_float_attribute, read_vector_attribute,
|
||||
store_float_attribute, store_vector_attribute, remove_attribute_pattern,
|
||||
prepare_node_group, evaluate_field, compare_node,
|
||||
)
|
||||
from ..common.boundary_smoothing import smooth_cut_boundary
|
||||
from ..common.cutout import _position_field, _shade_output
|
||||
from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera
|
||||
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"
|
||||
FRONT_NORMAL_ATTRIBUTE = "_o_front_normal"
|
||||
BALLOON_THICKNESS_BASE = 1.0
|
||||
NORMAL_SMOOTH_ITERATIONS = 256
|
||||
SOLID_WELD_SCALE = 1e-3
|
||||
|
||||
|
||||
def build_normal_weight(group, is_shell, profile):
|
||||
"""Keep shell smoothing and let the balloon profile drive the blend."""
|
||||
weight = group.nodes.new("GeometryNodeSwitch")
|
||||
weight.input_type = "FLOAT"
|
||||
group.links.new(is_shell, weight.inputs["Switch"])
|
||||
group.links.new(profile, weight.inputs["False"])
|
||||
weight.inputs["True"].default_value = 0.0
|
||||
return weight.outputs[0]
|
||||
|
||||
|
||||
def build_profile_weight(group, geometry):
|
||||
"""Normalize the corrected balloon profile into a zero to one blend weight."""
|
||||
profile = read_float_attribute(group, "o_balloon")
|
||||
statistics = group.nodes.new("GeometryNodeAttributeStatistic")
|
||||
statistics.data_type, statistics.domain = "FLOAT", "POINT"
|
||||
group.links.new(geometry, statistics.inputs["Geometry"])
|
||||
group.links.new(profile, statistics.inputs["Attribute"])
|
||||
return _math(
|
||||
group, "DIVIDE", profile,
|
||||
_math(group, "MAXIMUM", statistics.outputs["Max"], 1e-6),
|
||||
)
|
||||
|
||||
|
||||
def build_island_normal(group, normal):
|
||||
"""Average the smoothed normals of one connected mesh island."""
|
||||
nodes, links = group.nodes, group.links
|
||||
island = nodes.new("GeometryNodeInputMeshIsland")
|
||||
accumulate = nodes.new("GeometryNodeAccumulateField")
|
||||
accumulate.data_type, accumulate.domain = "FLOAT_VECTOR", "POINT"
|
||||
links.new(normal, accumulate.inputs["Value"])
|
||||
links.new(island.outputs["Island Index"], accumulate.inputs["Group Index"])
|
||||
normalize = nodes.new("ShaderNodeVectorMath")
|
||||
normalize.operation = "NORMALIZE"
|
||||
links.new(accumulate.outputs["Total"], normalize.inputs[0])
|
||||
return normalize.outputs["Vector"]
|
||||
|
||||
|
||||
def build_smoothed_normals(group):
|
||||
"""Return the smoothed normals and the average normal of each mesh island."""
|
||||
nodes, links = group.nodes, group.links
|
||||
normal = group.nodes.new("GeometryNodeInputNormal")
|
||||
blur = group.nodes.new("GeometryNodeBlurAttribute")
|
||||
blur.data_type = "FLOAT_VECTOR"
|
||||
links.new(normal.outputs[0], blur.inputs["Value"])
|
||||
blur.inputs["Iterations"].default_value = NORMAL_SMOOTH_ITERATIONS
|
||||
smoothed = blur.outputs[0]
|
||||
return smoothed, build_island_normal(group, smoothed)
|
||||
|
||||
|
||||
def build_normal_direction(group, controls, smoothed, average, weight):
|
||||
"""Blend the smoothed normal against the island average normal."""
|
||||
nodes, links = group.nodes, group.links
|
||||
difference = group.nodes.new("ShaderNodeVectorMath")
|
||||
difference.operation = "SUBTRACT"
|
||||
links.new(average, difference.inputs[0])
|
||||
links.new(smoothed, difference.inputs[1])
|
||||
lean = _math(
|
||||
group, "MULTIPLY", _math(group, "SUBTRACT", 1.0, weight),
|
||||
controls.outputs["Edge Turn"],
|
||||
)
|
||||
blend = _math(group, "ADD", weight, lean)
|
||||
offset = group.nodes.new("ShaderNodeVectorMath")
|
||||
offset.operation = "SCALE"
|
||||
links.new(difference.outputs["Vector"], offset.inputs[0])
|
||||
links.new(blend, offset.inputs[3])
|
||||
direction = group.nodes.new("ShaderNodeVectorMath")
|
||||
direction.operation = "ADD"
|
||||
links.new(smoothed, direction.inputs[0])
|
||||
links.new(offset.outputs["Vector"], direction.inputs[1])
|
||||
normalize = group.nodes.new("ShaderNodeVectorMath")
|
||||
normalize.operation = "NORMALIZE"
|
||||
links.new(direction.outputs["Vector"], normalize.inputs[0])
|
||||
return normalize.outputs["Vector"]
|
||||
|
||||
|
||||
def build_inflation_amount(group, controls, is_shell):
|
||||
"""Balloon scales the profile by Depth Scale, Shell moves a fixed distance."""
|
||||
nodes, links = group.nodes, group.links
|
||||
thickness = _math(
|
||||
group, "MULTIPLY", controls.outputs["Thickness"],
|
||||
_math(group, "ADD", BALLOON_THICKNESS_BASE, controls.outputs["Depth Scale"]),
|
||||
)
|
||||
balloon = _math(
|
||||
group, "MULTIPLY", read_float_attribute(group, "o_balloon"), thickness,
|
||||
)
|
||||
amount = nodes.new("GeometryNodeSwitch")
|
||||
amount.input_type = "FLOAT"
|
||||
links.new(is_shell, amount.inputs["Switch"])
|
||||
links.new(balloon, amount.inputs["False"])
|
||||
links.new(controls.outputs["Shell Thickness"], amount.inputs["True"])
|
||||
return amount.outputs[0]
|
||||
|
||||
|
||||
def build_solid(group, surface, amount, normal, is_shell, controls, weld_distance):
|
||||
"""Inflate the projected surface into a front leaf, wall and rear leaf."""
|
||||
nodes, links = group.nodes, group.links
|
||||
|
||||
def offset(distance):
|
||||
result = nodes.new("ShaderNodeVectorMath")
|
||||
result.operation = "SCALE"
|
||||
links.new(normal, result.inputs[0])
|
||||
links.new(distance, result.inputs[3])
|
||||
return result.outputs[0]
|
||||
|
||||
# The front leaf moves by Thickness x o_balloon x Front Inflation, independent of
|
||||
# the Depth Scale that scales the rear recession.
|
||||
inflation = _math(
|
||||
group, "MULTIPLY",
|
||||
_math(
|
||||
group, "MULTIPLY", read_float_attribute(group, "o_balloon"),
|
||||
controls.outputs["Thickness"],
|
||||
),
|
||||
controls.outputs["Front Inflation"],
|
||||
)
|
||||
front_amount = nodes.new("GeometryNodeSwitch")
|
||||
front_amount.input_type = "FLOAT"
|
||||
links.new(is_shell, front_amount.inputs["Switch"])
|
||||
links.new(inflation, front_amount.inputs["False"])
|
||||
front_amount.inputs["True"].default_value = 0.0
|
||||
|
||||
front = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(surface, front.inputs["Geometry"])
|
||||
links.new(offset(front_amount.outputs[0]), front.inputs["Offset"])
|
||||
|
||||
# The rear leaf inflates along the negated front direction, straight from the
|
||||
# untouched surface so the front offset cannot drag it along.
|
||||
span = _math(group, "MULTIPLY", amount, -1.0)
|
||||
extrude = nodes.new("GeometryNodeExtrudeMesh")
|
||||
extrude.mode = "FACES"
|
||||
extrude.inputs["Individual"].default_value = False
|
||||
links.new(surface, extrude.inputs["Mesh"])
|
||||
links.new(offset(span), extrude.inputs["Offset"])
|
||||
|
||||
# The rear surface smooths in proportion to its thickness and the balloon profile,
|
||||
# so thin shells and cut boundaries keep their exact positions.
|
||||
thickness = nodes.new("GeometryNodeSwitch")
|
||||
thickness.input_type = "FLOAT"
|
||||
links.new(is_shell, thickness.inputs["Switch"])
|
||||
links.new(controls.outputs["Thickness"], thickness.inputs["False"])
|
||||
links.new(controls.outputs["Shell Thickness"], thickness.inputs["True"])
|
||||
weight = _math(
|
||||
group, "MULTIPLY", _math(group, "MINIMUM", thickness.outputs[0], 1.0),
|
||||
read_float_attribute(group, "o_balloon"),
|
||||
)
|
||||
position = nodes.new("GeometryNodeInputPosition")
|
||||
blur = nodes.new("GeometryNodeBlurAttribute")
|
||||
blur.data_type = "FLOAT_VECTOR"
|
||||
blur.inputs["Weight"].default_value = 1.0
|
||||
links.new(position.outputs["Position"], blur.inputs["Value"])
|
||||
links.new(group_input(nodes, {"Back Smooth"}).outputs["Back Smooth"], blur.inputs["Iterations"])
|
||||
difference = nodes.new("ShaderNodeVectorMath")
|
||||
difference.operation = "SUBTRACT"
|
||||
links.new(blur.outputs["Value"], difference.inputs[0])
|
||||
links.new(position.outputs["Position"], difference.inputs[1])
|
||||
smooth_offset = nodes.new("ShaderNodeVectorMath")
|
||||
smooth_offset.operation = "SCALE"
|
||||
links.new(difference.outputs["Vector"], smooth_offset.inputs[0])
|
||||
links.new(weight, smooth_offset.inputs[3])
|
||||
smoothed = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(extrude.outputs["Mesh"], smoothed.inputs["Geometry"])
|
||||
links.new(smooth_offset.outputs["Vector"], smoothed.inputs["Offset"])
|
||||
|
||||
# Extruding against the normal leaves the rear leaf and wall facing inward.
|
||||
flipped = nodes.new("GeometryNodeFlipFaces")
|
||||
links.new(smoothed.outputs["Geometry"], flipped.inputs["Mesh"])
|
||||
rear = store_float_attribute(
|
||||
group, flipped.outputs["Mesh"], NORMAL_REDUCTION_ATTRIBUTE_NAME, 1.0,
|
||||
)
|
||||
join = nodes.new("GeometryNodeJoinGeometry")
|
||||
links.new(front.outputs["Geometry"], join.inputs["Geometry"])
|
||||
links.new(rear, join.inputs["Geometry"])
|
||||
merge = nodes.new("GeometryNodeMergeByDistance")
|
||||
merge.mode = "ALL"
|
||||
links.new(weld_distance, merge.inputs["Distance"])
|
||||
links.new(join.outputs["Geometry"], merge.inputs["Geometry"])
|
||||
return merge.outputs["Geometry"]
|
||||
|
||||
|
||||
def _build_depth_surface(group, geometry, controls):
|
||||
"""Project the depth image and inflate the projected surface into a solid."""
|
||||
links = group.links
|
||||
split = controls.outputs["Depth Split"]
|
||||
image = controls.outputs["Depth Image"]
|
||||
scale = controls.outputs["Uniform Scale"]
|
||||
geometry, corner_camera, cut_vertex = split_depth_surface(
|
||||
group, geometry, sample_face_camera(group, image), split, controls.outputs["Reference Depth"],
|
||||
controls.outputs["Depth Scale"], triangles=True,
|
||||
)
|
||||
geometry = remove_boundary_triangles(group, geometry)
|
||||
boundary = edge_boundary_field(group.nodes, group.links)
|
||||
rim = evaluate_field(group, boundary, "FLOAT", "POINT")
|
||||
cut = evaluate_field(
|
||||
group, boolean_node(group, "AND", boundary, cut_vertex), "FLOAT", "POINT",
|
||||
)
|
||||
# Only the Depth Split cuts taper the profile; the outline keeps its thickness.
|
||||
geometry = taper_split_profile(group, geometry, cut)
|
||||
camera = build_surface_camera(
|
||||
group, _position_field(group, image), corner_camera, cut_vertex, split,
|
||||
)
|
||||
projection = project_depth_surface(
|
||||
group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"],
|
||||
)
|
||||
projection = smooth_cut_boundary(group, projection, cut_vertex, boundary=rim)
|
||||
# Clamped depth pixels can collapse adjacent outline samples after projection.
|
||||
edge = group.nodes.new("GeometryNodeInputMeshEdgeVertices")
|
||||
length = group.nodes.new("ShaderNodeVectorMath")
|
||||
length.operation = "DISTANCE"
|
||||
links.new(edge.outputs["Position 1"], length.inputs[0])
|
||||
links.new(edge.outputs["Position 2"], length.inputs[1])
|
||||
typical = group.nodes.new("GeometryNodeAttributeStatistic")
|
||||
typical.data_type, typical.domain = "FLOAT", "EDGE"
|
||||
links.new(projection, typical.inputs["Geometry"])
|
||||
links.new(length.outputs["Value"], typical.inputs["Attribute"])
|
||||
merge = group.nodes.new("GeometryNodeMergeByDistance")
|
||||
merge.mode = "CONNECTED"
|
||||
links.new(projection, merge.inputs["Geometry"])
|
||||
links.new(_math(group, "MULTIPLY", typical.outputs["Mean"], 1e-6), merge.inputs["Distance"])
|
||||
projection = merge.outputs["Geometry"]
|
||||
# The mode field is shared by the thickness and direction choices.
|
||||
mode = group.nodes.new("GeometryNodeMenuSwitch")
|
||||
mode.data_type = "BOOLEAN"
|
||||
for item, name in zip(mode.enum_items, ("Balloon", "Shell")):
|
||||
item.name = name
|
||||
links.new(controls.outputs["Mode"], mode.inputs["Menu"])
|
||||
mode.inputs["Balloon"].default_value = False
|
||||
mode.inputs["Shell"].default_value = True
|
||||
is_shell = mode.outputs[0]
|
||||
amount = build_inflation_amount(group, controls, is_shell)
|
||||
statistics = group.nodes.new("GeometryNodeAttributeStatistic")
|
||||
statistics.data_type, statistics.domain = "FLOAT", "POINT"
|
||||
links.new(projection, statistics.inputs["Geometry"])
|
||||
links.new(amount, statistics.inputs["Attribute"])
|
||||
has_thickness = compare_node(group, "GREATER_THAN", statistics.outputs["Max"], 1e-6)
|
||||
profile = build_profile_weight(group, projection)
|
||||
smoothed, average = build_smoothed_normals(group)
|
||||
weight = build_normal_weight(group, is_shell, profile)
|
||||
# Material normal strength: the balloon profile gates it, Depth Scale attenuates it.
|
||||
inflated = group.nodes.new("GeometryNodeSwitch")
|
||||
inflated.input_type = "FLOAT"
|
||||
links.new(is_shell, inflated.inputs["Switch"])
|
||||
links.new(profile, inflated.inputs["False"])
|
||||
inflated.inputs["True"].default_value = 1.0
|
||||
normal_strength = group.nodes.new("GeometryNodeSwitch")
|
||||
normal_strength.input_type = "FLOAT"
|
||||
links.new(has_thickness, normal_strength.inputs["Switch"])
|
||||
normal_strength.inputs["False"].default_value = 1.0
|
||||
links.new(inflated.outputs[0], normal_strength.inputs["True"])
|
||||
reduction = _math(
|
||||
group, "SUBTRACT", 1.0,
|
||||
_math(group, "MULTIPLY", controls.outputs["Depth Scale"], normal_strength.outputs[0]),
|
||||
)
|
||||
projection = store_float_attribute(
|
||||
group, projection, NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction,
|
||||
)
|
||||
# The rear leaf inflates along the negated front direction, so one direction is
|
||||
# carried across the extrusion instead of being re-evaluated on the moved surface.
|
||||
projection = store_vector_attribute(
|
||||
group, projection, FRONT_NORMAL_ATTRIBUTE,
|
||||
build_normal_direction(group, controls, smoothed, average, weight),
|
||||
)
|
||||
solid = build_solid(
|
||||
group, projection, amount,
|
||||
read_vector_attribute(group, FRONT_NORMAL_ATTRIBUTE),
|
||||
is_shell, controls,
|
||||
_math(group, "MULTIPLY", typical.outputs["Mean"], SOLID_WELD_SCALE),
|
||||
)
|
||||
shaped = group.nodes.new("GeometryNodeSwitch")
|
||||
shaped.input_type = "GEOMETRY"
|
||||
links.new(has_thickness, shaped.inputs["Switch"])
|
||||
links.new(projection, shaped.inputs["False"])
|
||||
links.new(solid, shaped.inputs["True"])
|
||||
return remove_attribute_pattern(group, shaped.outputs[0], "_o_*")
|
||||
|
||||
|
||||
def build_image_depth_cutout_group():
|
||||
name = NODE_GROUP_NAME
|
||||
existing = bpy.data.node_groups.get(name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
group = bpy.data.node_groups.new(name, 'GeometryNodeTree')
|
||||
group.is_modifier, group.use_fake_user = (True, True)
|
||||
group.color_tag = 'GEOMETRY'
|
||||
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
|
||||
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
|
||||
float_input(group, 'Thickness', 0.0, maximum=2.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.'
|
||||
float_input(group, 'Shell Thickness', 0.0, maximum=1.0, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.'
|
||||
float_input(group, 'Depth Scale', 1.0, maximum=2.0)
|
||||
float_input(group, 'Depth Split', 0.1, maximum=1, subtype='FACTOR').description = 'Split the mesh where depth changes abruptly. Scaled by Depth Scale: a flat projection stays whole, higher values detect smaller depth jumps.'
|
||||
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 = 4, 0, 16
|
||||
boundary_smooth.description = 'Smooth geometry near the original outline and Depth Split edges. Zero disables boundary smoothing.'
|
||||
back_smooth = interface_socket(group, 'Back Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
back_smooth.default_value, back_smooth.min_value, back_smooth.max_value = 8, 0, 16
|
||||
back_smooth.description = 'Soften the rear surface where the balloon is thick. The effect follows Thickness and the balloon profile, so thin geometry and cut boundaries keep their positions.'
|
||||
float_input(group, 'Edge Turn', 2.0, minimum=-3.0, maximum=3.0, parent=options).description = 'Turn the outline direction: zero follows the smoothed normal, one reaches the island average normal, two mirrors the smoothed normal across it, and higher or negative values keep turning either way.'
|
||||
float_input(group, 'Front Inflation', 0.2, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode, as a share of the balloon thickness. Zero keeps the original front surface.'
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||
data = group.interface.new_panel(name='Data', default_closed=True)
|
||||
float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True
|
||||
interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True
|
||||
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
|
||||
controls = group_input(group.nodes, set())
|
||||
geometry = _build_depth_surface(group, controls.outputs['Geometry'], controls)
|
||||
_shade_output(group, geometry)
|
||||
prepare_node_group(group)
|
||||
for item in group.interface.items_tree:
|
||||
if item.item_type != 'SOCKET' or item.in_out != 'INPUT':
|
||||
continue
|
||||
if item.name == 'Mode':
|
||||
item.default_value = 'Balloon'
|
||||
elif item.name == 'Shell Thickness':
|
||||
item.name = 'Thickness'
|
||||
return group
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Build O Image Depth Layer."""
|
||||
import bpy
|
||||
from anyimage.common.material import DEPTH_MATERIAL_NODE_GROUP_NAME
|
||||
from ..common.shader import build_material_nodes, connect_material_outputs
|
||||
|
||||
MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement"
|
||||
BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth"
|
||||
DEPTH_SCALE_ATTRIBUTE = "o_depth_scale"
|
||||
|
||||
|
||||
def build_image_depth_layer_group():
|
||||
group_name = DEPTH_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')]
|
||||
input_sockets.insert(2, ('Depth', 'NodeSocketFloat'))
|
||||
options = None
|
||||
option_names = {'Alpha Fix', 'Normal Scale', 'Object Space'}
|
||||
for name, socket_type in input_sockets:
|
||||
socket = interface.new_socket(name=name, in_out='INPUT', socket_type=socket_type, parent=options if name in option_names else None)
|
||||
if name in {'Alpha', 'Alpha Fix'}:
|
||||
socket.default_value = 1.0 if name == 'Alpha' else 0.0
|
||||
socket.min_value = 0.0
|
||||
socket.max_value = 1.0
|
||||
socket.subtype = 'FACTOR'
|
||||
elif name == 'Bump Scale':
|
||||
socket.description = 'Control bump detail derived from image brightness. Negative values reverse the bumps. Zero disables the effect.'
|
||||
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')]
|
||||
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, links = group.nodes, group.links
|
||||
group_input, group_output, roughness_curve, bump = build_material_nodes(group)
|
||||
processed_alpha = group_input.outputs['Alpha']
|
||||
displacement = nodes.new('ShaderNodeDisplacement')
|
||||
displacement.inputs['Midlevel'].default_value = 0.0
|
||||
displacement.inputs['Scale'].default_value = 1.0
|
||||
background_attribute = nodes.new('ShaderNodeAttribute')
|
||||
background_attribute.attribute_type = 'GEOMETRY'
|
||||
background_attribute.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE
|
||||
material_attribute = nodes.new('ShaderNodeAttribute')
|
||||
material_attribute.attribute_type = 'GEOMETRY'
|
||||
material_attribute.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE
|
||||
strength_attribute = nodes.new('ShaderNodeAttribute')
|
||||
strength_attribute.attribute_type = 'GEOMETRY'
|
||||
strength_attribute.attribute_name = DEPTH_SCALE_ATTRIBUTE
|
||||
foreground_mask = nodes.new('ShaderNodeMath')
|
||||
foreground_mask.operation = 'GREATER_THAN'
|
||||
foreground_mask.inputs[1].default_value = 0.5
|
||||
clamp_foreground = nodes.new('ShaderNodeMath')
|
||||
clamp_foreground.operation = 'MAXIMUM'
|
||||
masked_depth = nodes.new('ShaderNodeMix')
|
||||
masked_depth.data_type = 'FLOAT'
|
||||
enabled_depth = nodes.new('ShaderNodeMath')
|
||||
enabled_depth.operation = 'MULTIPLY'
|
||||
scaled_depth = nodes.new('ShaderNodeMath')
|
||||
scaled_depth.operation = 'MULTIPLY'
|
||||
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]
|
||||
connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha)
|
||||
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.'
|
||||
group.use_fake_user = True
|
||||
return group
|
||||
@@ -1,212 +0,0 @@
|
||||
"""Build a quad panorama with periodic sampling and radial depth separation."""
|
||||
|
||||
import math
|
||||
|
||||
import bpy
|
||||
|
||||
from ..common.depth_surface import (
|
||||
_math, split_depth_surface, build_surface_camera,
|
||||
)
|
||||
from ..common.nodes import (
|
||||
boolean_node, compare_node, float_input, group_input, interface_socket,
|
||||
prepare_node_group, evaluate_field, remove_attribute_pattern,
|
||||
)
|
||||
from ..common.material_slots import inherit_material_slots
|
||||
from ..common.boundary_smoothing import smooth_boundary_depth, smooth_cut_boundary
|
||||
|
||||
|
||||
def _quad_sphere(group, segments):
|
||||
nodes, links = group.nodes, group.links
|
||||
cube = nodes.new("GeometryNodeMeshCube")
|
||||
cube.inputs["Size"].default_value = (2, 2, 2)
|
||||
for axis in "XYZ":
|
||||
links.new(segments, cube.inputs[f"Vertices {axis}"])
|
||||
xyz = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(nodes.new("GeometryNodeInputPosition").outputs[0], xyz.inputs[0])
|
||||
squares = {axis: _math(group, "MULTIPLY", xyz.outputs[axis], xyz.outputs[axis]) for axis in "XYZ"}
|
||||
combine = nodes.new("ShaderNodeCombineXYZ")
|
||||
for axis, a, b in (("X", "Y", "Z"), ("Y", "Z", "X"), ("Z", "X", "Y")):
|
||||
half = _math(group, "DIVIDE", _math(group, "ADD", squares[a], squares[b]), 2)
|
||||
third = _math(group, "DIVIDE", _math(group, "MULTIPLY", squares[a], squares[b]), 3)
|
||||
root = _math(group, "SQRT", _math(group, "ADD", _math(group, "SUBTRACT", 1, half), third))
|
||||
links.new(_math(group, "MULTIPLY", xyz.outputs[axis], root), combine.inputs[axis])
|
||||
position = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(cube.outputs["Mesh"], position.inputs["Geometry"])
|
||||
links.new(combine.outputs[0], position.inputs["Position"])
|
||||
return position.outputs["Geometry"]
|
||||
|
||||
|
||||
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
|
||||
interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
|
||||
subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
|
||||
subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time."
|
||||
subdivide.default_value, subdivide.min_value, subdivide.max_value = 8, 0, 8
|
||||
dome = float_input(group, "Dome Radius", 50, minimum=1, maximum=100, subtype="DISTANCE")
|
||||
dome.description = "Radius of the sphere kept at Depth Scale 0, and where mask areas below Mask Threshold land."
|
||||
float_input(group, "Depth Scale", 1, maximum=1, subtype="FACTOR").description = "Blend between a sphere at zero and the estimated depth surface at one."
|
||||
float_input(group, "Depth Split", 0.1, maximum=1, subtype="FACTOR").description = "Split the mesh where depth changes abruptly. Scaled by Depth Scale: a flat projection stays whole, higher values detect smaller depth jumps."
|
||||
valid_only = interface_socket(group, "Depth Mask", "INPUT", "NodeSocketBool")
|
||||
valid_only.default_value = True
|
||||
valid_only.description = "Remove mesh areas marked invalid by the depth mask; with the mask off, they rest on the dome sphere."
|
||||
options = group.interface.new_panel("Options", default_closed=True)
|
||||
boundary_smooth = interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", options)
|
||||
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 0, 16
|
||||
boundary_smooth.description = "Smooth depth and geometry near cut edges on the panorama surface. Zero disables boundary smoothing."
|
||||
float_input(group, "Mask Threshold", 0.9, maximum=0.99, subtype="FACTOR", parent=options).description = "Set the minimum depth mask value treated as valid. Higher values exclude more partially valid boundary areas."
|
||||
data = group.interface.new_panel("Data", default_closed=True)
|
||||
interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage", data).hide_in_modifier = True
|
||||
interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
|
||||
nodes, links = group.nodes, group.links
|
||||
|
||||
def uv(direction):
|
||||
normalize = nodes.new("ShaderNodeVectorMath")
|
||||
normalize.operation = "NORMALIZE"
|
||||
links.new(direction, normalize.inputs[0])
|
||||
xyz = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(normalize.outputs[0], xyz.inputs[0])
|
||||
longitude = _math(group, "DIVIDE", _math(group, "ARCTAN2", xyz.outputs["Y"], xyz.outputs["X"]), 2 * math.pi)
|
||||
z = _math(group, "MAXIMUM", -1, _math(group, "MINIMUM", 1, xyz.outputs["Z"]))
|
||||
latitude = _math(group, "SUBTRACT", 1, _math(group, "DIVIDE", _math(group, "ARCCOSINE", z), math.pi))
|
||||
return longitude, latitude, xyz.outputs["Z"]
|
||||
|
||||
def combine(u, v):
|
||||
node = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(u, node.inputs["X"])
|
||||
links.new(v, node.inputs["Y"])
|
||||
return node.outputs[0]
|
||||
|
||||
def texture(coordinates, interpolation):
|
||||
inputs = group_input(nodes, {"Depth Image"})
|
||||
info = nodes.new("GeometryNodeImageInfo")
|
||||
links.new(inputs.outputs["Depth Image"], info.inputs["Image"])
|
||||
axes = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(coordinates, axes.inputs[0])
|
||||
half_pixel = _math(group, "DIVIDE", 0.5, info.outputs["Height"])
|
||||
v = _math(group, "MAXIMUM", half_pixel,
|
||||
_math(group, "MINIMUM", axes.outputs["Y"], _math(group, "SUBTRACT", 1, half_pixel)))
|
||||
node = nodes.new("GeometryNodeImageTexture")
|
||||
node.interpolation, node.extension = interpolation, "REPEAT"
|
||||
links.new(inputs.outputs["Depth Image"], node.inputs["Image"])
|
||||
links.new(combine(axes.outputs["X"], v), node.inputs["Vector"])
|
||||
return node.outputs["Color"]
|
||||
|
||||
def depth(coordinates):
|
||||
linear, nearest = texture(coordinates, "Linear"), texture(coordinates, "Closest")
|
||||
alphas = []
|
||||
for color in (linear, nearest):
|
||||
separate = nodes.new("FunctionNodeSeparateColor")
|
||||
links.new(color, separate.inputs["Color"])
|
||||
alphas.append(separate.outputs["Alpha"])
|
||||
choose = nodes.new("GeometryNodeSwitch")
|
||||
choose.input_type = "RGBA"
|
||||
links.new(compare_node(group, "LESS_THAN", alphas[0],
|
||||
_math(group, "SUBTRACT", alphas[1], 1e-6)), choose.inputs["Switch"])
|
||||
links.new(linear, choose.inputs["False"])
|
||||
links.new(nearest, choose.inputs["True"])
|
||||
return choose.outputs[0], alphas[1]
|
||||
|
||||
inputs = group_input(nodes, {"Subdivide"})
|
||||
sphere = _quad_sphere(group, _math(group, "ADD", _math(group, "POWER", 2, inputs.outputs["Subdivide"]), 1))
|
||||
position = nodes.new("GeometryNodeInputPosition").outputs[0]
|
||||
longitude, latitude, z = uv(position)
|
||||
coordinates = evaluate_field(group, combine(longitude, latitude), "FLOAT_VECTOR", "POINT")
|
||||
face = nodes.new("GeometryNodeFieldOnDomain")
|
||||
face.data_type, face.domain = "FLOAT_VECTOR", "FACE"
|
||||
links.new(position, face.inputs["Value"])
|
||||
face_u, face_v, _ = uv(face.outputs["Value"])
|
||||
correction = _math(group, "ROUND", _math(group, "SUBTRACT", face_u, longitude))
|
||||
choose = nodes.new("GeometryNodeSwitch")
|
||||
choose.input_type = "FLOAT"
|
||||
links.new(compare_node(group, "GREATER_THAN", _math(group, "ABSOLUTE", z), 0.999999), choose.inputs["Switch"])
|
||||
links.new(_math(group, "ADD", longitude, correction), choose.inputs["False"])
|
||||
links.new(face_u, choose.inputs["True"])
|
||||
uv_map = nodes.new("GeometryNodeStoreNamedAttribute")
|
||||
uv_map.data_type, uv_map.domain = "FLOAT2", "CORNER"
|
||||
uv_map.inputs["Name"].default_value = "UVMap"
|
||||
links.new(sphere, uv_map.inputs["Geometry"])
|
||||
links.new(combine(choose.outputs[0], latitude), uv_map.inputs["Value"])
|
||||
pixel, _ = depth(coordinates)
|
||||
pixel = evaluate_field(group, pixel, "FLOAT_VECTOR", "POINT")
|
||||
# Both the mask and the depth of a face are read at its centre, like Depth Plane.
|
||||
face_camera, face_alpha = depth(combine(face_u, face_v))
|
||||
face_alpha = evaluate_field(group, face_alpha, "FLOAT", "FACE")
|
||||
threshold_input = group_input(nodes, {"Mask Threshold"})
|
||||
invalid = compare_node(group, "LESS_THAN", face_alpha, threshold_input.outputs["Mask Threshold"])
|
||||
protected = uv_map.outputs["Geometry"]
|
||||
separate = nodes.new("GeometryNodeSeparateGeometry")
|
||||
separate.domain = "FACE"
|
||||
links.new(protected, separate.inputs["Geometry"])
|
||||
links.new(invalid, separate.inputs["Selection"])
|
||||
controls = group_input(nodes, {"Depth Split", "Depth Scale"})
|
||||
strength = controls.outputs["Depth Split"]
|
||||
radial = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(face_camera, radial.inputs[0])
|
||||
geometry, corner_camera, cut_vertex = split_depth_surface(
|
||||
group, separate.outputs["Inverted"], face_camera, strength, 1.0, controls.outputs["Depth Scale"],
|
||||
face_distance=radial.outputs["X"], face_center=face.outputs["Value"],
|
||||
)
|
||||
distance = build_surface_camera(group, pixel, corner_camera, cut_vertex, strength)
|
||||
axes = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(distance, axes.inputs[0])
|
||||
distance = smooth_boundary_depth(group, axes.outputs["X"])
|
||||
dome_radius = group_input(nodes, {"Dome Radius"}).outputs["Dome Radius"]
|
||||
radius = _math(
|
||||
group, "ADD", dome_radius,
|
||||
_math(group, "MULTIPLY", controls.outputs["Depth Scale"],
|
||||
_math(group, "SUBTRACT", distance, dome_radius)),
|
||||
)
|
||||
radial = nodes.new("ShaderNodeVectorMath")
|
||||
radial.operation = "SCALE"
|
||||
links.new(position, radial.inputs[0])
|
||||
links.new(radius, radial.inputs[3])
|
||||
deform = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(geometry, deform.inputs["Geometry"])
|
||||
links.new(radial.outputs[0], deform.inputs["Position"])
|
||||
geometry = remove_attribute_pattern(group, deform.outputs[0], "_o_*")
|
||||
geometry = smooth_cut_boundary(group, geometry, cut_vertex, outline_scale=1.0)
|
||||
# The excluded faces rest on the dome sphere and relax along their own boundary.
|
||||
domed = nodes.new("ShaderNodeVectorMath")
|
||||
domed.operation = "SCALE"
|
||||
links.new(position, domed.inputs[0])
|
||||
links.new(dome_radius, domed.inputs[3])
|
||||
resting = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(separate.outputs["Selection"], resting.inputs["Geometry"])
|
||||
links.new(domed.outputs[0], resting.inputs["Position"])
|
||||
resting = smooth_cut_boundary(group, resting.outputs["Geometry"], outline_scale=1.0)
|
||||
joined = nodes.new("GeometryNodeJoinGeometry")
|
||||
links.new(geometry, joined.inputs["Geometry"])
|
||||
links.new(resting, joined.inputs["Geometry"])
|
||||
# A flat projection without the mask is the dome sphere alone: no split, no seam.
|
||||
dome_sphere = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(protected, dome_sphere.inputs["Geometry"])
|
||||
links.new(domed.outputs[0], dome_sphere.inputs["Position"])
|
||||
cull_controls = group_input(nodes, {"Depth Mask"})
|
||||
dome_only = boolean_node(
|
||||
group,
|
||||
"AND",
|
||||
boolean_node(group, "NOT", compare_node(group, "GREATER_THAN", controls.outputs["Depth Scale"], 0.0)),
|
||||
boolean_node(group, "NOT", cull_controls.outputs["Depth Mask"]),
|
||||
)
|
||||
unmasked = nodes.new("GeometryNodeSwitch")
|
||||
unmasked.input_type = "GEOMETRY"
|
||||
links.new(dome_only, unmasked.inputs["Switch"])
|
||||
links.new(joined.outputs["Geometry"], unmasked.inputs["False"])
|
||||
links.new(dome_sphere.outputs["Geometry"], unmasked.inputs["True"])
|
||||
cull = nodes.new("GeometryNodeSwitch")
|
||||
cull.input_type = "GEOMETRY"
|
||||
links.new(cull_controls.outputs["Depth Mask"], cull.inputs["Switch"])
|
||||
links.new(unmasked.outputs["Output"], cull.inputs["False"])
|
||||
links.new(geometry, cull.inputs["True"])
|
||||
flip = nodes.new("GeometryNodeFlipFaces")
|
||||
links.new(cull.outputs["Output"], flip.inputs["Mesh"])
|
||||
smooth = nodes.new("GeometryNodeSetShadeSmooth")
|
||||
smooth.domain = "FACE"
|
||||
smooth.inputs["Shade Smooth"].default_value = True
|
||||
links.new(flip.outputs["Mesh"], smooth.inputs["Geometry"])
|
||||
geometry = inherit_material_slots(group, smooth.outputs[0], group_input(nodes, {"Geometry"}).outputs["Geometry"])
|
||||
output = nodes.new("NodeGroupOutput")
|
||||
links.new(geometry, output.inputs["Geometry"])
|
||||
prepare_node_group(group)
|
||||
return group
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Build the camera-projected Depth Plane Geometry Nodes group."""
|
||||
|
||||
import bpy
|
||||
|
||||
from ..common.validity import delete_invalid_faces
|
||||
|
||||
from ..common.nodes import group_input as _group_input, interface_socket as _interface_socket
|
||||
from ..common.nodes import compare_node, prepare_node_group, float_input, evaluate_field
|
||||
from ..common.boundary_smoothing import smooth_boundary_camera, smooth_cut_boundary
|
||||
from ..common.depth_surface import (
|
||||
_math, project_depth_surface, thicken_depth_surface,
|
||||
split_depth_surface, sample_face_camera, build_surface_camera,
|
||||
)
|
||||
|
||||
DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane"
|
||||
|
||||
|
||||
def build_image_depth_plane_group(image_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
|
||||
geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
|
||||
geometry.structure_type = "SINGLE"
|
||||
subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
|
||||
subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time."
|
||||
subdivide.default_value = 6
|
||||
subdivide.min_value = 0
|
||||
subdivide.max_value = 10
|
||||
thickness = _interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat")
|
||||
thickness.default_value = 0.0
|
||||
thickness.min_value = 0.0
|
||||
thickness.subtype = "DISTANCE"
|
||||
thickness.description = "Add shell thickness inward from the depth surface. Zero keeps a single surface."
|
||||
depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat")
|
||||
depth_scale.default_value = 1.0
|
||||
depth_scale.min_value = 0.0
|
||||
depth_scale.max_value = 2.0
|
||||
float_input(group, "Depth Split", 0.1, maximum=1, subtype="FACTOR").description = "Split the mesh where depth changes abruptly. Scaled by Depth Scale: a flat projection stays whole, higher values detect smaller depth jumps."
|
||||
valid_only = _interface_socket(group, "Depth Mask", "INPUT", "NodeSocketBool")
|
||||
valid_only.description = "Remove mesh areas marked invalid by the depth mask."
|
||||
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 = 4, 0, 16
|
||||
boundary_smooth.description = "Smooth depth and geometry near cut edges while preserving the original image border. Zero disables boundary smoothing."
|
||||
validity_threshold = _interface_socket(group, "Mask Threshold", "INPUT", "NodeSocketFloat", parent=options)
|
||||
validity_threshold.description = "Set the minimum depth mask value treated as valid. Higher values exclude more partially valid boundary areas."
|
||||
validity_threshold.default_value = 0.9
|
||||
validity_threshold.min_value, validity_threshold.max_value = 0.0, 0.99
|
||||
validity_threshold.subtype = "FACTOR"
|
||||
float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options)
|
||||
data = group.interface.new_panel(name="Data", default_closed=True)
|
||||
uniform_scale = _interface_socket(
|
||||
group,
|
||||
"Uniform Scale",
|
||||
"INPUT",
|
||||
"NodeSocketFloat",
|
||||
parent=data,
|
||||
)
|
||||
uniform_scale.default_value = 1.0
|
||||
uniform_scale.min_value = 0.0
|
||||
uniform_scale.hide_in_modifier = True
|
||||
depth_image = _interface_socket(
|
||||
group,
|
||||
"Depth Image",
|
||||
"INPUT",
|
||||
"NodeSocketImage",
|
||||
parent=data,
|
||||
)
|
||||
depth_image.hide_in_modifier = True
|
||||
_interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
|
||||
|
||||
nodes, links = group.nodes, group.links
|
||||
controls = _group_input(nodes, {"Geometry", "Subdivide"})
|
||||
plane = nodes.new("GeometryNodeGroup")
|
||||
plane.node_tree = image_plane_group
|
||||
plane.inputs["Thickness"].default_value = 0
|
||||
links.new(controls.outputs["Geometry"], plane.inputs["Geometry"])
|
||||
links.new(controls.outputs["Subdivide"], plane.inputs["Subdivide"])
|
||||
texture_input = _group_input(nodes, {"Depth Image"})
|
||||
scale_input = _group_input(nodes, {"Uniform Scale"})
|
||||
uv = nodes.new("GeometryNodeInputNamedAttribute")
|
||||
uv.data_type = "FLOAT_VECTOR"
|
||||
uv.inputs["Name"].default_value = "UVMap"
|
||||
texture = nodes.new("GeometryNodeImageTexture")
|
||||
texture.interpolation, texture.extension = "Linear", "EXTEND"
|
||||
links.new(uv.outputs["Attribute"], texture.inputs["Vector"])
|
||||
links.new(texture_input.outputs["Depth Image"], texture.inputs["Image"])
|
||||
threshold_input = _group_input(nodes, {"Mask Threshold"})
|
||||
channels = nodes.new("FunctionNodeSeparateColor")
|
||||
channels.mode = "RGB"
|
||||
links.new(texture.outputs["Color"], channels.inputs["Color"])
|
||||
invalid = nodes.new("FunctionNodeCompare")
|
||||
invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN"
|
||||
links.new(channels.outputs["Alpha"], invalid.inputs["A"])
|
||||
links.new(threshold_input.outputs["Mask Threshold"], invalid.inputs["B"])
|
||||
# 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, compare_node(group, "LESS_THAN", margin, 1e-6), "BOOLEAN", "POINT")
|
||||
protected = plane.outputs["Geometry"]
|
||||
culled = delete_invalid_faces(group, protected, invalid.outputs["Result"])
|
||||
cull_controls = _group_input(nodes, {"Depth Mask"})
|
||||
cull = nodes.new("GeometryNodeSwitch")
|
||||
cull.input_type = "GEOMETRY"
|
||||
links.new(cull_controls.outputs["Depth Mask"], cull.inputs["Switch"])
|
||||
links.new(culled, cull.inputs["True"])
|
||||
links.new(protected, cull.inputs["False"])
|
||||
split_input = _group_input(nodes, {"Depth Split", "Depth Scale"})
|
||||
reference_input = _group_input(nodes, {"Reference Depth"})
|
||||
geometry, corner_camera, cut_vertex = split_depth_surface(
|
||||
group, cull.outputs["Output"], sample_face_camera(group, texture_input.outputs["Depth Image"]),
|
||||
split_input.outputs["Depth Split"], reference_input.outputs["Reference Depth"],
|
||||
split_input.outputs["Depth Scale"],
|
||||
)
|
||||
camera = build_surface_camera(
|
||||
group, texture.outputs["Color"], corner_camera, cut_vertex,
|
||||
split_input.outputs["Depth Split"],
|
||||
)
|
||||
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"],
|
||||
)
|
||||
surface = smooth_cut_boundary(
|
||||
group, surface, cut_vertex, protected=original_boundary, outline_scale=1.0,
|
||||
)
|
||||
shell_input = _group_input(nodes, {"Thickness"})
|
||||
shell = thicken_depth_surface(
|
||||
group, surface, shell_input.outputs["Thickness"],
|
||||
normal_smooth=100,
|
||||
)
|
||||
positive = compare_node(group, "GREATER_THAN", shell_input.outputs["Thickness"], 0)
|
||||
choose = nodes.new("GeometryNodeSwitch")
|
||||
choose.input_type = "GEOMETRY"
|
||||
links.new(positive, choose.inputs["Switch"])
|
||||
links.new(surface, choose.inputs["False"])
|
||||
links.new(shell["geometry"], choose.inputs["True"])
|
||||
smooth = nodes.new("GeometryNodeSetShadeSmooth")
|
||||
smooth.domain = "FACE"
|
||||
links.new(choose.outputs["Output"], smooth.inputs["Geometry"])
|
||||
output = nodes.new("NodeGroupOutput")
|
||||
links.new(smooth.outputs["Geometry"], output.inputs["Geometry"])
|
||||
group.color_tag = "GEOMETRY"
|
||||
group.description = "Build a camera-projected image shell."
|
||||
group.use_fake_user = True
|
||||
prepare_node_group(group)
|
||||
return group
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Build O Image Layer."""
|
||||
import bpy
|
||||
from anyimage.common.material import IMAGE_MATERIAL_NODE_GROUP_NAME
|
||||
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
from ..common.shader import build_material_nodes, connect_material_outputs
|
||||
from ..common.normal_map import transform_object_normal_color
|
||||
|
||||
|
||||
def build_image_layer_group():
|
||||
group_name = 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')]
|
||||
input_sockets.insert(2, ('Normal', 'NodeSocketColor'))
|
||||
input_sockets.insert(3, ('Normal Scale', 'NodeSocketFloat'))
|
||||
input_sockets.insert(4, ('Object Space', 'NodeSocketBool'))
|
||||
input_sockets.insert(2, ('Alpha Fix', 'NodeSocketFloat'))
|
||||
options = interface.new_panel(name='Options', default_closed=True)
|
||||
option_names = {'Alpha Fix', 'Object Space'}
|
||||
for name, socket_type in input_sockets:
|
||||
socket = interface.new_socket(name=name, in_out='INPUT', socket_type=socket_type, parent=options if name in option_names else None)
|
||||
if name in {'Alpha', 'Alpha Fix'}:
|
||||
socket.default_value = 1.0 if name == 'Alpha' else 0.0
|
||||
socket.min_value = 0.0
|
||||
socket.max_value = 1.0
|
||||
socket.subtype = 'FACTOR'
|
||||
elif name == 'Bump Scale':
|
||||
socket.description = 'Control bump detail derived from image brightness. Negative values reverse the bumps. Zero disables the effect.'
|
||||
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.description = 'Control the strength of surface detail from the normal map.'
|
||||
socket.default_value = 1.0
|
||||
socket.min_value = 0.0
|
||||
socket.max_value = 10.0
|
||||
elif name == 'Object Space':
|
||||
socket.description = 'Interpret the normal map in object space when enabled, or tangent space when disabled.'
|
||||
socket.default_value = False
|
||||
if name == 'Alpha Fix':
|
||||
socket.description = 'Reduce semi-transparent fringes around image edges. Higher values remove more faint edge pixels. Zero keeps the original transparency.'
|
||||
output_sockets = [('Color', 'NodeSocketColor'), ('Roughness', 'NodeSocketFloat'), ('Alpha', 'NodeSocketFloat'), ('Normal', 'NodeSocketVector')]
|
||||
for name, socket_type in output_sockets:
|
||||
interface.new_socket(name=name, in_out='OUTPUT', socket_type=socket_type)
|
||||
nodes, links = group.nodes, group.links
|
||||
group_input, group_output, roughness_curve, bump = build_material_nodes(group)
|
||||
normal_attribute = nodes.new('ShaderNodeAttribute')
|
||||
normal_attribute.attribute_type = 'GEOMETRY'
|
||||
normal_attribute.attribute_name = NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
normal_weight = nodes.new('ShaderNodeMath')
|
||||
normal_weight.operation = 'SUBTRACT'
|
||||
normal_weight.inputs[0].default_value = 1.0
|
||||
links.new(normal_attribute.outputs['Fac'], normal_weight.inputs[1])
|
||||
weighted_strength = nodes.new('ShaderNodeMath')
|
||||
weighted_strength.operation = 'MULTIPLY'
|
||||
links.new(group_input.outputs['Normal Scale'], weighted_strength.inputs[0])
|
||||
links.new(normal_weight.outputs[0], weighted_strength.inputs[1])
|
||||
tangent_normal = nodes.new('ShaderNodeNormalMap')
|
||||
tangent_normal.space = 'TANGENT'
|
||||
object_normal = nodes.new('ShaderNodeNormalMap')
|
||||
object_normal.space = 'OBJECT'
|
||||
choose_normal = nodes.new('ShaderNodeMix')
|
||||
choose_normal.data_type = 'VECTOR'
|
||||
links.new(group_input.outputs['Normal'], tangent_normal.inputs['Color'])
|
||||
links.new(weighted_strength.outputs[0], tangent_normal.inputs['Strength'])
|
||||
normal_color, normal_strength = transform_object_normal_color(
|
||||
group, group_input.outputs['Normal'], weighted_strength.outputs[0],
|
||||
)
|
||||
links.new(normal_color, object_normal.inputs['Color'])
|
||||
links.new(normal_strength, object_normal.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'])
|
||||
processed_alpha = group_input.outputs['Alpha']
|
||||
alpha_curve = nodes.new('ShaderNodeFloatCurve')
|
||||
alpha_curve.mapping.initialize()
|
||||
alpha_curve.mapping.extend = 'HORIZONTAL'
|
||||
alpha_curve.mapping.use_clip = True
|
||||
alpha_points = alpha_curve.mapping.curves[0].points
|
||||
alpha_points[0].location = (0.0, 0.0)
|
||||
alpha_points[-1].location = (1.0, 1.0)
|
||||
alpha_points.new(0.8, 0.0)
|
||||
alpha_points.new(0.925, 0.925)
|
||||
for point in alpha_points:
|
||||
point.handle_type = 'AUTO_CLAMPED'
|
||||
alpha_curve.mapping.update()
|
||||
links.new(group_input.outputs['Alpha'], alpha_curve.inputs['Value'])
|
||||
links.new(group_input.outputs['Alpha Fix'], alpha_curve.inputs['Factor'])
|
||||
processed_alpha = alpha_curve.outputs['Value']
|
||||
connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha)
|
||||
group.color_tag = 'SHADER'
|
||||
group.description = f'Prepare {group_name} material outputs.'
|
||||
group.use_fake_user = True
|
||||
for node in nodes:
|
||||
for socket in node.outputs:
|
||||
socket.hide = not socket.is_linked
|
||||
return group
|
||||
@@ -1,253 +0,0 @@
|
||||
"""Build the Image Plane Geometry Nodes group."""
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
|
||||
from ..common.nodes import prepare_node_group
|
||||
from ..common.material_slots import inherit_material_slots
|
||||
|
||||
|
||||
IMAGE_PLANE_GROUP_NAME = "O Image Plane"
|
||||
|
||||
|
||||
def new_socket(interface, name, in_out, socket_type, parent=None):
|
||||
socket = interface.new_socket(
|
||||
name=name,
|
||||
in_out=in_out,
|
||||
socket_type=socket_type,
|
||||
parent=parent,
|
||||
)
|
||||
if in_out == "INPUT" and hasattr(socket, "structure_type"):
|
||||
socket.structure_type = "SINGLE"
|
||||
return socket
|
||||
|
||||
|
||||
def new_group_input(nodes, visible_names):
|
||||
node = nodes.new("NodeGroupInput")
|
||||
visible = set(visible_names)
|
||||
for output in node.outputs:
|
||||
output.hide = output.name not in visible
|
||||
return node
|
||||
|
||||
|
||||
def build_dimension_fields(group):
|
||||
"""Build independent source bounds for UV and mesh calculations."""
|
||||
sources = [
|
||||
group.nodes[name] for name in ("Bounding Box", "Vector Math", "Separate XYZ")
|
||||
]
|
||||
for targets in (
|
||||
{"Math.011", "Math.013"},
|
||||
{"Grid", "Combine XYZ.001", "Vector Math.001"},
|
||||
):
|
||||
copies = {source: group.nodes.new(source.bl_idname) for source in sources}
|
||||
copies[sources[1]].operation = "SUBTRACT"
|
||||
for link in list(group.links):
|
||||
if link.to_node in copies:
|
||||
source = copies.get(link.from_node, link.from_node)
|
||||
group.links.new(
|
||||
source.outputs[link.from_socket.identifier],
|
||||
copies[link.to_node].inputs[link.to_socket.identifier],
|
||||
)
|
||||
elif link.from_node in copies and link.to_node.name in targets:
|
||||
group.links.new(
|
||||
copies[link.from_node].outputs[link.from_socket.identifier],
|
||||
link.to_socket,
|
||||
)
|
||||
|
||||
|
||||
def build_image_plane_group():
|
||||
group = bpy.data.node_groups.new(IMAGE_PLANE_GROUP_NAME, "GeometryNodeTree")
|
||||
group.is_modifier = True
|
||||
interface = group.interface
|
||||
geometry = new_socket(interface, "Geometry", "INPUT", "NodeSocketGeometry")
|
||||
geometry.structure_type = "SINGLE"
|
||||
subdivide = new_socket(interface, "Subdivide", "INPUT", "NodeSocketInt")
|
||||
subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time."
|
||||
subdivide.default_value = 0
|
||||
subdivide.min_value = 0
|
||||
subdivide.max_value = 10
|
||||
thickness = new_socket(interface, "Thickness", "INPUT", "NodeSocketFloat")
|
||||
thickness.default_value = 0.0
|
||||
thickness.min_value = 0.0
|
||||
thickness.max_value = 10000.0
|
||||
thickness.subtype = "DISTANCE"
|
||||
new_socket(interface, "Geometry", "OUTPUT", "NodeSocketGeometry")
|
||||
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
geometry_input = new_group_input(nodes, {"Geometry"})
|
||||
subdivide_input = new_group_input(nodes, {"Subdivide"})
|
||||
thickness_input = new_group_input(nodes, {"Thickness"})
|
||||
output = nodes.new("NodeGroupOutput")
|
||||
|
||||
bounds = nodes.new("GeometryNodeBoundBox")
|
||||
links.new(geometry_input.outputs["Geometry"], bounds.inputs["Geometry"])
|
||||
dimensions = nodes.new("ShaderNodeVectorMath")
|
||||
dimensions.operation = "SUBTRACT"
|
||||
links.new(bounds.outputs["Max"], dimensions.inputs[0])
|
||||
links.new(bounds.outputs["Min"], dimensions.inputs[1])
|
||||
separate_dimensions = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(dimensions.outputs["Vector"], separate_dimensions.inputs[0])
|
||||
|
||||
short_segments = nodes.new("ShaderNodeMath")
|
||||
short_segments.operation = "POWER"
|
||||
short_segments.inputs[0].default_value = 2.0
|
||||
links.new(subdivide_input.outputs["Subdivide"], short_segments.inputs[1])
|
||||
|
||||
vertex_counts = []
|
||||
for axis, other_axis in (("X", "Z"), ("Z", "X")):
|
||||
ratio = nodes.new("ShaderNodeMath")
|
||||
ratio.operation = "DIVIDE"
|
||||
links.new(separate_dimensions.outputs[axis], ratio.inputs[0])
|
||||
links.new(separate_dimensions.outputs[other_axis], ratio.inputs[1])
|
||||
at_least_one = nodes.new("ShaderNodeMath")
|
||||
at_least_one.operation = "MAXIMUM"
|
||||
at_least_one.inputs[1].default_value = 1.0
|
||||
links.new(ratio.outputs[0], at_least_one.inputs[0])
|
||||
scaled_segments = nodes.new("ShaderNodeMath")
|
||||
scaled_segments.operation = "MULTIPLY"
|
||||
links.new(short_segments.outputs[0], scaled_segments.inputs[0])
|
||||
links.new(at_least_one.outputs[0], scaled_segments.inputs[1])
|
||||
rounded_segments = nodes.new("ShaderNodeMath")
|
||||
rounded_segments.operation = "ROUND"
|
||||
links.new(scaled_segments.outputs[0], rounded_segments.inputs[0])
|
||||
vertices = nodes.new("ShaderNodeMath")
|
||||
vertices.operation = "ADD"
|
||||
vertices.inputs[1].default_value = 1.0
|
||||
links.new(rounded_segments.outputs[0], vertices.inputs[0])
|
||||
vertex_counts.append(vertices.outputs[0])
|
||||
|
||||
grid = nodes.new("GeometryNodeMeshGrid")
|
||||
links.new(separate_dimensions.outputs["X"], grid.inputs["Size X"])
|
||||
links.new(separate_dimensions.outputs["Z"], grid.inputs["Size Y"])
|
||||
links.new(vertex_counts[0], grid.inputs["Vertices X"])
|
||||
links.new(vertex_counts[1], grid.inputs["Vertices Y"])
|
||||
position = nodes.new("GeometryNodeInputPosition")
|
||||
separate_position = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(position.outputs["Position"], separate_position.inputs[0])
|
||||
uv_components = []
|
||||
for position_axis, dimension_axis in (("X", "X"), ("Y", "Z")):
|
||||
normalize = nodes.new("ShaderNodeMath")
|
||||
normalize.operation = "DIVIDE"
|
||||
links.new(separate_position.outputs[position_axis], normalize.inputs[0])
|
||||
links.new(separate_dimensions.outputs[dimension_axis], normalize.inputs[1])
|
||||
offset_uv = nodes.new("ShaderNodeMath")
|
||||
offset_uv.operation = "ADD"
|
||||
offset_uv.inputs[1].default_value = 0.5
|
||||
links.new(normalize.outputs[0], offset_uv.inputs[0])
|
||||
uv_components.append(offset_uv.outputs[0])
|
||||
uv_coordinates = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(uv_components[0], uv_coordinates.inputs["X"])
|
||||
links.new(uv_components[1], uv_coordinates.inputs["Y"])
|
||||
|
||||
cube_uv_components = []
|
||||
for position_axis, dimension_axis in (("X", "X"), ("Z", "Z")):
|
||||
normalize = nodes.new("ShaderNodeMath")
|
||||
normalize.operation = "DIVIDE"
|
||||
links.new(separate_position.outputs[position_axis], normalize.inputs[0])
|
||||
links.new(separate_dimensions.outputs[dimension_axis], normalize.inputs[1])
|
||||
offset_uv = nodes.new("ShaderNodeMath")
|
||||
offset_uv.operation = "ADD"
|
||||
offset_uv.inputs[1].default_value = 0.5
|
||||
links.new(normalize.outputs[0], offset_uv.inputs[0])
|
||||
cube_uv_components.append(offset_uv.outputs[0])
|
||||
cube_uv_coordinates = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(cube_uv_components[0], cube_uv_coordinates.inputs["X"])
|
||||
links.new(cube_uv_components[1], cube_uv_coordinates.inputs["Y"])
|
||||
|
||||
store_grid_uv = nodes.new("GeometryNodeStoreNamedAttribute")
|
||||
store_grid_uv.data_type = "FLOAT2"
|
||||
store_grid_uv.domain = "CORNER"
|
||||
store_grid_uv.inputs["Name"].default_value = "UVMap"
|
||||
links.new(grid.outputs["Mesh"], store_grid_uv.inputs["Geometry"])
|
||||
links.new(uv_coordinates.outputs["Vector"], store_grid_uv.inputs["Value"])
|
||||
|
||||
rotate_grid = nodes.new("GeometryNodeTransform")
|
||||
rotate_grid.inputs["Rotation"].default_value = Matrix.Rotation(1.5707963267948966, 4, "X").to_euler()
|
||||
links.new(store_grid_uv.outputs["Geometry"], rotate_grid.inputs["Geometry"])
|
||||
|
||||
center_sum = nodes.new("ShaderNodeVectorMath")
|
||||
center_sum.operation = "ADD"
|
||||
links.new(bounds.outputs["Min"], center_sum.inputs[0])
|
||||
links.new(bounds.outputs["Max"], center_sum.inputs[1])
|
||||
center = nodes.new("ShaderNodeVectorMath")
|
||||
center.operation = "SCALE"
|
||||
center.inputs[3].default_value = 0.5
|
||||
links.new(center_sum.outputs["Vector"], center.inputs[0])
|
||||
place_grid = nodes.new("GeometryNodeTransform")
|
||||
links.new(rotate_grid.outputs["Geometry"], place_grid.inputs["Geometry"])
|
||||
links.new(center.outputs["Vector"], place_grid.inputs["Translation"])
|
||||
|
||||
cell_sizes = []
|
||||
for index, axis in enumerate(("X", "Z")):
|
||||
segments = nodes.new("ShaderNodeMath")
|
||||
segments.operation = "SUBTRACT"
|
||||
segments.inputs[1].default_value = 1.0
|
||||
links.new(vertex_counts[index], segments.inputs[0])
|
||||
cell_size = nodes.new("ShaderNodeMath")
|
||||
cell_size.operation = "DIVIDE"
|
||||
links.new(separate_dimensions.outputs[axis], cell_size.inputs[0])
|
||||
links.new(segments.outputs[0], cell_size.inputs[1])
|
||||
cell_sizes.append(cell_size.outputs[0])
|
||||
cell_area = nodes.new("ShaderNodeMath")
|
||||
cell_area.operation = "MULTIPLY"
|
||||
links.new(cell_sizes[0], cell_area.inputs[0])
|
||||
links.new(cell_sizes[1], cell_area.inputs[1])
|
||||
target_step = nodes.new("ShaderNodeMath")
|
||||
target_step.operation = "SQRT"
|
||||
links.new(cell_area.outputs[0], target_step.inputs[0])
|
||||
depth_ratio = nodes.new("ShaderNodeMath")
|
||||
depth_ratio.operation = "DIVIDE"
|
||||
links.new(thickness_input.outputs["Thickness"], depth_ratio.inputs[0])
|
||||
links.new(target_step.outputs[0], depth_ratio.inputs[1])
|
||||
rounded_depth = nodes.new("ShaderNodeMath")
|
||||
rounded_depth.operation = "ROUND"
|
||||
links.new(depth_ratio.outputs[0], rounded_depth.inputs[0])
|
||||
depth_segments = nodes.new("ShaderNodeMath")
|
||||
depth_segments.operation = "MAXIMUM"
|
||||
depth_segments.inputs[1].default_value = 3.0
|
||||
links.new(rounded_depth.outputs[0], depth_segments.inputs[0])
|
||||
depth_vertices = nodes.new("ShaderNodeMath")
|
||||
depth_vertices.operation = "ADD"
|
||||
depth_vertices.inputs[1].default_value = 1.0
|
||||
links.new(depth_segments.outputs[0], depth_vertices.inputs[0])
|
||||
|
||||
cube_size = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(separate_dimensions.outputs["X"], cube_size.inputs["X"])
|
||||
links.new(thickness_input.outputs["Thickness"], cube_size.inputs["Y"])
|
||||
links.new(separate_dimensions.outputs["Z"], cube_size.inputs["Z"])
|
||||
cube = nodes.new("GeometryNodeMeshCube")
|
||||
links.new(cube_size.outputs["Vector"], cube.inputs["Size"])
|
||||
links.new(vertex_counts[0], cube.inputs["Vertices X"])
|
||||
links.new(depth_vertices.outputs[0], cube.inputs["Vertices Y"])
|
||||
links.new(vertex_counts[1], cube.inputs["Vertices Z"])
|
||||
store_cube_uv = nodes.new("GeometryNodeStoreNamedAttribute")
|
||||
store_cube_uv.data_type = "FLOAT2"
|
||||
store_cube_uv.domain = "CORNER"
|
||||
store_cube_uv.inputs["Name"].default_value = "UVMap"
|
||||
links.new(cube.outputs["Mesh"], store_cube_uv.inputs["Geometry"])
|
||||
links.new(cube_uv_coordinates.outputs["Vector"], store_cube_uv.inputs["Value"])
|
||||
place_cube = nodes.new("GeometryNodeTransform")
|
||||
links.new(store_cube_uv.outputs["Geometry"], place_cube.inputs["Geometry"])
|
||||
links.new(center.outputs["Vector"], place_cube.inputs["Translation"])
|
||||
has_thickness = nodes.new("FunctionNodeCompare")
|
||||
has_thickness.data_type = "FLOAT"
|
||||
has_thickness.operation = "GREATER_THAN"
|
||||
links.new(thickness_input.outputs["Thickness"], has_thickness.inputs[0])
|
||||
geometry_switch = nodes.new("GeometryNodeSwitch")
|
||||
geometry_switch.input_type = "GEOMETRY"
|
||||
links.new(has_thickness.outputs["Result"], geometry_switch.inputs["Switch"])
|
||||
links.new(place_grid.outputs["Geometry"], geometry_switch.inputs["False"])
|
||||
links.new(place_cube.outputs["Geometry"], geometry_switch.inputs["True"])
|
||||
|
||||
geometry = inherit_material_slots(group, geometry_switch.outputs["Output"], geometry_input.outputs["Geometry"])
|
||||
links.new(geometry, output.inputs["Geometry"])
|
||||
|
||||
group.color_tag = "GEOMETRY"
|
||||
group.description = (
|
||||
"Build an aspect-adaptive image plane and add optional centered thickness."
|
||||
)
|
||||
group.use_fake_user = True
|
||||
build_dimension_fields(group)
|
||||
prepare_node_group(group)
|
||||
return group
|
||||
@@ -1,234 +0,0 @@
|
||||
"""Build the Relief Plane Geometry Nodes group."""
|
||||
|
||||
import bpy
|
||||
|
||||
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
from ..common.nodes import group_input as _group_input, interface_socket as _interface_socket
|
||||
from ..common.nodes import store_float_attribute
|
||||
from ..common.nodes import compare_node, prepare_node_group, vector_input
|
||||
from ..common.reference_plane import reference_plane_depth
|
||||
from ..common.boundary_smoothing import boundary_influence
|
||||
|
||||
|
||||
RELIEF_PLANE_NODE_GROUP_NAME = "O Image Relief Plane"
|
||||
|
||||
|
||||
def _math(nodes, operation):
|
||||
node = nodes.new("ShaderNodeMath")
|
||||
node.operation = operation
|
||||
return node
|
||||
|
||||
|
||||
def build_image_relief_plane_group(image_plane_group):
|
||||
group = bpy.data.node_groups.get(RELIEF_PLANE_NODE_GROUP_NAME)
|
||||
if group is not None:
|
||||
return group
|
||||
group = bpy.data.node_groups.new(
|
||||
RELIEF_PLANE_NODE_GROUP_NAME,
|
||||
"GeometryNodeTree",
|
||||
)
|
||||
group.is_modifier = True
|
||||
geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
|
||||
geometry.structure_type = "SINGLE"
|
||||
subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
|
||||
subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time."
|
||||
subdivide.default_value = 6
|
||||
subdivide.min_value = 0
|
||||
subdivide.max_value = 10
|
||||
thickness = _interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat")
|
||||
thickness.default_value = 0.0
|
||||
thickness.min_value = 0.0
|
||||
thickness.subtype = "DISTANCE"
|
||||
thickness.description = "Set the thickness of the flat base beneath the relief."
|
||||
vector_input(group, "Depth Direction", (0, 0, 1), subtype="DIRECTION").description = "Set the direction used to orient the reference depth plane."
|
||||
depth_offset = _interface_socket(group, "Depth Offset", "INPUT", "NodeSocketFloat")
|
||||
depth_offset.default_value = 0.0
|
||||
depth_offset.min_value = -10.0
|
||||
depth_offset.max_value = 10.0
|
||||
depth_offset.subtype = "DISTANCE"
|
||||
depth_offset.description = "Correct the reference depth before direction and scale are applied."
|
||||
depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat")
|
||||
depth_scale.default_value = 0.5
|
||||
depth_scale.min_value = 0.0
|
||||
depth_scale.max_value = 2.0
|
||||
valid_only = _interface_socket(group, "Depth Mask", "INPUT", "NodeSocketBool")
|
||||
valid_only.description = "Treat depth-mask-invalid areas as zero depth instead of deleting them."
|
||||
valid_only.default_value = True
|
||||
options = group.interface.new_panel(name="Options", default_closed=True)
|
||||
validity_threshold = _interface_socket(group, "Mask Threshold", "INPUT", "NodeSocketFloat", options)
|
||||
validity_threshold.description = "Set the minimum depth mask value treated as valid."
|
||||
validity_threshold.default_value = 0.9
|
||||
validity_threshold.min_value, validity_threshold.max_value = 0.0, 0.99
|
||||
validity_threshold.subtype = "FACTOR"
|
||||
base_plane_depth = _interface_socket(
|
||||
group,
|
||||
"Reference Depth",
|
||||
"INPUT",
|
||||
"NodeSocketFloat",
|
||||
parent=options,
|
||||
)
|
||||
base_plane_depth.default_value = 1.0
|
||||
base_plane_depth.min_value = 0.0
|
||||
base_plane_depth.subtype = "DISTANCE"
|
||||
data = group.interface.new_panel(name="Data", default_closed=True)
|
||||
uniform_scale = _interface_socket(
|
||||
group,
|
||||
"Uniform Scale",
|
||||
"INPUT",
|
||||
"NodeSocketFloat",
|
||||
parent=data,
|
||||
)
|
||||
uniform_scale.default_value = 1.0
|
||||
uniform_scale.min_value = 0.0
|
||||
uniform_scale.hide_in_modifier = True
|
||||
depth_image = _interface_socket(
|
||||
group,
|
||||
"Depth Image",
|
||||
"INPUT",
|
||||
"NodeSocketImage",
|
||||
parent=data,
|
||||
)
|
||||
depth_image.hide_in_modifier = True
|
||||
_interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
|
||||
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
geometry_input = _group_input(
|
||||
nodes,
|
||||
{"Geometry", "Subdivide"},
|
||||
)
|
||||
shape_input = _group_input(
|
||||
nodes,
|
||||
{"Thickness", "Depth Direction", "Depth Offset", "Depth Scale"},
|
||||
)
|
||||
image_input = _group_input(
|
||||
nodes,
|
||||
{"Depth Image", "Uniform Scale", "Depth Mask", "Mask Threshold"},
|
||||
)
|
||||
group_output = nodes.new("NodeGroupOutput")
|
||||
image_plane = nodes.new("GeometryNodeGroup")
|
||||
image_plane.node_tree = image_plane_group
|
||||
links.new(geometry_input.outputs["Geometry"], image_plane.inputs["Geometry"])
|
||||
links.new(geometry_input.outputs["Subdivide"], image_plane.inputs["Subdivide"])
|
||||
links.new(shape_input.outputs["Thickness"], image_plane.inputs["Thickness"])
|
||||
|
||||
positive_half = _math(nodes, "MULTIPLY")
|
||||
positive_half.inputs[1].default_value = 0.5
|
||||
links.new(shape_input.outputs["Thickness"], positive_half.inputs[0])
|
||||
translation = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(positive_half.outputs[0], translation.inputs["Y"])
|
||||
place_block = nodes.new("GeometryNodeTransform")
|
||||
links.new(image_plane.outputs["Geometry"], place_block.inputs["Geometry"])
|
||||
links.new(translation.outputs["Vector"], place_block.inputs["Translation"])
|
||||
|
||||
uv = nodes.new("GeometryNodeInputNamedAttribute")
|
||||
uv.data_type = "FLOAT_VECTOR"
|
||||
uv.inputs["Name"].default_value = "UVMap"
|
||||
texture = nodes.new("GeometryNodeImageTexture")
|
||||
texture.interpolation = "Linear"
|
||||
texture.extension = "EXTEND"
|
||||
links.new(image_input.outputs["Depth Image"], texture.inputs["Image"])
|
||||
links.new(uv.outputs["Attribute"], texture.inputs["Vector"])
|
||||
depth_components = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(texture.outputs["Color"], depth_components.inputs["Vector"])
|
||||
invalid = nodes.new("FunctionNodeCompare")
|
||||
invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN"
|
||||
links.new(texture.outputs["Alpha"], invalid.inputs["A"])
|
||||
links.new(image_input.outputs["Mask Threshold"], invalid.inputs["B"])
|
||||
mask_enabled = image_input.outputs["Depth Mask"]
|
||||
valid_mask = nodes.new("GeometryNodeSwitch")
|
||||
valid_mask.input_type = "FLOAT"
|
||||
valid_mask.inputs["False"].default_value = 1.0
|
||||
links.new(invalid.outputs["Result"], valid_mask.inputs["Switch"])
|
||||
mask_switch = nodes.new("GeometryNodeSwitch")
|
||||
mask_switch.input_type = "FLOAT"
|
||||
mask_switch.inputs["False"].default_value = 1.0
|
||||
links.new(mask_enabled, mask_switch.inputs["Switch"])
|
||||
links.new(valid_mask.outputs[0], mask_switch.inputs["True"])
|
||||
metric_depth = _math(nodes, "MULTIPLY")
|
||||
links.new(depth_components.outputs["Z"], metric_depth.inputs[0])
|
||||
links.new(image_input.outputs["Uniform Scale"], metric_depth.inputs[1])
|
||||
relative_depth = _math(nodes, "SUBTRACT")
|
||||
reference_input = _group_input(nodes, {"Reference Depth"})
|
||||
corrected_reference = _math(nodes, "ADD")
|
||||
links.new(reference_input.outputs["Reference Depth"], corrected_reference.inputs[0])
|
||||
links.new(shape_input.outputs["Depth Offset"], corrected_reference.inputs[1])
|
||||
plane_depth = reference_plane_depth(
|
||||
group, shape_input.outputs["Depth Direction"], corrected_reference.outputs[0],
|
||||
)
|
||||
links.new(metric_depth.outputs[0], relative_depth.inputs[0])
|
||||
links.new(plane_depth, relative_depth.inputs[1])
|
||||
full_offset = _math(nodes, "MULTIPLY")
|
||||
links.new(relative_depth.outputs[0], full_offset.inputs[0])
|
||||
links.new(shape_input.outputs["Depth Scale"], full_offset.inputs[1])
|
||||
masked_full_offset = _math(nodes, "MULTIPLY")
|
||||
links.new(full_offset.outputs[0], masked_full_offset.inputs[0])
|
||||
links.new(mask_switch.outputs[0], masked_full_offset.inputs[1])
|
||||
|
||||
has_thickness = compare_node(group, "GREATER_THAN", shape_input.outputs["Thickness"], 1e-6)
|
||||
safe_offset = _math(nodes, "MINIMUM")
|
||||
safe_offset.inputs[1].default_value = 0.0
|
||||
links.new(masked_full_offset.outputs[0], safe_offset.inputs[0])
|
||||
edge_influence = boundary_influence(group, protected=False)
|
||||
edge_clamped = nodes.new("ShaderNodeClamp")
|
||||
edge_clamped.inputs["Min"].default_value = 0.0
|
||||
edge_clamped.inputs["Max"].default_value = 1.0
|
||||
links.new(edge_influence, edge_clamped.inputs["Value"])
|
||||
geometry_weight = _math(nodes, "SUBTRACT")
|
||||
geometry_weight.inputs[0].default_value = 1.0
|
||||
links.new(edge_clamped.outputs[0], geometry_weight.inputs[1])
|
||||
smooth_safe_offset = _math(nodes, "MULTIPLY")
|
||||
links.new(safe_offset.outputs[0], smooth_safe_offset.inputs[0])
|
||||
links.new(geometry_weight.outputs[0], smooth_safe_offset.inputs[1])
|
||||
|
||||
position = nodes.new("GeometryNodeInputPosition")
|
||||
position_components = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(position.outputs["Position"], position_components.inputs["Vector"])
|
||||
layer_ratio = _math(nodes, "DIVIDE")
|
||||
links.new(position_components.outputs["Y"], layer_ratio.inputs[0])
|
||||
links.new(shape_input.outputs["Thickness"], layer_ratio.inputs[1])
|
||||
nonnegative_ratio = _math(nodes, "MAXIMUM")
|
||||
nonnegative_ratio.inputs[1].default_value = 0.0
|
||||
links.new(layer_ratio.outputs[0], nonnegative_ratio.inputs[0])
|
||||
clamped_ratio = _math(nodes, "MINIMUM")
|
||||
clamped_ratio.inputs[1].default_value = 1.0
|
||||
links.new(nonnegative_ratio.outputs[0], clamped_ratio.inputs[0])
|
||||
one_minus_ratio = _math(nodes, "SUBTRACT")
|
||||
one_minus_ratio.inputs[0].default_value = 1.0
|
||||
links.new(clamped_ratio.outputs[0], one_minus_ratio.inputs[1])
|
||||
layer_weight = nodes.new("GeometryNodeSwitch")
|
||||
layer_weight.input_type = "FLOAT"
|
||||
layer_weight.inputs["False"].default_value = 1.0
|
||||
links.new(has_thickness, layer_weight.inputs["Switch"])
|
||||
links.new(one_minus_ratio.outputs[0], layer_weight.inputs["True"])
|
||||
|
||||
weighted_offset = _math(nodes, "MULTIPLY")
|
||||
links.new(smooth_safe_offset.outputs[0], weighted_offset.inputs[0])
|
||||
links.new(layer_weight.outputs["Output"], weighted_offset.inputs[1])
|
||||
displacement = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(weighted_offset.outputs[0], displacement.inputs["Y"])
|
||||
set_position = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(place_block.outputs["Geometry"], set_position.inputs["Geometry"])
|
||||
links.new(displacement.outputs["Vector"], set_position.inputs["Offset"])
|
||||
shade_smooth = nodes.new("GeometryNodeSetShadeSmooth")
|
||||
shade_smooth.domain = "FACE"
|
||||
shade_smooth.inputs["Shade Smooth"].default_value = True
|
||||
links.new(set_position.outputs["Geometry"], shade_smooth.inputs["Geometry"])
|
||||
normal_reduction = _math(nodes, "SUBTRACT")
|
||||
normal_reduction.inputs[0].default_value = 1.0
|
||||
scaled_depth = _math(nodes, "MULTIPLY")
|
||||
links.new(shape_input.outputs["Depth Scale"], scaled_depth.inputs[0])
|
||||
links.new(geometry_weight.outputs[0], scaled_depth.inputs[1])
|
||||
links.new(scaled_depth.outputs[0], normal_reduction.inputs[1])
|
||||
output_geometry = store_float_attribute(
|
||||
group, shade_smooth.outputs["Geometry"], NORMAL_REDUCTION_ATTRIBUTE_NAME,
|
||||
normal_reduction.outputs[0],
|
||||
)
|
||||
links.new(output_geometry, group_output.inputs["Geometry"])
|
||||
group.color_tag = "GEOMETRY"
|
||||
group.description = (
|
||||
"Build a fixed-base image relief."
|
||||
)
|
||||
group.use_fake_user = True
|
||||
prepare_node_group(group)
|
||||
return group
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Build the Shadeless shader node group."""
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
SHADELESS_GROUP_NAME = "O Shadeless"
|
||||
|
||||
|
||||
def build_shadeless_group():
|
||||
group = bpy.data.node_groups.new(SHADELESS_GROUP_NAME, "ShaderNodeTree")
|
||||
group.interface.new_socket(
|
||||
name="Shader", in_out="OUTPUT", socket_type="NodeSocketShader"
|
||||
)
|
||||
group.interface.new_socket(
|
||||
name="Color", in_out="INPUT", socket_type="NodeSocketColor"
|
||||
)
|
||||
alpha = group.interface.new_socket(name="Alpha", in_out="INPUT", socket_type="NodeSocketFloat")
|
||||
alpha.default_value, alpha.min_value, alpha.max_value = 1.0, 0.0, 1.0
|
||||
alpha.subtype = "FACTOR"
|
||||
strength = group.interface.new_socket(
|
||||
name="Strength", in_out="INPUT", socket_type="NodeSocketFloat"
|
||||
)
|
||||
strength.description = "Set the emission strength for camera rays. Zero renders the surface black."
|
||||
strength.default_value, strength.min_value = 1.0, 0.0
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
group_input = nodes.new("NodeGroupInput")
|
||||
group_output = nodes.new("NodeGroupOutput")
|
||||
|
||||
diffuse = nodes.new("ShaderNodeBsdfDiffuse")
|
||||
emission = nodes.new("ShaderNodeEmission")
|
||||
links.new(group_input.outputs["Color"], diffuse.inputs["Color"])
|
||||
links.new(group_input.outputs["Color"], emission.inputs["Color"])
|
||||
links.new(group_input.outputs["Strength"], emission.inputs["Strength"])
|
||||
|
||||
light_path = nodes.new("ShaderNodeLightPath")
|
||||
bounce_count = nodes.new("ShaderNodeMath")
|
||||
bounce_count.operation = "SUBTRACT"
|
||||
links.new(light_path.outputs["Ray Depth"], bounce_count.inputs[0])
|
||||
links.new(light_path.outputs["Transmission Depth"], bounce_count.inputs[1])
|
||||
refracted = nodes.new("ShaderNodeMath")
|
||||
refracted.operation = "SUBTRACT"
|
||||
refracted.inputs[0].default_value = 1.0
|
||||
links.new(bounce_count.outputs[0], refracted.inputs[1])
|
||||
|
||||
reflection_limit = nodes.new("ShaderNodeMath")
|
||||
reflection_limit.operation = "SUBTRACT"
|
||||
reflection_limit.inputs[0].default_value = 2.0
|
||||
links.new(light_path.outputs["Ray Depth"], reflection_limit.inputs[1])
|
||||
camera_reflected = nodes.new("ShaderNodeMath")
|
||||
camera_reflected.operation = "MULTIPLY"
|
||||
links.new(reflection_limit.outputs[0], camera_reflected.inputs[0])
|
||||
links.new(light_path.outputs["Is Glossy Ray"], camera_reflected.inputs[1])
|
||||
shadow_or_reflect = nodes.new("ShaderNodeMath")
|
||||
shadow_or_reflect.operation = "MAXIMUM"
|
||||
links.new(camera_reflected.outputs[0], shadow_or_reflect.inputs[0])
|
||||
links.new(light_path.outputs["Is Shadow Ray"], shadow_or_reflect.inputs[1])
|
||||
shader_factor = nodes.new("ShaderNodeMath")
|
||||
shader_factor.operation = "MAXIMUM"
|
||||
links.new(shadow_or_reflect.outputs[0], shader_factor.inputs[0])
|
||||
links.new(refracted.outputs[0], shader_factor.inputs[1])
|
||||
|
||||
mix = nodes.new("ShaderNodeMixShader")
|
||||
links.new(shader_factor.outputs[0], mix.inputs["Fac"])
|
||||
links.new(diffuse.outputs["BSDF"], mix.inputs[1])
|
||||
links.new(emission.outputs["Emission"], mix.inputs[2])
|
||||
transparent = nodes.new("ShaderNodeBsdfTransparent")
|
||||
alpha_mix = nodes.new("ShaderNodeMixShader")
|
||||
links.new(group_input.outputs["Alpha"], alpha_mix.inputs["Fac"])
|
||||
links.new(transparent.outputs["BSDF"], alpha_mix.inputs[1])
|
||||
links.new(mix.outputs["Shader"], alpha_mix.inputs[2])
|
||||
links.new(alpha_mix.outputs["Shader"], group_output.inputs["Shader"])
|
||||
group.color_tag = "SHADER"
|
||||
group.description = (
|
||||
"Display color without scene lighting while preserving shadows "
|
||||
"and reflections."
|
||||
)
|
||||
group.use_fake_user = True
|
||||
return group
|
||||
@@ -35,6 +35,15 @@ PLATFORM_CONFIGS = {
|
||||
PLATFORMS = tuple(PLATFORM_CONFIGS)
|
||||
|
||||
|
||||
def require_node_asset():
|
||||
"""Fail before packaging when the generated node asset is absent."""
|
||||
asset_path = SOURCE_DIR / "assets" / "O_AnyImage.blend"
|
||||
if not asset_path.is_file():
|
||||
raise RuntimeError(
|
||||
"Node asset is missing; run: uv run --group blender node-group build"
|
||||
)
|
||||
|
||||
|
||||
def project_dependencies():
|
||||
"""Return Extension wheel requirements declared by the project."""
|
||||
project = tomllib.loads(
|
||||
@@ -171,6 +180,7 @@ def manifest_for_platform(platform, wheels):
|
||||
|
||||
|
||||
def _build_extension(platform, wheels):
|
||||
require_node_asset()
|
||||
wheels = extension_wheels(wheels)
|
||||
manifest_text = manifest_for_platform(platform, wheels)
|
||||
manifest = tomllib.loads(manifest_text)
|
||||
|
||||
Reference in New Issue
Block a user