Files
AnyImage/tools/nodes/groups/image_cutout_symmetry.py
nan 4035f5c9e4 Refactor geometry generation to standardize Blender object coordinates
- 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.
2026-09-15 13:33:37 +08:00

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