From a703b010ee3a96bced047f2e915162c9cf01c9a9 Mon Sep 17 00:00:00 2001 From: Yuki Kitagawa Date: Sat, 17 May 2025 23:12:31 +0800 Subject: [PATCH] feat: Add mirror mesh tool --- .vscode/settings.json | 4 +- __pycache__/test.cpython-311.pyc | Bin 0 -> 1265 bytes pyproject.toml | 7 +- src/solstice/__init__.py | 14 +- src/solstice/mirror_mesh.py | 348 +++++++++++++++++++++++++++++++ 5 files changed, 366 insertions(+), 7 deletions(-) create mode 100644 __pycache__/test.cpython-311.pyc create mode 100644 src/solstice/mirror_mesh.py 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 0000000000000000000000000000000000000000..8725d34c6457b61d70d95725775a46bc8ef8beb6 GIT binary patch literal 1265 zcmZuxL2Khi6n>I5w(M2vCV`M;167u`Sk2nB$1aOQ+M09^>s>aNl67kw&)8NXOJX!S ztZ}Ied+Z^H!j_iOP)bTk&;1oWNJY>fSSXYpdUHv8*;C)xvDswv^ycY(-@Nx`=4sH6 z3kwB6;XjmmOa=H;f|+RN&e<>MJOv6=abOozV6d;uf~blJGpF)%<{15xIo{1sjcFZ~ zk|)|O!AwH4$C!IE&sStULUUKL`ju?{O15yBrOFOi*Z!6ps}DZ=q_p|q&R#2EO}Eu& z)km%JV5_|QcKO{#rBQA)Ki=51N-`v{DQgDIuRc1Tux4!48Sz_q{MdpP_*X_!0izn860Q7qInA9g*mDW;T!l?y?i(^ zZ!y5p3qBS&mmyrt`BWJy{LKrs)uD1E*L_KjkvW;OSFKOAos0X-TwF~78DWvZhXoVm z1GX1-%}(gHh3&bf@JyfEt}sQ9nUj8)fk2{bwp?o3A~5?c(e9a?1)(D$X z<{)=ZPNGw+d*^{2FLY_8ZfPl*6i$op-LKc0^{sog=EK@{?W2@TDyI1^_d=iAZDFZg zUU06X31kON^Z8AByYYZ~`>ZX3M!Us@*9bf(5O$k21PesPf1DC3&%&=^MqZ|JG$Z&= z2U3U%Hx6rPP-AT^(bghu?U`}ocVqoGWBurj*eE4NX;eGS8Dq$h^=JA*^vcFpt>>x(M->9RY>!Do;$tJg9V3mE1~Z*UU4e#46+9< zV-;thPv6MA+QI(ftZ%yqk7bONO;yg)T-)pWw!_lG-R=7Qhqd>&c4|B)t7q_=OwRce za03Z0e-+R5JAIFa4y*EIgz~Be-=i646h%3O{Alw0v9xx0^UJr!Dv)jUv|xNu9+S+n z`VXM_SDI0AY~+>o!{ssH>#|t|B|9RcgBX?)Sc+ij6bNE3hLr?XB3PN?-si<9#Zhqz a_G4H~U@?Nl368R>F=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