feat: Implement weight cutout boundary smoothing for image depth cutout

- Added a new proposal for boundary smoothing in the weight cutout process, differentiating between Split edges and Outline smoothing requirements.
- Introduced a new capability `cutout-boundary-smoothing` to control smoothing strength and default values.
- Updated specifications to define the behavior of boundary-specific smoothing strength and default settings.
- Created tasks to verify the implementation of boundary behavior and asset updates.
- Developed comprehensive tests for depth symmetry and normal scaling to ensure expected behavior and performance.
- Introduced new attributes for normal mapping to facilitate persistent face attributes sharing between depth symmetry and image materials.
- Built a new node group for O Image Depth Symmetry, integrating the new smoothing capabilities and ensuring compatibility with existing workflows.
This commit is contained in:
2026-09-14 12:44:20 +08:00
parent 09778c4b48
commit dc000e50b1
53 changed files with 1350 additions and 364 deletions

View File

@@ -1,4 +1,4 @@
"""Materialize the joint calculation and connection plan in geometry node trees."""
"""Materialize the joint calculation and connection plan in node trees."""
from .branches import calculation_blocks
from .dimensions import estimate_node_dimensions, visible_socket

View File

@@ -1,4 +1,4 @@
"""Identify connected field calculations by their nearest geometry consumers."""
"""Identify connected calculations by their nearest geometry or shader consumers."""
from collections import defaultdict
from graphlib import TopologicalSorter
@@ -17,7 +17,8 @@ def calculation_blocks(nodes, edges):
i
for i, n in enumerate(nodes)
if n.bl_idname == "NodeGroupOutput"
or any(s.type == "GEOMETRY" and s.is_linked for s in n.outputs)
or n.bl_idname in {"ShaderNodeNormalMap", "ShaderNodeBump", "ShaderNodeDisplacement"}
or any(s.type in {"GEOMETRY", "SHADER"} and s.is_linked for s in n.outputs)
or getattr(n, "paired_output", None) is not None
}
inputs = {i for i, n in enumerate(nodes) if n.bl_idname == "NodeGroupInput"}

View File

@@ -74,6 +74,19 @@
"ShaderNodeMix": 3,
"ShaderNodeSeparateXYZ": 0,
"ShaderNodeValue": 1,
"ShaderNodeVectorMath": 1
"ShaderNodeVectorMath": 1,
"FunctionNodeAlignEulerToVector": 2,
"ShaderNodeVectorRotate": 2,
"ShaderNodeAttribute": 2,
"ShaderNodeFloatCurve": 12,
"ShaderNodeBump": 2,
"ShaderNodeNormalMap": 2,
"ShaderNodeDisplacement": 1,
"ShaderNodeBsdfDiffuse": 1,
"ShaderNodeBsdfTransparent": 0,
"ShaderNodeEmission": 0,
"ShaderNodeLightPath": 0,
"ShaderNodeMixShader": 0,
"ShaderNodeGroup": 1
}
}

View File

@@ -29,7 +29,7 @@ def estimate_node_dimensions(node):
inputs = [s for s in node.inputs if visible_socket(s)]
outputs = [s for s in node.outputs if visible_socket(s)]
width = RULES["wide_width"] if (
"Attribute" in node.bl_idname or node.bl_idname == "GeometryNodeGroup"
"Attribute" in node.bl_idname or node.bl_idname in {"GeometryNodeGroup", "ShaderNodeGroup", "ShaderNodeFloatCurve"}
) else RULES["width"]
if node.hide:
height = max(RULES["collapsed_height"], 16 + 12 * max(len(inputs), len(outputs)))
@@ -44,7 +44,7 @@ def estimate_node_dimensions(node):
controls = RULES["controls"][node.bl_idname]
for collection in ("repeat_items", "state_items", "input_items", "main_items", "generation_items", "enum_items"):
controls += len(getattr(node, collection, ()))
if node.bl_idname == "GeometryNodeGroup" and node.node_tree:
if node.bl_idname in {"GeometryNodeGroup", "ShaderNodeGroup"} and node.node_tree:
controls += sum(item.item_type == "PANEL" for item in node.node_tree.interface.items_tree)
cursor = RULES["header"] + RULES["padding"]
sockets = {}

View File

@@ -2,6 +2,8 @@
from collections import defaultdict
from .branches import calculation_blocks
SHARED_GEOMETRY_USES = 3
@@ -84,5 +86,9 @@ def geometry_paths(node_group):
def node_roles(node_group):
"""Classify by contribution to the result, not by available socket types."""
if node_group.bl_idname == "ShaderNodeTree":
nodes = list(node_group.nodes)
_, core = calculation_blocks(nodes, list(node_group.links))
return {node: "shader" if i in core else "field" for i, node in enumerate(nodes)}
core, _ = geometry_paths(node_group)
return {node: "geometry" if node in core else "field" for node in node_group.nodes}

View File

@@ -90,7 +90,7 @@ def plan_layout(nodes, edges, size, blocks, core):
continue
sy = items[u]["outputs"][e][1] if a in members else 16.0
ty = items[v]["inputs"][e][1] if b in members else 16.0
weight = 3.0 if edge_types[e] == "GEOMETRY" else 1.0
weight = 3.0 if edge_types[e] in {"GEOMETRY", "SHADER"} else 1.0
links.append((e, u, v, sy, ty, weight))
parents = {k: set() for k in items}
children = {k: set() for k in items}
@@ -360,7 +360,7 @@ def plan_layout(nodes, edges, size, blocks, core):
u, v = node_atom[a], node_atom[b]
sy = seed[a][1] + size[a].sockets[edges[e].from_socket] - atoms[u]["preferred"]
ty = seed[b][1] + size[b].sockets[edges[e].to_socket] - atoms[v]["preferred"]
weight = 3.0 if edge_types[e] == "GEOMETRY" else 1.0
weight = 3.0 if edge_types[e] in {"GEOMETRY", "SHADER"} else 1.0
if e in edge_bus:
weight *= 0.15
sequence = [(u, sy), (v, ty)] if e not in edge_bus else [(u, sy), (edge_bus[e], 12.0), (v, ty)]

View File

@@ -9,7 +9,7 @@ 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_depth_balloon import build_image_depth_balloon_group
from .groups.image_depth_symmetry import build_image_depth_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
@@ -29,7 +29,7 @@ def build_node_groups():
build_image_relief_plane_group(image_plane),
build_image_cutout_group(),
build_image_depth_cutout_group(),
build_image_depth_balloon_group(),
build_image_depth_symmetry_group(),
build_image_layer_group(),
build_image_depth_layer_group(),
build_shadeless_group(),
@@ -40,8 +40,7 @@ def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
for node_group in build_node_groups():
if node_group.bl_idname == "GeometryNodeTree":
arrange_nodes(node_group)
arrange_nodes(node_group)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(OUTPUT_PATH), check_existing=False)

View File

@@ -9,7 +9,7 @@ ASSET_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyIma
GEOMETRY_NAMES = {
"O Image Depth Panorama",
"O Image Plane", "O Image Depth Plane", "O Image Relief Plane",
"O Image Cutout", "O Image Depth Cutout", "O Image Depth Balloon",
"O Image Cutout", "O Image Depth Cutout", "O Image Depth Symmetry",
}
SHADER_NAMES = {"O Image Layer", "O Image Depth Layer", "O Shadeless"}
@@ -60,6 +60,8 @@ def check_asset_library(asset_path):
values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0,
"Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0,
"Depth Split": 0.0}
if node_group.name == "O Image Depth Symmetry":
values["Reference Depth"] = 2.5
for item in node_group.interface.items_tree:
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name in values:
modifier[item.identifier] = values[item.name]

View File

@@ -5,10 +5,12 @@ from .depth_surface import _math
from .smoothing import edge_boundary_field, expand_smooth
def boundary_influence(group, protected=False):
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
boundary = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
if boundary is None:
boundary = edge_boundary_field(nodes, links)
boundary = evaluate_field(group, boundary, "FLOAT", "POINT")
unprotected = _math(group, "SUBTRACT", 1, protected)
cut = _math(group, "MULTIPLY", boundary, unprotected)
blur = nodes.new("GeometryNodeBlurAttribute")
@@ -75,9 +77,20 @@ def reconstruct_camera_depth(group, camera, depth, smoothed):
return choose.outputs[0]
def smooth_cut_boundary(group, geometry, original_boundary=False):
"""Blend O Smooth's pinned-boundary result through a two-ring edge mask."""
influence = boundary_influence(group, original_boundary)
def cut_boundary_influence(group, split_boundary):
"""Give split edges full influence and the original outline one tenth."""
nodes, links = group.nodes, group.links
boundary = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
split = _math(group, "MULTIPLY", boundary, split_boundary)
outline = _math(group, "MAXIMUM", _math(group, "SUBTRACT", boundary, split), 0)
split_influence = boundary_influence(group, boundary=split)
outline_influence = _math(group, "MULTIPLY", boundary_influence(group, boundary=outline), 0.1)
return _math(group, "MAXIMUM", split_influence, outline_influence)
def smooth_cut_boundary(group, geometry, split_boundary=False):
"""Blend O Smooth through split and outline masks with distinct weights."""
influence = cut_boundary_influence(group, split_boundary)
# The support band depends on topology, which stays unchanged in the loop.
return expand_smooth(
group, geometry, iterations_name="Boundary Smooth", weight_name="Smooth Weight",

View File

@@ -327,7 +327,7 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *,
links.new(rim, back_parts.inputs["Geometry"])
rear = back_parts.outputs["Geometry"]
if back_mask is not None:
rear = store_float_attribute(group, rear, back_mask, 0.0, domain="FACE")
rear = store_float_attribute(group, rear, back_mask, 1.0, domain="FACE")
for value in (
saved,
rear,

View File

@@ -0,0 +1,78 @@
"""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.operators.cutout_tool.shape import CUTOUT_Z_TO_X
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])
rotate = nodes.new('ShaderNodeVectorRotate')
rotate.rotation_type = 'EULER_XYZ'
rotate.invert = True
links.new(decode.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 Z component on the back.
difference = math('MULTIPLY', separate.outputs['Z'], -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['Z'], correction.inputs[2])
combine = nodes.new('ShaderNodeCombineXYZ')
links.new(separate.outputs['X'], combine.inputs['X'])
links.new(separate.outputs['Y'], combine.inputs['Y'])
links.new(correction.outputs[0], combine.inputs['Z'])
axis_rotation = nodes.new('ShaderNodeVectorMath')
axis_rotation.operation = 'SCALE'
axis_rotation.inputs[0].default_value = Matrix(CUTOUT_Z_TO_X).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

@@ -5,22 +5,18 @@ BUMP_BASE_DISTANCE = 0.001
def build_material_nodes(group):
nodes = group.nodes
group_input = nodes.new("NodeGroupInput")
group_input.location = (-500.0, 100.0)
group_output = nodes.new("NodeGroupOutput")
group_output.location = (300.0, 100.0)
roughness_curve = nodes.new("ShaderNodeFloatCurve")
roughness_curve.mapping.initialize()
curve_points = roughness_curve.mapping.curves[0].points
curve_points[0].location = (0.0, 0.5)
curve_points[-1].location = (1.0, 0.1)
roughness_curve.mapping.update()
roughness_curve.location = (-250.0, 350.0)
bump = nodes.new("ShaderNodeBump")
bump.location = (100.0, 0.0)
return group_input, group_output, roughness_curve, bump
def connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha, bump_location):
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"])
@@ -33,7 +29,6 @@ def connect_material_outputs(group, group_input, group_output, roughness_curve,
bump_distance = nodes.new("ShaderNodeMath")
bump_distance.operation = "MULTIPLY"
bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE
bump_distance.location = bump_location
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,7 +1,7 @@
"""Build O Image Cutout."""
import bpy
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group
from ..common.smoothing import expand_smooth
from ..common.cutout import _extrude_layers, _island_cleanup, _offset_geometry_z, _remove_zero_thickness, _set_layer_z, _shade_output
@@ -198,7 +198,10 @@ def build_image_cutout_group():
mode.inputs['Shell'].default_value = 1
group.links.new(inflated.outputs[0], mode.inputs['Balloon'])
scale = mode.outputs[0]
geometry = store_float_attribute(group, geometry, NORMAL_SCALE_ATTRIBUTE_NAME, scale)
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])
orient = group.nodes.new('GeometryNodeTransform')
orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
group.links.new(geometry, orient.inputs['Geometry'])

View File

@@ -1,157 +0,0 @@
"""Build O Image Depth Balloon."""
import bpy
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME
from ..common.nodes import (
interface_socket, group_input, math_node, float_input, bool_input, vector_input,
menu_switch, read_float_attribute, store_float_attribute, prepare_node_group,
)
from ..common.smoothing import expand_smooth
from ..common.reference_plane import reference_plane_depth
from ..common.cutout import _extrude_layers, _island_cleanup, _position_field, _profile_field, _remove_zero_thickness, _set_layer_z, _shade_output
NODE_GROUP_NAME = "O Image Depth Balloon"
def _set_geometry_z(group, geometry, z_value):
nodes = group.nodes
links = group.links
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(separate.outputs["Y"], combine.inputs["Y"])
links.new(z_value, combine.inputs["Z"])
set_position = nodes.new("GeometryNodeSetPosition")
links.new(geometry, set_position.inputs["Geometry"])
links.new(combine.outputs["Vector"], set_position.inputs["Position"])
return set_position.outputs["Geometry"]
def _build_depth_balloon(
group,
geometry,
thickness,
depth_scale,
double_sided,
uniform_scale,
depth_direction,
depth_image,
depth_reference,
):
nodes = group.nodes
links = group.links
profile, profile_factor = _profile_field(group, geometry)
camera_position = _position_field(group, depth_image)
separate_camera = nodes.new("ShaderNodeSeparateXYZ")
links.new(camera_position, separate_camera.inputs["Vector"])
metric_depth = math_node(nodes, "MULTIPLY")
links.new(separate_camera.outputs["Z"], metric_depth.inputs[0])
links.new(uniform_scale, metric_depth.inputs[1])
plane_depth = reference_plane_depth(
group,
depth_direction,
depth_reference,
)
corrected_depth = math_node(nodes, "SUBTRACT")
links.new(plane_depth, corrected_depth.inputs[0])
links.new(metric_depth.outputs[0], corrected_depth.inputs[1])
nonnegative_depth = math_node(nodes, "MAXIMUM")
nonnegative_depth.inputs[1].default_value = 0.0
links.new(corrected_depth.outputs[0], nonnegative_depth.inputs[0])
scaled_depth = math_node(nodes, "MULTIPLY")
links.new(nonnegative_depth.outputs[0], scaled_depth.inputs[0])
links.new(depth_scale, scaled_depth.inputs[1])
front_depth = scaled_depth.outputs[0]
base_front = math_node(nodes, "MULTIPLY")
links.new(profile, base_front.inputs[0])
links.new(thickness, base_front.inputs[1])
negate_back = math_node(nodes, "MULTIPLY")
negate_back.inputs[1].default_value = -1.0
links.new(base_front.outputs[0], negate_back.inputs[0])
front_mix = nodes.new("ShaderNodeMix")
front_mix.data_type = "FLOAT"
front_mix.clamp_factor = True
links.new(profile_factor, front_mix.inputs["Factor"])
links.new(base_front.outputs[0], front_mix.inputs["A"])
links.new(front_depth, front_mix.inputs["B"])
mirrored_front = math_node(nodes, "MULTIPLY")
mirrored_front.inputs[1].default_value = -1.0
links.new(front_mix.outputs["Result"], mirrored_front.inputs[0])
back_choice = nodes.new("GeometryNodeSwitch")
back_choice.input_type = "FLOAT"
links.new(double_sided, back_choice.inputs["Switch"])
links.new(negate_back.outputs[0], back_choice.inputs["False"])
links.new(mirrored_front.outputs[0], back_choice.inputs["True"])
extrude = _extrude_layers(group, geometry)
shaped = _set_layer_z(
group,
geometry,
extrude,
front_mix.outputs["Result"],
back_choice.outputs["Output"],
)
front_only = _set_geometry_z(
group, geometry, front_mix.outputs["Result"]
)
return _remove_zero_thickness(group, front_only, shaped, thickness)
def build_image_depth_balloon_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')
float_input(group, 'Thickness', 1.0).description = 'Control how much the surface bulges. Zero removes the bulge.'
float_input(group, 'Depth Scale', 1.0)
bool_input(group, 'Double Sided', True)
iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt')
iterations.description = 'Set the number of mesh smoothing passes. Higher values smooth the surface more but may soften details. Zero disables smoothing.'
iterations.default_value = 0
iterations.min_value, iterations.max_value = (0, 20)
options = group.interface.new_panel(name='Options', default_closed=True)
float_input(group, 'Cleanup Threshold', 0.0, parent=options)
float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control the strength of each mesh smoothing pass. Higher values move vertices more.'
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True
vector_input(group, 'Depth Direction', (0, 0, 1), subtype='DIRECTION', 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_balloon(group, controls.outputs['Geometry'], controls.outputs['Thickness'], controls.outputs['Depth Scale'], controls.outputs['Double Sided'], controls.outputs['Uniform Scale'], controls.outputs['Depth Direction'], controls.outputs['Depth Image'], controls.outputs['Reference Depth'])
planar_inputs = [link.to_socket for link in group.links if link.from_node.bl_idname == 'NodeGroupInput' and link.from_socket.name == 'Geometry']
base = group_input(group.nodes, {'Geometry', 'Cleanup Threshold'})
area = math_node(group.nodes, 'MULTIPLY')
area.inputs[1].default_value = 0.01
group.links.new(base.outputs['Cleanup Threshold'], area.inputs[0])
cleaned = _island_cleanup(group, base.outputs['Geometry'], area.outputs[0])
for target in planar_inputs:
group.links.new(cleaned, target)
geometry = expand_smooth(group, geometry, iterations_name='Smooth', weight_name='Smooth Weight')
profile = read_float_attribute(group, 'o_balloon')
scale = profile
geometry = store_float_attribute(group, geometry, NORMAL_SCALE_ATTRIBUTE_NAME, scale)
orient = group.nodes.new('GeometryNodeTransform')
orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
group.links.new(geometry, orient.inputs['Geometry'])
axis = menu_switch(group, 'Depth Axis', ('-Z', '+X'), 'GEOMETRY')
group.links.new(geometry, axis.inputs['-Z'])
group.links.new(orient.outputs[0], axis.inputs['+X'])
_shade_output(group, axis.outputs[0])
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 == 'Depth Axis':
item.default_value = '-Z'
return group

View File

@@ -1,7 +1,7 @@
"""Build O Image Depth Cutout."""
import bpy
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group, sample_field, evaluate_field
from ..common.smoothing import expand_smooth
from ..common.boundary_smoothing import smooth_cut_boundary
@@ -235,7 +235,7 @@ def build_shell_position(group, position, front, is_shell, fields):
links.new(control.outputs["Thickness"], clamp.inputs["Value"])
t = clamp.outputs[0]
radius = _math(group, "MULTIPLY", _math(group, "SUBTRACT", front_z, fields["center"]), t)
rear = _math(group, "SUBTRACT", 1, read_float_attribute(group, "o_normal_scale"))
rear = read_float_attribute(group, NORMAL_REDUCTION_ATTRIBUTE_NAME)
rear_radius = _math(group, "MULTIPLY", rear, _math(group, "ADD", radius, distance))
direction = vector("SUBTRACT", fields["normal"], (0, 0, 1))
rear_shift = vector("SCALE", direction, rear_radius)
@@ -277,7 +277,7 @@ def _build_depth_surface(group, geometry, controls):
projection = project_depth_surface(
group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"], split,
)
projection = smooth_cut_boundary(group, projection)
projection = smooth_cut_boundary(group, projection, cut_vertex)
# Clamped depth pixels can collapse adjacent outline samples after projection.
edge = group.nodes.new("GeometryNodeInputMeshEdgeVertices")
length = group.nodes.new("ShaderNodeVectorMath")
@@ -309,7 +309,7 @@ def _build_depth_surface(group, geometry, controls):
front = build_front_fields(group, volume, normal, normal_weight)
shell_fields = build_shell_fields(group, projection, volume, is_shell, normal_weight)
shell = thicken_depth_surface(
group, projection, shell_fields["thickness"], back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True,
group, projection, shell_fields["thickness"], back_mask=NORMAL_REDUCTION_ATTRIBUTE_NAME, weld_zero_thickness=True,
)
fields = {
name: sample_field(group, projection, value, data_type, index=shell["source_index"])
@@ -357,7 +357,7 @@ def build_image_depth_cutout_group():
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True
boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 0, 0, 16
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.'
normal_smooth = interface_socket(group, 'Normal Smooth', 'INPUT', 'NodeSocketInt', options)
normal_smooth.default_value, normal_smooth.min_value, normal_smooth.max_value = 64, 16, 512
@@ -372,7 +372,7 @@ def build_image_depth_cutout_group():
area.inputs[1].default_value = 0.01
group.links.new(base.outputs['Cleanup Threshold'], area.inputs[0])
cleaned = _island_cleanup(group, base.outputs['Geometry'], area.outputs[0])
cleaned = store_float_attribute(group, cleaned, NORMAL_SCALE_ATTRIBUTE_NAME, 1.0, domain='FACE')
cleaned = store_float_attribute(group, cleaned, NORMAL_REDUCTION_ATTRIBUTE_NAME, 0.0, domain='FACE')
controls = group_input(group.nodes, set())
geometry = _build_depth_surface(group, cleaned, controls)
geometry = expand_smooth(group, geometry, iterations_name='Smooth', weight_name='Smooth Weight')

View File

@@ -49,39 +49,26 @@ def build_image_depth_layer_group():
displacement = nodes.new('ShaderNodeDisplacement')
displacement.inputs['Midlevel'].default_value = 0.0
displacement.inputs['Scale'].default_value = 1.0
group_input.location = (-1200.0, 100.0)
group_output.location = (1050.0, 100.0)
roughness_curve.location = (-300.0, 450.0)
bump.location = (750.0, 50.0)
displacement.location = (750.0, -300.0)
background_attribute = nodes.new('ShaderNodeAttribute')
background_attribute.attribute_type = 'GEOMETRY'
background_attribute.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE
background_attribute.location = (-950.0, -500.0)
material_attribute = nodes.new('ShaderNodeAttribute')
material_attribute.attribute_type = 'GEOMETRY'
material_attribute.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE
material_attribute.location = (-50.0, -650.0)
strength_attribute = nodes.new('ShaderNodeAttribute')
strength_attribute.attribute_type = 'GEOMETRY'
strength_attribute.attribute_name = DEPTH_SCALE_ATTRIBUTE
strength_attribute.location = (200.0, -650.0)
foreground_mask = nodes.new('ShaderNodeMath')
foreground_mask.operation = 'GREATER_THAN'
foreground_mask.inputs[1].default_value = 0.5
foreground_mask.location = (-950.0, -250.0)
clamp_foreground = nodes.new('ShaderNodeMath')
clamp_foreground.operation = 'MAXIMUM'
clamp_foreground.location = (-650.0, -400.0)
masked_depth = nodes.new('ShaderNodeMix')
masked_depth.data_type = 'FLOAT'
masked_depth.location = (-350.0, -350.0)
enabled_depth = nodes.new('ShaderNodeMath')
enabled_depth.operation = 'MULTIPLY'
enabled_depth.location = (250.0, -350.0)
scaled_depth = nodes.new('ShaderNodeMath')
scaled_depth.operation = 'MULTIPLY'
scaled_depth.location = (450.0, -350.0)
links.new(group_input.outputs['Alpha'], foreground_mask.inputs[0])
links.new(group_input.outputs['Depth'], clamp_foreground.inputs[0])
links.new(background_attribute.outputs['Fac'], clamp_foreground.inputs[1])
@@ -94,7 +81,7 @@ def build_image_depth_layer_group():
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, (500.0, 100.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'

View File

@@ -0,0 +1,289 @@
"""Build O Image Depth Symmetry."""
import bpy
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import (
interface_socket, group_input, math_node, float_input, bool_input, vector_input,
menu_switch, evaluate_field, read_float_attribute, store_float_attribute, prepare_node_group,
)
from ..common.smoothing import expand_smooth
from ..common.boundary_smoothing import smooth_cut_boundary
from ..common.depth_surface import _math, project_depth_surface
from ..common.reference_plane import reference_plane_depth
from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
from ..common.cutout import _extrude_layers, _island_cleanup, _position_field, _profile_field, _remove_zero_thickness, _set_layer_z
NODE_GROUP_NAME = "O Image Depth Symmetry"
def _set_geometry_z(group, geometry, z_value):
nodes = group.nodes
links = group.links
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(separate.outputs["Y"], combine.inputs["Y"])
links.new(z_value, combine.inputs["Z"])
set_position = nodes.new("GeometryNodeSetPosition")
links.new(geometry, set_position.inputs["Geometry"])
links.new(combine.outputs["Vector"], set_position.inputs["Position"])
return set_position.outputs["Geometry"]
def _build_balloon(
group,
geometry,
thickness,
depth_scale,
double_sided,
uniform_scale,
depth_direction,
depth_image,
depth_reference,
depth_offset,
):
nodes = group.nodes
links = group.links
profile, profile_factor = _profile_field(group, geometry)
camera_position = _position_field(group, depth_image)
separate_camera = nodes.new("ShaderNodeSeparateXYZ")
links.new(camera_position, separate_camera.inputs["Vector"])
metric_depth = math_node(nodes, "MULTIPLY")
links.new(separate_camera.outputs["Z"], metric_depth.inputs[0])
links.new(uniform_scale, metric_depth.inputs[1])
plane_depth = reference_plane_depth(
group,
depth_direction,
depth_reference,
)
corrected_depth = math_node(nodes, "SUBTRACT")
links.new(plane_depth, corrected_depth.inputs[0])
links.new(metric_depth.outputs[0], corrected_depth.inputs[1])
nonnegative_depth = math_node(nodes, "MAXIMUM")
nonnegative_depth.inputs[1].default_value = 0.0
links.new(corrected_depth.outputs[0], nonnegative_depth.inputs[0])
scaled_depth = math_node(nodes, "MULTIPLY")
links.new(nonnegative_depth.outputs[0], scaled_depth.inputs[0])
links.new(depth_scale, scaled_depth.inputs[1])
front_depth = _math(group, 'MAXIMUM', _math(group, 'ADD', scaled_depth.outputs[0], depth_offset), 0.0)
base_front = math_node(nodes, "MULTIPLY")
links.new(profile, base_front.inputs[0])
links.new(thickness, base_front.inputs[1])
negate_back = math_node(nodes, "MULTIPLY")
negate_back.inputs[1].default_value = -1.0
links.new(base_front.outputs[0], negate_back.inputs[0])
front_mix = nodes.new("ShaderNodeMix")
front_mix.data_type = "FLOAT"
front_mix.clamp_factor = True
links.new(profile_factor, front_mix.inputs["Factor"])
links.new(base_front.outputs[0], front_mix.inputs["A"])
links.new(front_depth, front_mix.inputs["B"])
mirrored_front = math_node(nodes, "MULTIPLY")
mirrored_front.inputs[1].default_value = -1.0
links.new(front_mix.outputs["Result"], mirrored_front.inputs[0])
back_choice = nodes.new("GeometryNodeSwitch")
back_choice.input_type = "FLOAT"
links.new(double_sided, back_choice.inputs["Switch"])
links.new(negate_back.outputs[0], back_choice.inputs["False"])
links.new(mirrored_front.outputs[0], back_choice.inputs["True"])
extrude = _extrude_layers(group, geometry)
shaped = _set_layer_z(
group,
geometry,
extrude,
front_mix.outputs["Result"],
back_choice.outputs["Output"],
)
front_only = _set_geometry_z(
group, geometry, front_mix.outputs["Result"]
)
return _remove_zero_thickness(group, front_only, shaped, thickness)
def _build_projected_front(group, geometry, controls):
"""Project camera depth and align its direction with the fixed XY plane."""
nodes, links = group.nodes, group.links
camera = _position_field(group, controls['Depth Image'])
projected = project_depth_surface(
group, geometry, camera, controls['Uniform Scale'],
controls['Reference Depth'], controls['Depth Scale'], 0.0,
)
align = nodes.new('FunctionNodeAlignEulerToVector')
align.axis = 'Z'
links.new(controls['Depth 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'])
place = nodes.new('GeometryNodeSetPosition')
links.new(projected, place.inputs['Geometry'])
links.new(rotate.outputs[0], place.inputs['Position'])
offset = nodes.new('ShaderNodeCombineXYZ')
links.new(controls['Depth Offset'], offset.inputs['Z'])
links.new(offset.outputs[0], place.inputs['Offset'])
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 _delete_negative_faces(group, front, tolerance):
"""Discard faces touching negative Z without leaving wires or isolated vertices."""
position = group.nodes.new('GeometryNodeInputPosition')
components = group.nodes.new('ShaderNodeSeparateXYZ')
group.links.new(position.outputs[0], components.inputs[0])
negative = _math(group, 'LESS_THAN', components.outputs['Z'], tolerance)
point_negative = evaluate_field(group, negative, 'FLOAT', 'POINT')
face_negative = evaluate_field(group, point_negative, 'FLOAT', 'FACE')
delete = group.nodes.new('GeometryNodeDeleteGeometry')
delete.domain = 'FACE'
delete.mode = 'ALL'
group.links.new(front, delete.inputs['Geometry'])
group.links.new(_math(group, 'GREATER_THAN', face_negative, 0), delete.inputs['Selection'])
return delete.outputs[0]
def _build_straight_volume(group, front, double_sided, fill_sides, tolerance):
"""Mirror the positive surface and join matching boundary vertices with straight walls."""
nodes, links = group.nodes, group.links
shade = nodes.new('GeometryNodeSetShadeSmooth')
shade.domain = 'FACE'
links.new(front, shade.inputs['Geometry'])
front = shade.outputs[0]
front = store_float_attribute(group, front, FACE_ATTRIBUTE, 1., domain='FACE')
count = nodes.new('GeometryNodeInputMeshEdgeNeighbors')
boundary = _math(group, 'COMPARE', count.outputs['Face Count'], 1)
extrude = nodes.new('GeometryNodeExtrudeMesh')
extrude.mode = 'EDGES'
extrude.inputs['Offset'].default_value = (0, 0, 0)
extrude.inputs['Offset Scale'].default_value = 0
links.new(front, extrude.inputs['Mesh'])
links.new(boundary, extrude.inputs['Selection'])
position = nodes.new('GeometryNodeInputPosition')
components = nodes.new('ShaderNodeSeparateXYZ')
links.new(position.outputs[0], components.inputs[0])
mirror_point = nodes.new('ShaderNodeCombineXYZ')
links.new(components.outputs['X'], mirror_point.inputs['X'])
links.new(components.outputs['Y'], mirror_point.inputs['Y'])
links.new(_math(group, 'MULTIPLY', components.outputs['Z'], -1), mirror_point.inputs['Z'])
place = nodes.new('GeometryNodeSetPosition')
links.new(extrude.outputs['Mesh'], place.inputs['Geometry'])
links.new(extrude.outputs['Top'], place.inputs['Selection'])
links.new(mirror_point.outputs[0], place.inputs['Position'])
sides = nodes.new('GeometryNodeSeparateGeometry')
sides.domain = 'FACE'
links.new(place.outputs[0], sides.inputs['Geometry'])
links.new(extrude.outputs['Side'], sides.inputs['Selection'])
flat = nodes.new('GeometryNodeSetShadeSmooth')
flat.domain = 'FACE'
flat.inputs['Shade Smooth'].default_value = False
links.new(sides.outputs['Selection'], flat.inputs['Geometry'])
mirror = nodes.new('GeometryNodeTransform')
mirror.inputs['Scale'].default_value = (1, 1, -1)
links.new(front, mirror.inputs['Geometry'])
flip = nodes.new('GeometryNodeFlipFaces')
links.new(mirror.outputs[0], flip.inputs['Mesh'])
join = nodes.new('GeometryNodeJoinGeometry')
back = store_float_attribute(group, flip.outputs[0], FACE_ATTRIBUTE, 2., domain='FACE')
walls = store_float_attribute(group, flat.outputs[0], FACE_ATTRIBUTE, 3., domain='FACE')
wall_switch = nodes.new('GeometryNodeSwitch')
wall_switch.input_type = 'GEOMETRY'
links.new(fill_sides, wall_switch.inputs['Switch'])
links.new(walls, wall_switch.inputs['True'])
for geometry in (front, back, wall_switch.outputs[0]):
links.new(geometry, join.inputs[0])
merge = nodes.new('GeometryNodeMergeByDistance')
links.new(tolerance, merge.inputs['Distance'])
links.new(join.outputs[0], merge.inputs[0])
switch = nodes.new('GeometryNodeSwitch')
switch.input_type = 'GEOMETRY'
links.new(double_sided, switch.inputs['Switch'])
links.new(front, switch.inputs['False'])
links.new(merge.outputs[0], switch.inputs['True'])
return switch.outputs[0]
def build_image_depth_symmetry_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).description = 'Control the bulge in Balloon mode. Zero keeps only the front surface.'
float_input(group, 'Depth Scale', 1.0)
vector_input(group, 'Depth Direction', (0, 0, 1), subtype='DIRECTION')
float_input(group, 'Depth Offset', 0.0, minimum=-1e10, subtype='DISTANCE').description = 'Offset the front depth along the symmetry axis after direction adjustment. Positive values move it away from the symmetry plane.'
iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt')
iterations.description = 'Set the number of mesh smoothing passes. Higher values smooth the surface more but may soften details. Zero disables smoothing.'
iterations.default_value = 0
iterations.min_value, iterations.max_value = (0, 20)
options = group.interface.new_panel(name='Options', default_closed=True)
bool_input(group, 'Double Sided', True, parent=options)
bool_input(group, 'Fill Sides', True, parent=options).description = 'Close the gap between the front and mirrored back with straight walls in Projected mode.'
float_input(group, 'Cleanup Threshold', 0.0, parent=options)
float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control the strength of each mesh smoothing pass. Higher values move vertices more.'
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options).description = 'Calibrate the source depth before direction adjustment.'
interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True
boundary = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
boundary.default_value, boundary.min_value, boundary.max_value = 1, 0, 16
boundary.description = 'Smooth the projected outline before clipping and closing the surface.'
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())
inputs = controls.outputs
cleaned = _island_cleanup(
group, inputs['Geometry'], _math(group, 'MULTIPLY', inputs['Cleanup Threshold'], 0.01),
)
balloon = _build_balloon(
group, cleaned, inputs['Thickness'], inputs['Depth Scale'], inputs['Double Sided'],
inputs['Uniform Scale'], inputs['Depth Direction'], inputs['Depth Image'], inputs['Reference Depth'],
inputs['Depth Offset'],
)
balloon = expand_smooth(group, balloon, iterations_name='Smooth', weight_name='Smooth Weight')
shade = group.nodes.new('GeometryNodeSetShadeSmooth')
shade.domain = 'FACE'
group.links.new(balloon, shade.inputs['Geometry'])
tolerance = _math(group, 'MULTIPLY', _math(group, 'MAXIMUM', inputs['Uniform Scale'], 1e-8), 1e-7)
front = smooth_cut_boundary(group, _build_projected_front(group, cleaned, inputs))
front = _delete_negative_faces(group, front, tolerance)
front = expand_smooth(group, front, iterations_name='Smooth', weight_name='Smooth Weight')
projected = _build_straight_volume(group, front, inputs['Double Sided'], inputs['Fill Sides'], tolerance)
modes = menu_switch(group, 'Mode', ('Balloon', 'Projected'), 'GEOMETRY')
group.links.new(shade.outputs[0], modes.inputs['Balloon'])
group.links.new(projected, modes.inputs['Projected'])
geometry = modes.outputs[0]
profile = read_float_attribute(group, 'o_balloon')
reduction = _math(group, 'SUBTRACT', 1.0, profile)
geometry = store_float_attribute(group, geometry, NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction)
geometry = store_float_attribute(group, geometry, AXIS_ATTRIBUTE, 0., domain='FACE')
orient = group.nodes.new('GeometryNodeTransform')
orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
group.links.new(geometry, orient.inputs['Geometry'])
axis = menu_switch(group, 'Depth Axis', ('-Z', '+X'), 'GEOMETRY')
group.links.new(geometry, axis.inputs['-Z'])
oriented = store_float_attribute(group, orient.outputs[0], AXIS_ATTRIBUTE, 1., domain='FACE')
group.links.new(oriented, axis.inputs['+X'])
output = group.nodes.new('NodeGroupOutput')
group.links.new(axis.outputs[0], output.inputs['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 = 'Projected'
elif item.name == 'Depth Axis':
item.default_value = '-Z'
return group

View File

@@ -1,7 +1,9 @@
"""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():
@@ -47,18 +49,30 @@ def build_image_layer_group():
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'
tangent_normal.location = (-500.0, -100.0)
object_normal = nodes.new('ShaderNodeNormalMap')
object_normal.space = 'OBJECT'
object_normal.location = (-500.0, -350.0)
choose_normal = nodes.new('ShaderNodeMix')
choose_normal.data_type = 'VECTOR'
choose_normal.location = (-150.0, -150.0)
for normal_map in (tangent_normal, object_normal):
links.new(group_input.outputs['Normal'], normal_map.inputs['Color'])
links.new(group_input.outputs['Normal Scale'], normal_map.inputs['Strength'])
links.new(group_input.outputs['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])
@@ -76,14 +90,10 @@ def build_image_layer_group():
for point in alpha_points:
point.handle_type = 'AUTO_CLAMPED'
alpha_curve.mapping.update()
group_input.location = (-750.0, 150.0)
group_output.location = (350.0, 450.0)
alpha_curve.location = (-350.0, 1100.0)
roughness_curve.location = (-350.0, 650.0)
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, (-250.0, 150.0))
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

View File

@@ -20,61 +20,46 @@ def build_shadeless_group():
nodes = group.nodes
links = group.links
group_input = nodes.new("NodeGroupInput")
group_input.location = (-900.0, -350.0)
group_output = nodes.new("NodeGroupOutput")
group_output.location = (850.0, 100.0)
diffuse = nodes.new("ShaderNodeBsdfDiffuse")
diffuse.location = (250.0, -150.0)
emission = nodes.new("ShaderNodeEmission")
emission.location = (250.0, -400.0)
links.new(group_input.outputs["Color"], diffuse.inputs["Color"])
links.new(group_input.outputs["Color"], emission.inputs["Color"])
light_path = nodes.new("ShaderNodeLightPath")
light_path.location = (-900.0, 150.0)
bounce_count = nodes.new("ShaderNodeMath")
bounce_count.operation = "SUBTRACT"
bounce_count.location = (-500.0, -50.0)
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
refracted.location = (-250.0, -50.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
reflection_limit.location = (-650.0, 450.0)
links.new(light_path.outputs["Ray Depth"], reflection_limit.inputs[1])
camera_reflected = nodes.new("ShaderNodeMath")
camera_reflected.operation = "MULTIPLY"
camera_reflected.location = (-400.0, 400.0)
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"
shadow_or_reflect.location = (-100.0, 350.0)
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"
shader_factor.location = (150.0, 300.0)
links.new(shadow_or_reflect.outputs[0], shader_factor.inputs[0])
links.new(refracted.outputs[0], shader_factor.inputs[1])
mix = nodes.new("ShaderNodeMixShader")
mix.location = (550.0, 100.0)
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")
transparent.location = (550.0, -200.0)
alpha_mix = nodes.new("ShaderNodeMixShader")
alpha_mix.location = (850.0, 100.0)
group_output.location = (1100.0, 100.0)
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])

View File

@@ -46,7 +46,7 @@ def _node_group_data(node_group):
label = prop.enum_items[value].name if prop.type == "ENUM" and value in prop.enum_items else str(value)
settings.append(label)
title = node.bl_label
if node.bl_idname == "GeometryNodeGroup":
if node.bl_idname in {"GeometryNodeGroup", "ShaderNodeGroup"}:
title = node.node_tree.name
elif hasattr(node, "operation") and settings:
title = settings[0]
@@ -120,7 +120,7 @@ button,select { padding: 8px; font: inherit; max-width: 100%; }
def main():
"""Build and arrange geometry assets in memory for preview."""
"""Build and arrange node assets in memory for preview."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True, help="Output HTML path")
parser.add_argument("--fragment", action="store_true", help="Write a fragment for an inline preview")
@@ -130,7 +130,7 @@ def main():
from ..arrangement.arrange import arrange_nodes
bpy.ops.wm.read_factory_settings(use_empty=True)
node_groups = [node_group for node_group in build_node_groups() if node_group.bl_idname == "GeometryNodeTree"]
node_groups = build_node_groups()
for node_group in node_groups:
arrange_nodes(node_group)
print(write_node_preview(node_groups, output_path, fragment=args.fragment))

View File

@@ -27,7 +27,7 @@
</div>
<canvas id="layout-canvas" role="img" aria-label="按实际排列坐标绘制的节点组,尺寸和曲线使用估算模型"></canvas>
<div class="viz-row text-small" id="layout-legend">
<span><i class="geometry-key"></i>几何连接</span>
<span><i class="geometry-key"></i>几何与着色连接</span>
<span><i class="field-key"></i>字段与参数连接</span>
<span id="layout-count"></span>
</div>
@@ -113,15 +113,15 @@
return;
}
if (view.value === 'middle') {
const geometryNodes = current.nodes.filter(n => n.type !== 'NodeReroute' && n.role === 'geometry');
const midX = (Math.min(...geometryNodes.map(n => n.x)) + Math.max(...geometryNodes.map(n => n.x))) / 2;
const target = geometryNodes.sort((a, b) => Math.abs(a.x - midX) - Math.abs(b.x - midX))[0];
const mainNodes = current.nodes.filter(n => n.type !== 'NodeReroute' && n.role !== 'field');
const midX = (Math.min(...mainNodes.map(n => n.x)) + Math.max(...mainNodes.map(n => n.x))) / 2;
const target = mainNodes.sort((a, b) => Math.abs(a.x - midX) - Math.abs(b.x - midX))[0];
fit([target.x - 850, target.y - 560, target.x + 670, target.y + 600]);
detail.textContent = `${current.name} · 中段计算局部`;
return;
}
if (view.value === 'input') {
const target = current.nodes.filter(n => n.role === 'geometry' && n.type !== 'NodeReroute').sort((a, b) => a.x - b.x)[0];
const target = current.nodes.filter(n => n.role !== 'field' && n.type !== 'NodeReroute').sort((a, b) => a.x - b.x)[0];
fit([target.x - 850, target.y - 650, target.x + 750, target.y + 650]);
detail.textContent = `${current.name} · 起点输入附近`; return;
}
@@ -192,9 +192,9 @@
const x2 = wx2 * scale + dx, y2 = wy2 * scale + dy;
const bend = Math.max(40, Math.abs(wx2 - wx1) * 0.42) * scale;
const active = selected === source || selected === target;
context.globalAlpha = selected !== null && !active ? 0.18 : type === 'GEOMETRY' ? 0.85 : 0.46;
context.strokeStyle = type === 'GEOMETRY' ? geometry : fields;
context.lineWidth = active ? 2.5 : type === 'GEOMETRY' ? 1.35 : 0.8;
context.globalAlpha = selected !== null && !active ? 0.18 : ['GEOMETRY', 'SHADER'].includes(type) ? 0.85 : 0.46;
context.strokeStyle = ['GEOMETRY', 'SHADER'].includes(type) ? geometry : fields;
context.lineWidth = active ? 2.5 : ['GEOMETRY', 'SHADER'].includes(type) ? 1.35 : 0.8;
context.beginPath(); context.moveTo(x1, y1);
context.bezierCurveTo(x1 + bend, y1, x2 - bend, y2, x2, y2); context.stroke();
}
@@ -202,9 +202,9 @@
for (const node of current.nodes) {
const x = node.x * scale + dx, y = node.y * scale + dy, w = node.w * scale, h = node.h * scale;
if (x + w < 0 || x > width || y + h < 0 || y > height) continue;
const geometryNode = node.type === 'NodeReroute'
? node.sockets.some(socket => socket[3] === 'GEOMETRY') : node.role === 'geometry';
const hue = geometryNode ? geometry : other;
const mainNode = node.type === 'NodeReroute'
? node.sockets.some(socket => ['GEOMETRY', 'SHADER'].includes(socket[3])) : node.role !== 'field';
const hue = mainNode ? geometry : other;
if (node.type === 'NodeReroute') {
context.fillStyle = hue; context.beginPath();
context.arc(x + w / 2, y + h / 2, Math.max(1.5, w / 3), 0, Math.PI * 2); context.fill(); continue;
@@ -241,7 +241,7 @@
}
if (scale >= 0.22) {
for (const socket of node.sockets) {
context.fillStyle = socket[3] === 'GEOMETRY' ? geometry : fields;
context.fillStyle = ['GEOMETRY', 'SHADER'].includes(socket[3]) ? geometry : fields;
context.beginPath(); context.arc(socket[2] ? x + w : x, y + socket[1] * scale,
Math.max(1.4, 3.6 * scale), 0, Math.PI * 2); context.fill();
}
@@ -285,7 +285,7 @@
const x = (event.clientX - rect.left - dx) / scale, y = (event.clientY - rect.top - dy) / scale;
const node = current.nodes.findLast(n => x >= n.x && x <= n.x + n.w && y >= n.y && y <= n.y + n.h);
selected = node ? node.id : null;
detail.textContent = node ? `${node.name} · ${node.title} · ${node.role === 'geometry' ? '几何主线' : '计算或输入'}${node.settings.length ? ' · ' + node.settings.join(' / ') : ''}` : current.name;
detail.textContent = node ? `${node.name} · ${node.title} · ${node.role !== 'field' ? '结果主线' : '计算或输入'}${node.settings.length ? ' · ' + node.settings.join(' / ') : ''}` : current.name;
draw();
}
dragging = null;