- Introduced a new `Boundary Padding` preference in the Cutout Tool with a default of 2 px and a configurable range of 1-4 px. - Updated the Cutout client to establish inward mapping from the final Mask, sampling in a fixed direction and processing independent material RGBA and Depth. - Enhanced the handling of RGBA and Depth during cutout operations, ensuring proper mapping and preservation of source data. - Added tests to validate the new boundary padding functionality and its integration with existing cutout processes. feat(geometry): remove overall mesh smoothing from geometry nodes - Removed the `Smooth` input from several geometry node groups to eliminate unnecessary overall mesh smoothing. - Retained `Smooth Weight` for depth nodes while removing it from Cutout and Relief Plane nodes. - Ensured that boundary, normal, and fill smoothing behaviors remain intact and consistent. - Updated tests and documentation to reflect the changes in node interfaces and behaviors. feat(normals): scale tangent normals with depth - Reused the existing `o_normal_reduction` attribute to adjust Tangent Normal strength based on the geometry's `Depth Scale`. - Ensured that Tangent Normal strength is consistent with the geometry depth, preserving full strength for materials that do not provide the attribute. - Updated tests to verify the correct behavior of normal strength adjustments in both Tangent and Object Space.
201 lines
10 KiB
Python
201 lines
10 KiB
Python
"""Mirror completed depth geometry and fill matching boundaries."""
|
|
import bpy
|
|
|
|
from ..common.nodes import (
|
|
interface_socket, group_input, float_input, bool_input, vector_input,
|
|
evaluate_field, store_float_attribute, read_float_attribute, prepare_node_group,
|
|
)
|
|
from ..common.depth_surface import _math
|
|
from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
|
|
|
|
NODE_GROUP_NAME = "O Image Cutout Symmetry"
|
|
|
|
|
|
def _align_front(group, geometry, controls):
|
|
"""Align existing geometry with the fixed XY symmetry plane."""
|
|
nodes, links = group.nodes, group.links
|
|
align = nodes.new('FunctionNodeAlignEulerToVector')
|
|
align.axis = 'Z'
|
|
links.new(controls['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')
|
|
links.new(controls['Offset'], offset.inputs['Z'])
|
|
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['Y'].default_value = 1
|
|
links.new(controls['Scale'], scale_vector.inputs['Z'])
|
|
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 _delete_negative_faces(group, front, tolerance):
|
|
"""Discard faces touching negative Z without leaving wires or isolated vertices."""
|
|
position = group.nodes.new('GeometryNodeInputPosition')
|
|
components = group.nodes.new('ShaderNodeSeparateXYZ')
|
|
group.links.new(position.outputs[0], components.inputs[0])
|
|
negative = _math(group, 'LESS_THAN', components.outputs['Z'], tolerance)
|
|
point_negative = evaluate_field(group, negative, 'FLOAT', 'POINT')
|
|
face_negative = evaluate_field(group, point_negative, 'FLOAT', 'FACE')
|
|
delete = group.nodes.new('GeometryNodeDeleteGeometry')
|
|
delete.domain = 'FACE'
|
|
delete.mode = 'ALL'
|
|
group.links.new(front, delete.inputs['Geometry'])
|
|
group.links.new(_math(group, 'GREATER_THAN', face_negative, 0), delete.inputs['Selection'])
|
|
return delete.outputs[0]
|
|
|
|
|
|
def _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 _extrude_boundary(group, geometry, selection, z_factor):
|
|
"""Extrude selected edges to a fraction of their current height."""
|
|
nodes, links = group.nodes, group.links
|
|
extrude = nodes.new('GeometryNodeExtrudeMesh')
|
|
extrude.mode = 'EDGES'
|
|
extrude.inputs['Offset Scale'].default_value = 0
|
|
links.new(geometry, extrude.inputs['Mesh'])
|
|
links.new(selection, extrude.inputs['Selection'])
|
|
position = nodes.new('GeometryNodeInputPosition')
|
|
scale = nodes.new('ShaderNodeVectorMath')
|
|
scale.operation = 'MULTIPLY'
|
|
scale.inputs[1].default_value = (1, 1, z_factor)
|
|
links.new(position.outputs[0], scale.inputs[0])
|
|
place = nodes.new('GeometryNodeSetPosition')
|
|
links.new(extrude.outputs['Mesh'], place.inputs['Geometry'])
|
|
links.new(extrude.outputs['Top'], place.inputs['Selection'])
|
|
links.new(scale.outputs[0], place.inputs['Position'])
|
|
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(place.outputs[0], store.inputs['Geometry'])
|
|
links.new(extrude.outputs['Side'], store.inputs['Selection'])
|
|
return store.outputs[0], extrude.outputs['Top']
|
|
|
|
|
|
def _walls(group, front, boundary, smooth, tolerance, *, curved):
|
|
"""Build straight walls or a smoothed half-strip followed by its reflection."""
|
|
nodes, links = group.nodes, group.links
|
|
geometry, top = _extrude_boundary(group, front, boundary, .5 if curved else -1)
|
|
if curved:
|
|
geometry, _ = _extrude_boundary(group, geometry, top, 0)
|
|
sides = nodes.new('GeometryNodeSeparateGeometry')
|
|
sides.domain = 'FACE'
|
|
links.new(geometry, sides.inputs['Geometry'])
|
|
links.new(_math(group, 'COMPARE', read_float_attribute(group, FACE_ATTRIBUTE), 3), sides.inputs['Selection'])
|
|
geometry = sides.outputs['Selection']
|
|
if curved:
|
|
# Query the retained front to pin the original seam; only new ring XY moves.
|
|
proximity = nodes.new('GeometryNodeProximity')
|
|
proximity.target_element = 'POINTS'
|
|
links.new(front, proximity.inputs['Target'])
|
|
movable = _math(group, 'GREATER_THAN', proximity.outputs['Distance'], tolerance)
|
|
position = nodes.new('GeometryNodeInputPosition')
|
|
blur = nodes.new('GeometryNodeBlurAttribute')
|
|
blur.data_type = 'FLOAT_VECTOR'
|
|
links.new(position.outputs[0], blur.inputs['Value'])
|
|
links.new(smooth, blur.inputs['Iterations'])
|
|
# Keep heights fixed so neither the seam nor the symmetry plane can drift.
|
|
difference = nodes.new('ShaderNodeVectorMath')
|
|
difference.operation = 'SUBTRACT'
|
|
links.new(blur.outputs[0], difference.inputs[0])
|
|
links.new(position.outputs[0], difference.inputs[1])
|
|
mask = nodes.new('ShaderNodeVectorMath')
|
|
mask.operation = 'MULTIPLY'
|
|
mask.inputs[1].default_value = (.5, .5, 0)
|
|
links.new(difference.outputs[0], mask.inputs[0])
|
|
place = nodes.new('GeometryNodeSetPosition')
|
|
links.new(geometry, place.inputs['Geometry'])
|
|
links.new(movable, place.inputs['Selection'])
|
|
links.new(mask.outputs[0], place.inputs['Offset'])
|
|
join = nodes.new('GeometryNodeJoinGeometry')
|
|
links.new(place.outputs[0], join.inputs[0])
|
|
links.new(_mirror(group, place.outputs[0]), join.inputs[0])
|
|
geometry = join.outputs[0]
|
|
shade = nodes.new('GeometryNodeSetShadeSmooth')
|
|
shade.domain = 'FACE'
|
|
shade.inputs['Shade Smooth'].default_value = curved
|
|
links.new(geometry, shade.inputs['Geometry'])
|
|
return shade.outputs[0]
|
|
|
|
|
|
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, 'Fill Smooth', 'INPUT', 'NodeSocketInt', options)
|
|
smooth.default_value, smooth.min_value, smooth.max_value = 2, 0, 16
|
|
smooth.description = 'Smooth only the generated wall interiors. Zero keeps straight walls.'
|
|
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
|
|
nodes, links = group.nodes, group.links
|
|
inputs = group_input(nodes, set()).outputs
|
|
bounds = nodes.new('GeometryNodeBoundBox')
|
|
links.new(inputs['Geometry'], bounds.inputs['Geometry'])
|
|
size = nodes.new('ShaderNodeVectorMath')
|
|
size.operation = 'DISTANCE'
|
|
links.new(bounds.outputs['Min'], size.inputs[0])
|
|
links.new(bounds.outputs['Max'], size.inputs[1])
|
|
tolerance = _math(group, 'MULTIPLY', _math(group, 'MAXIMUM', size.outputs['Value'], 1e-8), 1e-7)
|
|
front = _delete_negative_faces(group, _align_front(group, inputs['Geometry'], inputs), tolerance)
|
|
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')
|
|
count = nodes.new('GeometryNodeInputMeshEdgeNeighbors')
|
|
boundary = _math(group, 'COMPARE', count.outputs['Face Count'], 1)
|
|
walls = nodes.new('GeometryNodeSwitch')
|
|
walls.input_type = 'GEOMETRY'
|
|
links.new(_math(group, 'GREATER_THAN', inputs['Fill Smooth'], 0), walls.inputs['Switch'])
|
|
for curved, socket in ((False, 'False'), (True, 'True')):
|
|
links.new(_walls(group, front, boundary, inputs['Fill Smooth'], tolerance, curved=curved), walls.inputs[socket])
|
|
enabled = nodes.new('GeometryNodeSwitch')
|
|
enabled.input_type = 'GEOMETRY'
|
|
links.new(inputs['Fill Sides'], enabled.inputs['Switch'])
|
|
links.new(walls.outputs[0], enabled.inputs['True'])
|
|
back = store_float_attribute(group, _mirror(group, front), FACE_ATTRIBUTE, 2., domain='FACE')
|
|
join = nodes.new('GeometryNodeJoinGeometry')
|
|
for geometry in (front, back, enabled.outputs[0]):
|
|
links.new(geometry, join.inputs[0])
|
|
merge = nodes.new('GeometryNodeMergeByDistance')
|
|
links.new(tolerance, merge.inputs['Distance'])
|
|
links.new(join.outputs[0], merge.inputs['Geometry'])
|
|
geometry = store_float_attribute(group, merge.outputs[0], AXIS_ATTRIBUTE, 0., domain='FACE')
|
|
links.new(geometry, nodes.new('NodeGroupOutput').inputs['Geometry'])
|
|
prepare_node_group(group)
|
|
return group
|