diff --git a/.vscode/settings.json b/.vscode/settings.json index dc3f727..0967ef4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1 @@ -{ - "python.analysis.typeCheckingMode": "basic" -} +{} diff --git a/__pycache__/test.cpython-311.pyc b/__pycache__/test.cpython-311.pyc new file mode 100644 index 0000000..8725d34 Binary files /dev/null and b/__pycache__/test.cpython-311.pyc differ diff --git a/pyproject.toml b/pyproject.toml index 2919202..1c8b04f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/src/solstice/__init__.py b/src/solstice/__init__.py index a1e187d..8c6e48c 100644 --- a/src/solstice/__init__.py +++ b/src/solstice/__init__.py @@ -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__": diff --git a/src/solstice/mirror_mesh.py b/src/solstice/mirror_mesh.py new file mode 100644 index 0000000..00cadc5 --- /dev/null +++ b/src/solstice/mirror_mesh.py @@ -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[a-zA-Z0-9\.\-\_]+?)((?P[\.\-\_])(?Pl|r|left|right))?((?P[\.\-\_])(?P\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 `(.)(.)` format, where: + - `.` is the separator, either `.` or `_` or `-`. + - `` is the base name of the bone. + - `` 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