- Unified the canonical local frame for generated geometries (Plane, Depth Plane, Relief Plane, Cutout, Depth Cutout) to align with Blender's object coordinate system, setting X as horizontal, Z as up, and +Y as depth. - Removed the `Depth Axis` parameter from `O Image Cutout` and `O Image Depth Cutout`, simplifying the geometry generation process. - Updated the depth direction defaults to use +Y instead of -Z for `O Image Relief Plane` and Depth Symmetry. - Adjusted the geometry nodes to ensure thickness, projection, and shell displacements operate along the Y-axis. - Implemented new capabilities for handling object space normals to align with the new canonical frame. - Enhanced the Depth Symmetry functionality to mirror across the canonical Y-axis and perform a final rotation to maintain the correct orientation. - Added tests to validate the new coordinate system and ensure compatibility with existing assets.
211 lines
11 KiB
Python
211 lines
11 KiB
Python
"""Mirror completed depth geometry and fill matching boundaries."""
|
|
import bpy
|
|
from mathutils import Matrix
|
|
|
|
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
|
|
from ..common.direction import symmetry_legacy_direction_to_canonical
|
|
|
|
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
|
|
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 _delete_positive_faces(group, front, tolerance):
|
|
"""Discard faces touching positive Y 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])
|
|
positive = _math(group, 'GREATER_THAN', components.outputs['Y'], tolerance)
|
|
point_positive = evaluate_field(group, positive, 'FLOAT', 'POINT')
|
|
face_positive = evaluate_field(group, point_positive, '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_positive, 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, y_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, y_factor, 1)
|
|
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, 0, .5)
|
|
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_positive_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, 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'])
|
|
links.new(orient.outputs[0], nodes.new('NodeGroupOutput').inputs['Geometry'])
|
|
prepare_node_group(group)
|
|
return group
|