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:
2026-09-19 21:23:19 +08:00
parent 2b3c0194a6
commit 9debab5c0c
73 changed files with 500 additions and 217 deletions

View File

@@ -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):

View File

@@ -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"

View File

@@ -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__":

View File

@@ -1 +0,0 @@

View File

@@ -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)

View File

@@ -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"])

View File

@@ -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)

View File

@@ -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_*")

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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]

View File

@@ -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]

View File

@@ -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"])

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -1 +0,0 @@

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)