Initial public release
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-09-19 21:31:36 +08:00
commit 198fcbf55b
837 changed files with 67239 additions and 0 deletions

1
nodes/groups/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Named node group definitions."""

View File

@@ -0,0 +1,205 @@
"""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

@@ -0,0 +1,416 @@
"""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

@@ -0,0 +1,336 @@
"""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

@@ -0,0 +1,90 @@
"""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

@@ -0,0 +1,212 @@
"""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

@@ -0,0 +1,158 @@
"""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

103
nodes/groups/image_layer.py Normal file
View File

@@ -0,0 +1,103 @@
"""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

253
nodes/groups/image_plane.py Normal file
View File

@@ -0,0 +1,253 @@
"""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

@@ -0,0 +1,234 @@
"""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

79
nodes/groups/shadeless.py Normal file
View File

@@ -0,0 +1,79 @@
"""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