feat: Add mirror mesh tool

This commit is contained in:
Yuki Kitagawa 2025-05-17 23:12:31 +08:00
parent 4f9c5acde0
commit a703b010ee
5 changed files with 366 additions and 7 deletions

View File

@ -1,3 +1 @@
{
"python.analysis.typeCheckingMode": "basic"
}
{}

Binary file not shown.

View File

@ -5,10 +5,11 @@ description = "Add your description here"
readme = "README.md"
authors = [{ name = "Yuki Kitagawa", email = "yuki.kitagawa@pm.me" }]
requires-python = ">=3.11,<3.12"
dependencies = [
"fake-bpy-module-latest>=20250505",
]
dependencies = ["fake-bpy-module-latest>=20250505"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pyright]
reportInvalidTypeForm = false

View File

@ -11,7 +11,7 @@ bl_info = {
# Test if the libraries are already loaded
# This is useful for reloading the addon during development
if "solstice.align_island" in sys.modules:
if any(map(lambda x: x.startswith("solstice."), sys.modules.keys())):
# Purge any existing modules with 'solstice.' prefix
for module_name in list(sys.modules.keys()):
if module_name.startswith("solstice."):
@ -20,6 +20,7 @@ if "solstice.align_island" in sys.modules:
# from .align_island import AlignIsland
from .export_constraint import ExportConstraint
from .mirror_mesh import MirrorMeshOperator
import bpy
@ -35,18 +36,29 @@ def bone_menu_func(self, context):
self.layout.operator(ExportConstraint.bl_idname)
def mirror_menu_func(self, context):
self.layout.separator()
self.layout.label(text="Solstice Toolbox", icon="PLUGIN")
self.layout.operator(MirrorMeshOperator.bl_idname)
def register():
# bpy.utils.register_class(AlignIsland)
bpy.utils.register_class(MirrorMeshOperator)
bpy.utils.register_class(ExportConstraint)
bpy.types.IMAGE_MT_uvs.append(uv_menu_func)
bpy.types.VIEW3D_MT_object.append(bone_menu_func)
bpy.types.VIEW3D_MT_edit_mesh.append(mirror_menu_func)
def unregister():
bpy.types.IMAGE_MT_uvs.remove(uv_menu_func)
bpy.types.VIEW3D_MT_object.remove(bone_menu_func)
bpy.types.VIEW3D_MT_edit_mesh.remove(mirror_menu_func)
# bpy.utils.unregister_class(AlignIsland)
bpy.utils.unregister_class(ExportConstraint)
bpy.utils.unregister_class(MirrorMeshOperator)
if __name__ == "__main__":

348
src/solstice/mirror_mesh.py Normal file
View File

@ -0,0 +1,348 @@
"""
Ensures a mesh is mirrored while preserving bone weight data.
"""
import bpy
import typing
import re
import bmesh
from dataclasses import dataclass
from bpy.types import Mesh, Object as BObject
from bmesh.types import BMesh, BMVert
from typing import Optional
from mathutils import Vector
if typing.TYPE_CHECKING:
from bpy._typing.rna_enums import OperatorReturnItems
BONE_NAME_REGEX = re.compile(
r"^(?P<base_name>[a-zA-Z0-9\.\-\_]+?)((?P<side_sep>[\.\-\_])(?P<side>l|r|left|right))?((?P<number_sep>[\.\-\_])(?P<number>\d+))?$",
flags=re.IGNORECASE,
)
def is_valid_side(side: str) -> bool:
return side.lower() in {"l", "r", "left", "right"}
def side_is_left(side: str) -> bool:
return side.lower() in {"l", "left"}
def flip_side(side: str) -> str:
# detect the case of the side
if side.isupper():
case = "upper"
elif side[0].isupper():
case = "capital"
else:
case = "lower"
def to_case(s: str) -> str:
if case == "upper":
return s.upper()
elif case == "capital":
return s.capitalize()
else:
return s.lower()
match side.lower():
case "l":
return to_case("r")
case "left":
return to_case("right")
case "r":
return to_case("l")
case "right":
return to_case("left")
case _:
raise ValueError(f"Invalid side: {side}")
def get_side(vg_name) -> str | None:
match = BONE_NAME_REGEX.match(vg_name)
if not match:
raise ValueError(f"Invalid vertex group name: {repr(vg_name)}")
return match.group("side")
def recreate_vg_name(vg_name: str, side: str) -> str:
match = BONE_NAME_REGEX.match(vg_name)
if not match:
raise ValueError(f"Invalid vertex group name: {repr(vg_name)}")
if match.group("side") is None:
raise ValueError(f"Vertex group name does not have a side: {repr(vg_name)}")
res = match.group("base_name")
if match.group("side_sep") is not None:
res += match.group("side_sep")
res += side
if match.group("number_sep") is not None:
res += match.group("number_sep")
if match.group("number") is not None:
res += match.group("number")
return res
def vg_side_flipped(vg_name) -> str:
match = BONE_NAME_REGEX.match(vg_name)
if not match:
raise ValueError(f"Invalid vertex group name: {repr(vg_name)}")
side = match.group("side")
if side is None:
raise ValueError(f"Vertex group name does not have a side: {repr(vg_name)}")
new_side = flip_side(side)
return recreate_vg_name(vg_name, new_side)
@dataclass
class MirrorResult:
"""
Represents the result of a mirror operation.
"""
mirror_plane_vtx: set[int]
lr_mapping: dict[int, int]
class BoneName:
"""
Represents a bone name in `<base_name>(.<l/r>)(.<number>)` format, where:
- `.` is the separator, either `.` or `_` or `-`.
- `<base_name>` is the base name of the bone.
- `<l/r>` is the side of the bone, case insensitive: one of `l`, `r`, `left`, `right`.
"""
base_name: str
side: Optional[str] = None
side_sep: Optional[str] = "."
number: Optional[int] = None
number_sep: Optional[str] = "."
def __init__(self, name: str):
match = BONE_NAME_REGEX.match(name)
if not match:
raise ValueError(f"Invalid bone name: {repr(name)}")
self.base_name = match.group("base_name")
self.side = match.group("side")
self.side_sep = match.group("side_sep")
self.number = int(match.group("number")) if match.group("number") else None
self.number_sep = match.group("number_sep")
class MirrorMeshOperator(bpy.types.Operator):
"""Mirror a mesh while preserving bone weight data"""
bl_idname = "solstice.mirror_mesh"
bl_label = "Mirror Mesh"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context) -> set["OperatorReturnItems"]:
# Get the active object
obj = context.active_object
if obj is None or obj.type != "MESH":
self.report({"ERROR"}, "No active mesh object found")
return {"CANCELLED"}
# Ensure the object is in edit mode
if obj.mode != "EDIT":
self.report({"ERROR"}, "Object must be in edit mode")
return {"CANCELLED"}
# Get the mesh data
mesh = obj.data
if mesh is None or not isinstance(mesh, bpy.types.Mesh):
self.report({"ERROR"}, "No mesh data found")
return {"CANCELLED"}
# We don't care about UVs for now; we'll revisit this later
# TODO: Add UV mirroring support
bm = bmesh.from_edit_mesh(mesh)
if bm is None:
self.report({"ERROR"}, "No bmesh data found")
return {"CANCELLED"}
res = self._do_mirror(mesh, bm)
bm.free()
# Get out of edit mode
bpy.ops.object.mode_set(mode="OBJECT")
self._mirror_weights(res, obj)
# Get back into edit mode
bpy.ops.object.mode_set(mode="EDIT")
return {"FINISHED"}
def _do_mirror(self, mesh: Mesh, bm: BMesh):
# We only mirror from left to right, right now.
mirror_plane_vtx: set[BMVert] = set()
left_vtx: set[BMVert] = set()
right_vtx: set[BMVert] = set()
for vtx in bm.verts:
x = vtx.co.x
if abs(x) < 0.000001:
mirror_plane_vtx.add(vtx)
elif x < 0:
right_vtx.add(vtx)
else:
left_vtx.add(vtx)
# Delete all vertices in the right side
for vtx in right_vtx:
bm.verts.remove(vtx)
# Create a new vertex for each vertex in the left side
left_right_map: dict[BMVert, BMVert] = {}
for vtx in left_vtx:
new_vtx = bm.verts.new((-vtx.co.x, vtx.co.y, vtx.co.z))
new_vtx.normal = Vector((-vtx.normal.x, vtx.normal.y, vtx.normal.z))
left_right_map[vtx] = new_vtx
# Recreate any edges and faces that were connected to the original vertex
recreated_edges = set()
recreated_faces = set()
for vtx in left_vtx:
for edge in vtx.link_edges:
if edge in recreated_edges:
continue
other = edge.other_vert(vtx)
try:
if other in left_right_map:
# Create a new edge between the new vertex and the other vertex
bm.edges.new((left_right_map[vtx], left_right_map[other]), edge)
elif other in mirror_plane_vtx:
# Create a new edge between the new vertex and the other vertex
bm.edges.new((left_right_map[vtx], other), edge)
else:
# This edge is not mirrored, so we can just delete it
pass
except ValueError:
# This edge already exists, so we can skip it
pass
for face in vtx.link_faces:
if face in recreated_faces:
continue
recreated_faces.add(face)
new_verts = []
for vert in face.verts:
if vert in left_right_map:
new_verts.append(left_right_map[vert])
elif vert in mirror_plane_vtx:
new_verts.append(vert)
else:
# This vertex is not mirrored, so we can just delete it
pass
# Create a new face with the new vertices
try:
new_face = bm.faces.new(new_verts, face)
new_face.normal_flip() # because the vertex order is reversed
except ValueError:
# This face already exists, so we can skip it
pass
# Update vertex indexes
bm.verts.index_update()
# Update the mesh
bmesh.update_edit_mesh(mesh, loop_triangles=True, destructive=True)
# Return the mapping of left to right vertices
res = MirrorResult(
mirror_plane_vtx={vtx.index for vtx in mirror_plane_vtx},
lr_mapping={
vtx.index: new_vtx.index for vtx, new_vtx in left_right_map.items()
},
)
return res
def _mirror_weights(self, res: MirrorResult, obj: BObject):
vgs = obj.vertex_groups
for vg in vgs.values():
if vg is None:
continue
def get_weight(vg, weights, vtx: int):
try:
w = vg.weight(vtx)
weights[vtx] = w
except Exception:
pass
# Dump weights for later ops
weights = {}
for vtx in res.lr_mapping.keys():
get_weight(vg, weights, vtx)
for vtx in res.mirror_plane_vtx:
get_weight(vg, weights, vtx)
side = get_side(vg.name)
if side is None:
# Mirror weights across the mirror plane
for vtx, new_vtx in res.lr_mapping.items():
if vtx in weights:
weight = weights[vtx]
vg.add([new_vtx], weight, "REPLACE")
else:
vg.remove([new_vtx])
# Vertices on the mirror plane should keep their weights
elif side_is_left(side):
# Handle the left and right sides together
right_vg_name = vg_side_flipped(vg.name)
right_side = vgs.get(right_vg_name)
if right_side is None:
# Create a new vertex group for the right side
right_side = vgs.new(name=right_vg_name)
# Collect the weights on the left side of the mesh for the flip side
right_weights: dict[int, float] = {}
for vtx in res.lr_mapping.keys():
get_weight(right_side, right_weights, vtx)
for vtx in res.mirror_plane_vtx:
get_weight(right_side, right_weights, vtx)
# Apply the weights to the left group
# Essentially:
# - weight originally on the left side is unchanged
# - weight collected from (the left side of) the right group is added to the mirrored vertex
# - weights on the mirror plane are unchanged (they are not iterated anyway)
# - other weights are removed
for left, right in res.lr_mapping.items():
if left in weights:
pass # unchanged
else:
# Remove the weight from the left side
vg.remove([left])
if left in right_weights:
# Add the weight to the right side
right_side.add([right], right_weights[left], "REPLACE")
else:
# Remove the weight from the right side
right_side.remove([right])
# Apply to the right group
# The right group is recreated completely from the left group,
# except for the weights on the mirror plane, which are not
# iterated anyway
for left, right in res.lr_mapping.items():
if left in weights:
# Add the weight to the right side
right_side.add([right], weights[left], "REPLACE")
else:
# Remove the weight from the right side
right_side.remove([right])
if left in right_weights:
right_side.add([left], right_weights[left], "REPLACE")
else:
# Remove the weight from the right side
right_side.remove([left])
else:
pass