feat(body_mesh): Add body mesh separator module with edge dissolution

- Add body_mesh submodule with VRM_OT_separate_non_skin_faces operator
- Add VRM_PT_body_mesh_separator panel for UI integration
- Add utility functions for mesh operations and edge dissolution logic
- Add comprehensive edge dissolution configuration files for all body parts (head, torso, arms, legs, hands) with UV coordinates
- Add test utilities for validating mesh separation operations
- Add .gitignore to exclude Python cache files and test artifacts
- Register body_mesh module in editor package initialization
- Update registration.py to include new body mesh components
This commit is contained in:
2026-01-01 22:20:31 +08:00
parent 091ad6a49a
commit d70b36fd75
23 changed files with 87935 additions and 0 deletions

View File

@@ -1 +1,2 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from . import body_mesh

View File

@@ -0,0 +1,8 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from .ops import VRM_OT_separate_non_skin_faces
from .panel import VRM_PT_body_mesh_separator
__all__ = [
"VRM_OT_separate_non_skin_faces",
"VRM_PT_body_mesh_separator",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"description": "VRM模型手臂区域需要融并的边索引列表用于三角面转四边面",
"region": "arms",
"total_edges": 834,
"edge_indices": [
368, 369, 374, 379, 381, 383, 385, 388, 389, 392, 393, 396, 397, 400, 402, 404, 406, 407, 411, 417, 418, 419, 423, 425, 426, 428, 430, 432, 439, 442, 445, 452, 458, 460, 463, 465, 467, 470, 474, 479, 483, 486, 487, 490, 493, 495, 498, 502, 504, 506, 510, 515, 519, 521, 524,
726, 744, 746, 748, 754, 756, 757, 760, 763, 765, 768, 769, 771, 776, 779, 780, 781, 784, 791, 794, 797, 800, 803, 808, 812, 814, 817, 820, 822, 825, 829, 831, 835, 839, 841, 846, 848, 850, 851, 854, 858, 859, 864,
1637, 1640, 1642, 1752, 1753, 1756, 1760,
2638, 2644, 2645, 2649, 2650, 2656, 2657, 2673, 2676, 2677, 2682, 2690, 2693, 2696, 2700, 2702, 2703, 2705, 2707, 2709, 2710, 2713, 2716, 2721, 2726, 2734, 2735, 2736, 2739, 2742, 2745, 2749, 2750, 2755, 2762, 2763, 2765, 2767, 2768, 2773, 2778, 2780, 2782, 2786, 2793, 2798,
2966, 2968, 2978, 2980, 2983, 2986, 2988, 2992, 2999, 3001, 3003, 3006, 3011, 3012, 3013, 3018, 3022, 3025, 3028, 3032, 3039, 3042, 3044, 3048, 3051, 3055, 3058, 3060, 3065, 3066, 3067, 3070, 3072, 3074, 3076, 3080, 3081, 3082, 3085, 3086, 3090, 3091, 3092, 3095, 3100, 3104, 3105, 3109, 3110, 3111, 3114, 3117, 3120, 3121,
3766, 3767, 3775, 3873, 3880,
4786, 4791, 4793, 4802, 4805, 4813, 4819, 4822, 4823, 4830, 4831, 4836, 4841, 4844, 4847, 4849, 4851, 4854, 4859, 4861, 4864, 4866, 4871, 4872, 4875, 4878, 4879, 4883, 4886, 4887, 4889, 4892, 4896, 4897, 4898, 4902, 4907, 4909, 4913, 4915, 4916, 4917, 4918, 4921, 4926,
5104, 5105, 5110, 5113, 5116, 5119, 5122, 5125, 5128, 5134, 5136, 5140, 5142, 5144, 5150, 5154, 5156, 5157, 5162, 5168, 5175, 5178, 5181, 5182, 5187, 5193, 5198, 5201, 5204, 5212, 5215, 5219, 5221, 5224, 5228, 5229, 5233, 5236, 5237, 5238, 5247, 5248, 5250, 5252, 5255,
5904, 5906, 5907, 5911, 5916, 6027, 6030, 6036, 6038,
6894, 6896, 6898, 6900, 6906, 6907, 6910, 6912, 6915, 6918, 6921, 6930, 6931, 6933, 6937, 6939, 6940, 6951, 6954, 6957, 6958, 6965, 6970, 6974, 6977, 6983, 6985, 6990, 6992, 6996, 7000, 7001, 7002, 7010, 7011, 7015, 7018, 7020, 7021, 7022, 7027, 7028, 7030, 7036, 7040, 7045, 7050, 7051,
7233, 7240, 7241, 7244, 7245, 7248, 7249, 7252, 7253, 7256, 7257, 7260, 7261, 7264, 7265, 7268, 7269, 7272, 7274, 7276, 7278, 7279, 7283, 7289, 7290, 7291, 7295, 7297, 7298, 7300, 7302, 7304, 7311, 7314, 7317, 7324, 7330, 7332, 7335, 7337, 7339, 7340, 7342, 7346, 7349, 7352, 7355, 7356, 7359, 7360, 7361, 7369, 7370, 7374, 7379, 7381, 7386, 7389, 7391, 7398,
8033, 8153, 8158, 8161, 8163,
9113, 9114, 9119, 9120, 9125, 9127, 9133, 9134, 9137, 9138, 9141, 9142, 9145, 9146, 9148, 9150, 9151, 9155, 9156, 9159, 9161, 9169, 9172, 9174, 9176, 9177, 9181, 9182, 9188, 9193, 9195, 9201, 9206, 9208, 9211, 9212, 9217, 9218, 9223, 9225, 9231, 9235, 9236, 9243, 9246, 9251, 9255, 9256, 9260, 9266,
9481, 9484, 9485, 9490, 9498, 9501, 9504, 9508, 9510, 9511, 9513, 9515, 9517, 9518, 9522, 9524, 9529, 9534, 9536, 9539, 9543, 9546, 9548, 9551, 9553, 9559, 9562, 9563, 9566, 9567, 9572, 9574, 9576, 9579, 9581, 9585, 9592, 9594, 9595, 9599, 9602, 9603, 9604, 9606,
10291, 10293, 10294, 10297, 10419, 10420, 10428,
11283, 11284, 11292, 11293, 11309, 11311, 11313, 11319, 11321, 11322, 11325, 11328, 11330, 11333, 11334, 11336, 11341, 11344, 11345, 11346, 11349, 11356, 11358, 11362, 11365, 11368, 11373, 11375, 11377, 11378, 11379, 11381, 11385, 11386, 11390, 11392, 11397, 11399, 11400, 11403, 11406, 11408, 11411, 11413, 11414, 11419, 11422, 11425, 11430, 11432,
11619, 11629, 11632, 11640, 11646, 11649, 11650, 11657, 11658, 11663, 11668, 11671, 11673, 11678, 11681, 11686, 11688, 11691, 11694, 11698, 11699, 11702, 11706, 11710, 11713, 11715, 11717, 11719, 11725, 11728, 11729, 11730, 11734, 11735, 11741, 11742, 11749, 11750, 11753, 11757, 11760,
12379, 12380, 12383, 12387, 12504, 12506, 12507, 12511, 12516,
13427, 13429, 13431, 13436, 13442, 13444, 13447, 13450, 13452, 13456, 13463, 13465, 13467, 13470, 13475, 13476, 13477, 13482, 13486, 13489, 13492, 13496, 13501, 13504, 13506, 13509, 13513, 13515, 13516, 13517, 13520, 13522, 13525, 13527, 13529, 13533, 13539, 13540, 13545, 13546, 13549, 13552, 13556, 13557, 13560, 13564, 13570, 13572, 13575,
13758, 13759, 13764, 13765, 13768, 13771, 13774, 13777, 13780, 13783, 13786, 13795, 13796, 13798, 13802, 13804, 13805, 13816, 13819, 13822, 13823, 13830, 13835, 13839, 13842, 13846, 13848, 13853, 13855, 13858, 13861, 13867, 13870, 13872, 13876, 13877, 13878, 13883, 13884, 13887, 13890, 13895, 13899, 13902, 13905, 13906, 13909, 13915, 13916,
14530, 14537, 14658,
15540, 15542, 15545, 15548, 15549, 15555, 15558, 15561, 15564, 15570, 15572, 15576, 15578, 15580, 15586, 15590, 15592, 15593, 15598, 15604, 15611, 15614, 15617, 15618, 15623, 15628, 15632, 15636, 15639, 15642, 15645, 15649, 15650, 15654, 15655, 15663, 15665, 15666, 15668, 15673, 15674, 15677, 15679, 15680, 15682, 15684, 15687,
15873, 15874, 15879, 15880, 15883, 15884, 15887, 15888, 15891, 15892, 15895, 15896, 15899, 15900, 15903, 15904, 15906, 15908, 15909, 15913, 15914, 15917, 15919, 15927, 15930, 15932, 15934, 15935, 15939, 15940, 15946, 15951, 15953, 15956, 15959, 15964, 15966, 15970, 15974, 15978, 15981, 15982, 15988, 15990, 15993, 15996, 15998, 15999, 16007, 16011, 16012, 16020, 16021, 16022,
16814, 16818, 16821, 16827, 16829, 16951, 16953, 16954, 16957
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,293 @@
{
"description": "VRM模型手掌区域需要融并的边索引列表用于三角面转四边面",
"region": "hands",
"total_edges": 286,
"edge_indices": [
1696,
1700,
1709,
1716,
1723,
1727,
1732,
1736,
1742,
1744,
1747,
1809,
1811,
1814,
1816,
1820,
1821,
1827,
1828,
1829,
1831,
1835,
1839,
1841,
1844,
1847,
1852,
1856,
1860,
1864,
1868,
1869,
1874,
1876,
3823,
3825,
3826,
3829,
3830,
3835,
3837,
3838,
3839,
3845,
3850,
3852,
3859,
3864,
3867,
3869,
3934,
3936,
3937,
3942,
3948,
3953,
3960,
3963,
3965,
3971,
3973,
3975,
3978,
3979,
3982,
3986,
3992,
3995,
3998,
5960,
5963,
5964,
5971,
5973,
5981,
5984,
5988,
5992,
5994,
5999,
6000,
6003,
6006,
6008,
6016,
6019,
6021,
6023,
6089,
6092,
6093,
6095,
6099,
6107,
6109,
6110,
6114,
6117,
6120,
6124,
6130,
6134,
6135,
6138,
6140,
6147,
6150,
6151,
8089,
8099,
8101,
8102,
8105,
8111,
8116,
8128,
8135,
8137,
8139,
8141,
8143,
8145,
8147,
8150,
8217,
8221,
8230,
8237,
8246,
8251,
8256,
8261,
8263,
8266,
10348,
10352,
10356,
10358,
10361,
10366,
10368,
10370,
10374,
10377,
10381,
10383,
10385,
10386,
10394,
10395,
10399,
10403,
10408,
10409,
10413,
10470,
10478,
10480,
10481,
10484,
10485,
10490,
10492,
10493,
10494,
10500,
10505,
10514,
10518,
10522,
10524,
12435,
12437,
12440,
12442,
12446,
12447,
12453,
12454,
12455,
12457,
12461,
12465,
12467,
12470,
12471,
12473,
12478,
12482,
12486,
12490,
12494,
12495,
12500,
12502,
12564,
12565,
12572,
12574,
12582,
12585,
12589,
12593,
12595,
12601,
12603,
12604,
12607,
12609,
12617,
12620,
12623,
12624,
14591,
14593,
14594,
14599,
14606,
14610,
14617,
14620,
14622,
14628,
14630,
14633,
14634,
14637,
14641,
14647,
14650,
14653,
14710,
14720,
14722,
14723,
14726,
14732,
14737,
14749,
14756,
14758,
14760,
14762,
14764,
14766,
14768,
14771,
16880,
16883,
16884,
16886,
16891,
16898,
16900,
16901,
16905,
16908,
16911,
16915,
16929,
16931,
16936,
16938,
16941,
16942,
17008,
17012,
17016,
17018,
17021,
17026,
17028,
17030,
17034,
17037,
17041,
17043,
17045,
17046,
17054,
17055,
17059,
17063,
17067,
17069,
17073
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"description": "VRM模型头部区域需要融并的边索引列表用于三角面转四边面",
"region": "head",
"total_edges": 420,
"edge_indices": [
2107, 2109, 2114, 2116, 2122, 2123, 2126, 2128, 2129, 2131, 2133, 2136, 2139, 2141, 2146, 2155, 2159, 2163, 2165, 2168, 2170, 2173, 2178, 2179, 2183, 2185, 2190, 2195, 2198, 2209, 2211, 2215, 2217, 2219, 2221, 2226, 2228, 2233, 2236, 2239, 2242, 2246, 2247, 2248, 2252, 2253, 2254, 2259, 2262, 2263, 2266, 2267, 2271, 2275, 2278,
4245, 4247, 4252, 4254, 4255, 4256, 4259, 4265, 4273, 4281, 4282, 4291, 4294, 4295, 4299, 4305, 4307, 4308, 4310, 4314, 4318, 4321, 4327, 4329, 4337, 4342, 4343, 4347, 4348, 4352, 4356, 4361, 4365, 4366, 4368, 4372, 4377, 4378, 4380, 4382, 4384, 4390, 4391, 4395, 4400, 4404, 4405, 4406, 4411, 4412,
6362, 6363, 6368, 6370, 6373, 6380, 6382, 6386, 6388, 6390, 6393, 6397, 6400, 6404, 6408, 6411, 6412, 6418, 6420, 6421, 6422, 6425, 6433, 6438, 6441, 6448, 6453, 6457, 6460, 6466, 6469, 6474, 6478, 6481, 6483, 6485, 6491, 6494, 6497, 6498, 6500, 6503, 6506, 6507, 6513, 6518, 6519, 6521, 6526, 6527, 6530, 6531,
8579, 8582, 8587, 8590, 8593, 8595, 8596, 8601, 8606, 8609, 8610, 8612, 8615, 8617, 8623, 8629, 8630, 8636, 8640, 8643, 8648, 8655, 8657, 8658, 8662, 8665, 8667, 8668, 8670, 8671, 8676, 8678, 8685, 8687, 8691, 8694, 8696, 8698, 8699, 8703, 8707, 8708, 8710, 8712, 8714, 8718, 8719, 8721, 8727, 8731, 8733, 8735, 8738, 8745,
10758, 10760, 10765, 10771, 10772, 10777, 10780, 10782, 10783, 10785, 10789, 10790, 10797, 10802, 10811, 10812, 10823, 10824, 10827, 10830, 10831, 10832, 10838, 10844, 10845, 10852, 10858, 10859, 10863, 10866, 10870, 10873, 10874, 10875, 10876, 10877, 10881, 10885, 10891, 10896, 10899, 10902, 10905, 10906, 10910, 10916, 10921, 10922, 10925, 10926,
12887, 12892, 12894, 12897, 12899, 12904, 12905, 12912, 12917, 12920, 12923, 12926, 12931, 12933, 12936, 12939, 12940, 12941, 12944, 12952, 12954, 12955, 12958, 12961, 12964, 12966, 12967, 12973, 12979, 12984, 12986, 12989, 12992, 12995, 12998, 13002, 13004, 13008, 13011, 13014, 13016, 13018, 13026, 13027, 13032, 13036, 13038, 13041, 13042, 13046,
14999, 15001, 15006, 15008, 15011, 15012, 15013, 15018, 15019, 15022, 15026, 15027, 15028, 15033, 15039, 15040, 15044, 15047, 15051, 15056, 15061, 15063, 15066, 15069, 15070, 15071, 15072, 15076, 15078, 15082, 15083, 15086, 15093, 15097, 15100, 15103, 15106, 15109, 15110, 15113, 15118, 15120, 15121, 15129, 15130, 15133, 15134, 15138, 15140, 15144, 15146, 15149, 15153, 15154, 15155, 15158, 15160, 15169,
17278, 17280, 17285, 17287, 17292, 17293, 17296, 17297, 17300, 17305, 17307, 17309, 17312, 17320, 17321, 17324, 17326, 17329, 17332, 17337, 17339, 17342, 17344, 17348, 17349, 17352, 17355, 17362, 17365, 17367, 17370, 17372, 17375, 17379, 17383, 17386, 17388, 17393, 17396, 17399, 17400, 17405, 17410, 17415, 17419, 17425, 17428, 17433, 17435, 17441, 17442
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

220
editor/body_mesh/ops.py Normal file
View File

@@ -0,0 +1,220 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from collections.abc import Set as AbstractSet
from typing import TYPE_CHECKING, Optional
import bpy
from bpy.types import Context, Operator
from .utils import (
MaterialInfo,
find_body_mesh,
generate_unique_name,
get_material_face_count,
is_skin_material,
)
if TYPE_CHECKING:
from bpy.types import Object
class VRM_OT_separate_non_skin_faces(Operator):
"""将Body网格中非皮肤材质的面分离为独立对象"""
bl_idname = "vrm.separate_non_skin_faces"
bl_label = "Separate Non-Skin Faces"
bl_description = "将Body网格中非皮肤材质的面分离为独立对象"
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: Context) -> bool:
"""检查是否可以执行分离操作"""
armature = cls._get_armature(context)
if armature is None:
return False
body_mesh = find_body_mesh(armature)
if body_mesh is None:
return False
# 检查是否有非皮肤材质
mesh_data = body_mesh.data
if mesh_data is None:
return False
for material in mesh_data.materials:
if material is not None and not is_skin_material(material.name):
return True
return False
@classmethod
def _get_armature(cls, context: Context) -> Optional["Object"]:
"""获取当前选中的Armature对象"""
active_object = context.active_object
if active_object is None:
return None
if active_object.type == "ARMATURE":
return active_object
# 如果选中的是网格检查其父对象是否为Armature
if active_object.parent is not None and active_object.parent.type == "ARMATURE":
return active_object.parent
return None
def execute(self, context: Context) -> set[str]:
"""执行分离操作"""
try:
return self._execute_separation(context)
except Exception as e:
self.report({"ERROR"}, f"分离操作失败: {str(e)}")
return {"CANCELLED"}
def _execute_separation(self, context: Context) -> set[str]:
"""执行分离操作的核心逻辑"""
armature = self._get_armature(context)
if armature is None:
self.report({"ERROR"}, "未找到Armature对象")
return {"CANCELLED"}
body_mesh = find_body_mesh(armature)
if body_mesh is None:
self.report({"ERROR"}, "未找到Body网格对象")
return {"CANCELLED"}
mesh_data = body_mesh.data
if mesh_data is None:
self.report({"ERROR"}, "Body网格数据无效")
return {"CANCELLED"}
# 收集非皮肤材质信息
non_skin_materials: list[MaterialInfo] = []
for i, material in enumerate(mesh_data.materials):
if material is not None and not is_skin_material(material.name):
face_count = get_material_face_count(mesh_data, i)
if face_count > 0:
non_skin_materials.append(
MaterialInfo(
name=material.name,
index=i,
face_count=face_count,
is_skin=False,
)
)
if not non_skin_materials:
self.report({"INFO"}, "没有找到非皮肤材质的面")
return {"FINISHED"}
# 获取现有对象名称集合
existing_names = set(obj.name for obj in context.blend_data.objects)
# 为每个非皮肤材质执行分离
separated_count = 0
for mat_info in non_skin_materials:
result = self._separate_material_faces(
context, body_mesh, armature, mat_info, existing_names
)
if result:
separated_count += 1
existing_names.add(result.name)
if separated_count > 0:
self.report({"INFO"}, f"成功分离了 {separated_count} 个材质的面")
return {"FINISHED"}
else:
self.report({"WARNING"}, "没有成功分离任何面")
return {"CANCELLED"}
def _separate_material_faces(
self,
context: Context,
body_obj: "Object",
armature: "Object",
mat_info: MaterialInfo,
existing_names: set[str],
) -> Optional["Object"]:
"""分离指定材质的面为新对象"""
mesh_data = body_obj.data
if mesh_data is None:
return None
# 确保在对象模式
if context.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
# 取消所有选择
bpy.ops.object.select_all(action="DESELECT")
# 选择Body网格并设为活动对象
body_obj.select_set(True)
context.view_layer.objects.active = body_obj
# 进入编辑模式
bpy.ops.object.mode_set(mode="EDIT")
# 取消所有选择
bpy.ops.mesh.select_all(action="DESELECT")
# 切换到面选择模式
bpy.ops.mesh.select_mode(type="FACE")
# 返回对象模式以访问网格数据
bpy.ops.object.mode_set(mode="OBJECT")
# 选择使用指定材质的面
for polygon in mesh_data.polygons:
if polygon.material_index == mat_info.index:
polygon.select = True
# 返回编辑模式执行分离
bpy.ops.object.mode_set(mode="EDIT")
# 执行分离操作
bpy.ops.mesh.separate(type="SELECTED")
# 返回对象模式
bpy.ops.object.mode_set(mode="OBJECT")
# 查找新创建的对象
new_obj = self._find_new_separated_object(context, body_obj)
if new_obj is None:
return None
# 生成唯一名称
base_name = self._generate_object_name(mat_info.name)
unique_name = generate_unique_name(base_name, existing_names)
new_obj.name = unique_name
# 设置父子关系保持与原Armature的关系
new_obj.parent = armature
new_obj.parent_type = "OBJECT"
return new_obj
def _find_new_separated_object(
self, context: Context, original_obj: "Object"
) -> Optional["Object"]:
"""查找分离操作后新创建的对象"""
# 分离后,新对象会被选中,原对象也会被选中
for obj in context.selected_objects:
if obj != original_obj and obj.type == "MESH":
return obj
return None
def _generate_object_name(self, material_name: str) -> str:
"""根据材质名称生成对象名称"""
# 简化材质名称,移除常见前缀和后缀
simplified_name = material_name
# 移除 "(Instance)" 后缀
if simplified_name.endswith(" (Instance)"):
simplified_name = simplified_name[:-11]
# 移除常见的VRM材质前缀
prefixes_to_remove = ["N00_000_00_", "N00_001_00_", "N00_002_00_"]
for prefix in prefixes_to_remove:
if simplified_name.startswith(prefix):
simplified_name = simplified_name[len(prefix) :]
break
return f"Body_{simplified_name}"

113
editor/body_mesh/panel.py Normal file
View File

@@ -0,0 +1,113 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from collections.abc import Set as AbstractSet
from typing import TYPE_CHECKING, Optional
from bpy.types import Context, Panel
from .utils import (
MaterialInfo,
find_body_mesh,
get_material_face_count,
is_skin_material,
)
if TYPE_CHECKING:
from bpy.types import Object
class VRM_PT_body_mesh_separator(Panel):
"""Body网格分离面板"""
bl_idname = "VRM_PT_body_mesh_separator"
bl_label = "Body Mesh Separator"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "VRM"
bl_options: AbstractSet[str] = set()
@classmethod
def poll(cls, context: Context) -> bool:
"""检查是否应该显示面板 - 仅在选中包含Body网格的Armature时显示"""
armature = cls._get_armature(context)
if armature is None:
return False
body_mesh = find_body_mesh(armature)
return body_mesh is not None
@classmethod
def _get_armature(cls, context: Context) -> Optional["Object"]:
"""获取当前选中的Armature对象"""
active_object = context.active_object
if active_object is None:
return None
if active_object.type == "ARMATURE":
return active_object
# 如果选中的是网格检查其父对象是否为Armature
if active_object.parent is not None and active_object.parent.type == "ARMATURE":
return active_object.parent
return None
def draw(self, context: Context) -> None:
"""绘制面板UI"""
layout = self.layout
armature = self._get_armature(context)
if armature is None:
layout.label(text="未找到Armature对象", icon="ERROR")
return
body_mesh = find_body_mesh(armature)
if body_mesh is None:
layout.label(text="未找到Body网格对象", icon="ERROR")
return
mesh_data = body_mesh.data
if mesh_data is None:
layout.label(text="Body网格数据无效", icon="ERROR")
return
# 显示材质统计信息
box = layout.box()
box.label(text="材质列表", icon="MATERIAL")
# 收集材质信息
materials_info: list[MaterialInfo] = []
for i, material in enumerate(mesh_data.materials):
if material is not None:
face_count = get_material_face_count(mesh_data, i)
is_skin = is_skin_material(material.name)
materials_info.append(
MaterialInfo(
name=material.name,
index=i,
face_count=face_count,
is_skin=is_skin,
)
)
# 显示材质列表
has_non_skin = False
for mat_info in materials_info:
row = box.row()
if mat_info.is_skin:
row.label(text=f"[皮肤] {mat_info.name}", icon="CHECKMARK")
else:
row.label(text=f"[非皮肤] {mat_info.name}", icon="MESH_DATA")
has_non_skin = True
row.label(text=f"{mat_info.face_count}")
# 显示分离操作按钮
layout.separator()
col = layout.column()
col.enabled = has_non_skin
col.operator(
"vrm.separate_non_skin_faces",
text="分离非皮肤面",
icon="MESH_DATA",
)
if not has_non_skin:
layout.label(text="没有非皮肤材质可分离", icon="INFO")

View File

@@ -0,0 +1,60 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
"""
Property-based tests for body_mesh utility functions.
Feature: vrm-body-mesh-separator
"""
from hypothesis import given, settings
from hypothesis import strategies as st
from .utils import generate_unique_name, is_skin_material
class TestIsSkinMaterial:
"""
Property 2: 材质分类正确性
*For any* 材质名称字符串,`is_skin_material`函数应该返回True当且仅当该名称包含"SKIN"子字符串(不区分大小写)。
**Validates: Requirements 2.2, 2.3**
"""
@given(st.text())
@settings(max_examples=100)
def test_skin_material_classification_property(self, material_name: str) -> None:
"""
Feature: vrm-body-mesh-separator, Property 2: 材质分类正确性
For any material name string, is_skin_material returns True
if and only if the name contains "SKIN" (case-insensitive).
"""
result = is_skin_material(material_name)
expected = "SKIN" in material_name.upper()
assert result == expected, (
f"is_skin_material('{material_name}') returned {result}, "
f"expected {expected}"
)
class TestGenerateUniqueName:
"""
Property 6: 唯一命名保证
*For any* 基础名称和已存在名称集合,`generate_unique_name`返回的名称不应该在已存在名称集合中。
**Validates: Requirements 4.2**
"""
@given(
st.text(min_size=1, max_size=50).filter(lambda x: x.strip()),
st.sets(st.text(min_size=1, max_size=50), max_size=20),
)
@settings(max_examples=100)
def test_unique_name_generation_property(
self, base_name: str, existing_names: set[str]
) -> None:
"""
Feature: vrm-body-mesh-separator, Property 6: 唯一命名保证
For any base name and set of existing names, generate_unique_name
returns a name that is not in the existing names set.
"""
result = generate_unique_name(base_name, existing_names)
assert result not in existing_names, (
f"generate_unique_name('{base_name}', {existing_names}) "
f"returned '{result}' which is in existing_names"
)

88
editor/body_mesh/utils.py Normal file
View File

@@ -0,0 +1,88 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from bpy.types import Mesh, Object
@dataclass
class MaterialInfo:
"""材质信息数据类"""
name: str
index: int
face_count: int
is_skin: bool
def find_body_mesh(armature: "Object") -> Optional["Object"]:
"""
在Armature的子对象中查找名为"Body"的网格对象
Args:
armature: Armature对象
Returns:
Body网格对象如果不存在则返回None
"""
if armature is None:
return None
for child in armature.children:
if child.type == "MESH" and child.name == "Body":
return child
return None
def is_skin_material(material_name: str) -> bool:
"""
判断材质是否为皮肤材质
Args:
material_name: 材质名称
Returns:
如果材质名包含"SKIN"则返回True不区分大小写
"""
return "SKIN" in material_name.upper()
def get_material_face_count(mesh: "Mesh", material_index: int) -> int:
"""
获取指定材质的面数量
Args:
mesh: 网格数据
material_index: 材质索引
Returns:
使用该材质的面数量
"""
count = 0
for polygon in mesh.polygons:
if polygon.material_index == material_index:
count += 1
return count
def generate_unique_name(base_name: str, existing_names: set[str]) -> str:
"""
生成唯一的对象名称
Args:
base_name: 基础名称
existing_names: 已存在的名称集合
Returns:
唯一的名称
"""
if base_name not in existing_names:
return base_name
suffix = 1
while True:
candidate = f"{base_name}.{suffix:03d}"
if candidate not in existing_names:
return candidate
suffix += 1