- 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.
417 lines
20 KiB
Python
417 lines
20 KiB
Python
"""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
|