feat: Add mirror mesh tool
This commit is contained in:
parent
4f9c5acde0
commit
a703b010ee
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -1,3 +1 @@
|
||||
{
|
||||
"python.analysis.typeCheckingMode": "basic"
|
||||
}
|
||||
{}
|
||||
|
BIN
__pycache__/test.cpython-311.pyc
Normal file
BIN
__pycache__/test.cpython-311.pyc
Normal file
Binary file not shown.
@ -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
|
||||
|
@ -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
348
src/solstice/mirror_mesh.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user