Files
2026-03-17 14:58:51 -06:00

195 lines
6.6 KiB
Python

# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Operator
import bpy
from bpy.props import BoolProperty, EnumProperty, FloatProperty
from mathutils.kdtree import KDTree
def mirror_mesh(
*,
reference_verts: list,
vertices: list,
axis: str,
symmetrize=False,
symmetrize_pos_to_neg=True,
) -> tuple[int, int]:
"""
Symmetrize vertices around any axis in any direction based on a set of
reference vertices which share the same vertex order and are known to be
symmetrical.
This is useful for symmetrizing shape keys if the shape key's symmetry was
accidentally ruined. The reference verts can be the base shape, and the
vertices to modify can be the vertices of the shape key.
The return values describe what happened to how many verts.
If mirror==True, don't symmetrize one half of the mesh on top of the other half,
but instead, just mirror the whole thing.
"""
assert axis in 'XYZ', "Axis must be X, Y or Z!"
assert len(reference_verts) == len(
vertices
), "Reference vertices and vertices to be modified should have equal length!"
size = len(reference_verts)
kd = KDTree(size)
for i, v in enumerate(reference_verts):
kd.insert(v.co, i)
kd.balance()
coord_i = 'XYZ'.find(axis)
if symmetrize:
# Figure out the function that will be used to determine whether a vertex
# should be skipped or not, when symmetrize==True
zero_or_more = lambda x: x >= 0
zero_or_less = lambda x: x <= 0
skip_func = zero_or_more if symmetrize_pos_to_neg else zero_or_less
# Count number of vertices successfully symmetrized.
good_counter = 0
# Count number of vertices where the number of opposite vertices found in
# the reference vertices is not exactly 1.
# If this goes above 0, the reference verts were assymetrical, so the result
# will be wrong.
bad_counter = 0
affected_vert_idxs = []
# Store a copy of the un-modified vertices (only important when mirror=True)
orig_coords = [v.co.copy() for v in vertices]
# For any vertex with an X coordinate > 0, try to find a vertex at
# the coordinate with X flipped.
for i, ref_vert in enumerate(reference_verts):
if symmetrize and abs(ref_vert.co[coord_i]) < 0.0001:
# If we are symmetrizing and a vertex falls on the symmetry axis,
# its offset on the symmetry axis should be exactly 0.
vertices[i].co[coord_i] = 0.0
continue
if symmetrize and skip_func(ref_vert.co[coord_i]):
continue
flipped_co = ref_vert.co.copy()
flipped_co[coord_i] *= -1
_opposite_co, opposite_idx, dist = kd.find(flipped_co)
opposite_vert = vertices[opposite_idx]
if dist > 0.1: # pretty big threshold, for testing.
# TODO: Keep count of how many vertices were skipped and report it.
bad_counter += 1
continue
if opposite_idx in affected_vert_idxs:
# This vertex was already symmetrized, and another vertex just matched with it.
# No way to tell which is correct. Input mesh should just be more symmetrical.
bad_counter += 1
continue
opposite_vert.co = orig_coords[i].copy()
opposite_vert.co[coord_i] *= -1
good_counter += 1
affected_vert_idxs.append(opposite_idx)
return good_counter, bad_counter
class OBJECT_OT_symmetrize_shape_key(Operator):
"""Symmetrize shape key by matching vertex pairs by proximity in the original mesh"""
# NOTE: This script expects a mesh whose base shape is symmetrical, and symmetrize the
# active shape key based on the symmetry of the base mesh.
bl_idname = "object.symmetrize_shape_key"
bl_label = "Symmetrize Shape Key"
bl_options = {'REGISTER', 'UNDO'}
all_keys: BoolProperty(
name="All Keys",
description="Symmetrize all shape keys, including Basis and disabled ones",
default=False,
)
direction: EnumProperty(
name="Direction",
items=[
("NEGX_TO_X", "-X to X", "-X to X"),
("X_TO_NEGX", "X to -X", "X to -X"),
("NEGY_TO_Y", "-Y to Y", "-Y to Y"),
("Y_TO_NEGY", "Y to -Y", "Y to -Y"),
("NEGZ_TO_Z", "-Z to Z", "-Z to Z"),
("Z_TO_NEGZ", "Z to -Z", "Z to -Z"),
],
)
threshold: FloatProperty(
name="Threshold",
description="Distance threshold for matching vertex pairs in the basis shape. Lower values demand more precise symmetry from the base mesh, but will result in fewer mismatches",
default=0.0001,
min=0.000001,
max=0.1,
)
def draw(self, context):
layout = self.layout
layout.prop(self, 'all_keys')
layout.prop(self, 'direction')
layout.prop(self, 'threshold', slider=True)
def execute(self, context):
obj = context.object
mesh = obj.data
if 'X' in self.direction:
axis = 'X'
elif 'Y' in self.direction:
axis = 'Y'
elif 'Z' in self.direction:
axis = 'Z'
pos_to_neg = not self.direction.startswith('NEG')
key_blocks = [obj.active_shape_key]
if self.all_keys:
# TODO: This could be more optimized, right now we re-build the kdtree for each key block unneccessarily.
key_blocks = obj.data.shape_keys.key_blocks[:]
for kb in key_blocks:
good_counter, bad_counter = mirror_mesh(
reference_verts=mesh.vertices,
vertices=kb.data,
axis=axis,
symmetrize=True,
symmetrize_pos_to_neg=pos_to_neg,
)
if bad_counter > 0:
self.report(
{'WARNING'},
f"{bad_counter} vertices failed to symmetrize. Your base mesh is not symmetrical, result won't be perfect!",
)
else:
self.report(
{'INFO'}, f"Symmetrize fully successful (Affected {good_counter} vertices)."
)
return {'FINISHED'}
def draw_symmetrize_buttons(self, context):
layout = self.layout
layout.separator()
op = layout.operator(OBJECT_OT_symmetrize_shape_key.bl_idname, text="Symmetrize Active")
op = layout.operator(OBJECT_OT_symmetrize_shape_key.bl_idname, text="Symmetrize All")
op.all_keys = True
registry = [OBJECT_OT_symmetrize_shape_key]
def register():
bpy.types.MESH_MT_shape_key_context_menu.append(draw_symmetrize_buttons)
def unregister():
bpy.types.MESH_MT_shape_key_context_menu.remove(draw_symmetrize_buttons)