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"
|
readme = "README.md"
|
||||||
authors = [{ name = "Yuki Kitagawa", email = "yuki.kitagawa@pm.me" }]
|
authors = [{ name = "Yuki Kitagawa", email = "yuki.kitagawa@pm.me" }]
|
||||||
requires-python = ">=3.11,<3.12"
|
requires-python = ">=3.11,<3.12"
|
||||||
dependencies = [
|
dependencies = ["fake-bpy-module-latest>=20250505"]
|
||||||
"fake-bpy-module-latest>=20250505",
|
|
||||||
]
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
reportInvalidTypeForm = false
|
||||||
|
@ -11,7 +11,7 @@ bl_info = {
|
|||||||
# Test if the libraries are already loaded
|
# Test if the libraries are already loaded
|
||||||
# This is useful for reloading the addon during development
|
# 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
|
# Purge any existing modules with 'solstice.' prefix
|
||||||
for module_name in list(sys.modules.keys()):
|
for module_name in list(sys.modules.keys()):
|
||||||
if module_name.startswith("solstice."):
|
if module_name.startswith("solstice."):
|
||||||
@ -20,6 +20,7 @@ if "solstice.align_island" in sys.modules:
|
|||||||
|
|
||||||
# from .align_island import AlignIsland
|
# from .align_island import AlignIsland
|
||||||
from .export_constraint import ExportConstraint
|
from .export_constraint import ExportConstraint
|
||||||
|
from .mirror_mesh import MirrorMeshOperator
|
||||||
import bpy
|
import bpy
|
||||||
|
|
||||||
|
|
||||||
@ -35,18 +36,29 @@ def bone_menu_func(self, context):
|
|||||||
self.layout.operator(ExportConstraint.bl_idname)
|
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():
|
def register():
|
||||||
# bpy.utils.register_class(AlignIsland)
|
# bpy.utils.register_class(AlignIsland)
|
||||||
|
bpy.utils.register_class(MirrorMeshOperator)
|
||||||
bpy.utils.register_class(ExportConstraint)
|
bpy.utils.register_class(ExportConstraint)
|
||||||
bpy.types.IMAGE_MT_uvs.append(uv_menu_func)
|
bpy.types.IMAGE_MT_uvs.append(uv_menu_func)
|
||||||
bpy.types.VIEW3D_MT_object.append(bone_menu_func)
|
bpy.types.VIEW3D_MT_object.append(bone_menu_func)
|
||||||
|
bpy.types.VIEW3D_MT_edit_mesh.append(mirror_menu_func)
|
||||||
|
|
||||||
|
|
||||||
def unregister():
|
def unregister():
|
||||||
bpy.types.IMAGE_MT_uvs.remove(uv_menu_func)
|
bpy.types.IMAGE_MT_uvs.remove(uv_menu_func)
|
||||||
bpy.types.VIEW3D_MT_object.remove(bone_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(AlignIsland)
|
||||||
bpy.utils.unregister_class(ExportConstraint)
|
bpy.utils.unregister_class(ExportConstraint)
|
||||||
|
bpy.utils.unregister_class(MirrorMeshOperator)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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