"""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