feat(win): T-WIN-005 VRM 加载 + humanoid 骨骼名映射
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
extends TestCase
|
||||
|
||||
## T-WIN-005:VRM 加载与 humanoid 骨骼映射
|
||||
##
|
||||
## 分两部分:
|
||||
## 1. 纯逻辑:HumanoidMap 对三类命名规范的识别(总是运行)
|
||||
## 2. 真实模型:加载真实 .vrm 并检查绑定结果(找不到模型文件时跳过)
|
||||
##
|
||||
## 真实模型不入库(许可证原因),由开发者放在 _scratch/sample.vrm,
|
||||
## 或通过 --vrm=<路径> 命令行参数指定。
|
||||
|
||||
const HumanoidMap = preload("res://src/model/humanoid_map.gd")
|
||||
const VrmLoader = preload("res://src/model/vrm_loader.gd")
|
||||
|
||||
const CANDIDATE_PATHS := [
|
||||
"res://_scratch/sample.vrm",
|
||||
"user://models/sample.vrm",
|
||||
]
|
||||
|
||||
|
||||
func _find_model_path() -> String:
|
||||
for path in CANDIDATE_PATHS:
|
||||
if FileAccess.file_exists(path):
|
||||
return path
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--vrm="):
|
||||
var path := arg.substr(6)
|
||||
if FileAccess.file_exists(path):
|
||||
return path
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------- 纯逻辑:命名规范识别 ----------------
|
||||
|
||||
func test_vroid_naming_is_recognized() -> void:
|
||||
var names := PackedStringArray([
|
||||
"J_Bip_C_Hips", "J_Bip_C_Spine", "J_Bip_C_Chest", "J_Bip_C_UpperChest",
|
||||
"J_Bip_C_Neck", "J_Bip_C_Head",
|
||||
"J_Bip_L_Shoulder", "J_Bip_L_UpperArm", "J_Bip_L_LowerArm", "J_Bip_L_Hand",
|
||||
"J_Bip_R_Shoulder", "J_Bip_R_UpperArm", "J_Bip_R_LowerArm", "J_Bip_R_Hand",
|
||||
"J_Bip_L_UpperLeg", "J_Bip_L_LowerLeg", "J_Bip_L_Foot", "J_Bip_L_ToeBase",
|
||||
"J_Bip_R_UpperLeg", "J_Bip_R_LowerLeg", "J_Bip_R_Foot", "J_Bip_R_ToeBase",
|
||||
])
|
||||
var matched := HumanoidMap.match_bones(names)
|
||||
eq(matched.size(), HumanoidMap.HUMANOID_BONES.size(), "VRoid 命名全部 22 根骨骼均被识别")
|
||||
eq(int(matched.get("hips", -1)), 0, "hips 命中 J_Bip_C_Hips")
|
||||
eq(int(matched.get("leftUpperArm", -1)), 7, "leftUpperArm 命中 J_Bip_L_UpperArm")
|
||||
eq(int(matched.get("leftLowerLeg", -1)), 15, "leftLowerLeg 命中 J_Bip_L_LowerLeg")
|
||||
eq(int(matched.get("rightToes", -1)), 21, "rightToes 命中 J_Bip_R_ToeBase")
|
||||
almost(HumanoidMap.coverage(matched), 1.0, 1e-6, "覆盖率 100%")
|
||||
|
||||
|
||||
func test_vrm_0x_generic_naming_is_recognized() -> void:
|
||||
var names := PackedStringArray([
|
||||
"Hips", "Spine", "Chest", "Neck", "Head",
|
||||
"LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand",
|
||||
"RightShoulder", "RightArm", "RightForeArm", "RightHand",
|
||||
"LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase",
|
||||
"RightUpLeg", "RightLeg", "RightFoot", "RightToeBase",
|
||||
])
|
||||
var matched := HumanoidMap.match_bones(names)
|
||||
eq(int(matched.get("hips", -1)), 0, "hips 命中 Hips")
|
||||
eq(int(matched.get("leftUpperArm", -1)), 6, "leftUpperArm 命中 LeftArm")
|
||||
eq(int(matched.get("leftLowerArm", -1)), 7, "leftLowerArm 命中 LeftForeArm")
|
||||
eq(int(matched.get("leftUpperLeg", -1)), 13, "leftUpperLeg 命中 LeftUpLeg")
|
||||
eq(int(matched.get("leftLowerLeg", -1)), 14, "leftLowerLeg 命中 LeftLeg")
|
||||
eq(int(matched.get("leftToes", -1)), 16, "leftToes 命中 LeftToeBase")
|
||||
|
||||
|
||||
func test_mixamo_naming_is_recognized() -> void:
|
||||
var names := PackedStringArray([
|
||||
"mixamorig:Hips", "mixamorig:Spine", "mixamorig:Spine1", "mixamorig:Neck", "mixamorig:Head",
|
||||
"mixamorig:LeftShoulder", "mixamorig:LeftArm", "mixamorig:LeftForeArm", "mixamorig:LeftHand",
|
||||
"mixamorig:RightShoulder", "mixamorig:RightArm", "mixamorig:RightForeArm", "mixamorig:RightHand",
|
||||
"mixamorig:LeftUpLeg", "mixamorig:LeftLeg", "mixamorig:LeftFoot", "mixamorig:LeftToeBase",
|
||||
"mixamorig:RightUpLeg", "mixamorig:RightLeg", "mixamorig:RightFoot", "mixamorig:RightToeBase",
|
||||
])
|
||||
var matched := HumanoidMap.match_bones(names)
|
||||
eq(int(matched.get("leftUpperArm", -1)), 6, "Mixamo 前缀被剥离,leftUpperArm 命中 LeftArm")
|
||||
eq(int(matched.get("rightUpperLeg", -1)), 17, "rightUpperLeg 命中 RightUpLeg")
|
||||
|
||||
|
||||
func test_vroid_helper_bones_do_not_shadow_real_bones() -> void:
|
||||
# VRoid 的扭转辅助骨:J_Aim_L_UpperLeg 等。若错误地剥离 j_aim 前缀,
|
||||
# 它会与真正的 J_Bip_L_UpperLeg 撞名并把动作驱动到错误的骨骼上。
|
||||
var names := PackedStringArray([
|
||||
"J_Bip_C_Hips",
|
||||
"J_Aim_L_UpperLeg", "J_Roll_L_UpperLeg", "J_Sec_L_TopsUpperLegBack",
|
||||
"J_Bip_L_UpperLeg",
|
||||
"J_Aim_R_UpperLeg", "J_Bip_R_UpperLeg",
|
||||
])
|
||||
var matched := HumanoidMap.match_bones(names)
|
||||
eq(int(matched.get("leftUpperLeg", -1)), 4, "leftUpperLeg 命中真正的 J_Bip_L_UpperLeg,而非 J_Aim/J_Roll")
|
||||
eq(int(matched.get("rightUpperLeg", -1)), 6, "rightUpperLeg 命中 J_Bip_R_UpperLeg")
|
||||
|
||||
|
||||
func test_unmapped_bones_are_reported() -> void:
|
||||
var names := PackedStringArray(["Hips", "Neck"])
|
||||
var matched := HumanoidMap.match_bones(names)
|
||||
var missing := HumanoidMap.missing_bones(matched)
|
||||
ok(missing.size() > 0, "缺失骨骼被列出", "缺失 %d 根" % missing.size())
|
||||
ok(missing.has("leftHand"), "leftHand 被列为缺失")
|
||||
is_false(missing.has("hips"), "已命中的 hips 不出现在缺失列表")
|
||||
|
||||
|
||||
func test_coverage_reflects_match_ratio() -> void:
|
||||
var full := HumanoidMap.match_bones(PackedStringArray(HumanoidMap.HUMANOID_BONES))
|
||||
var partial := HumanoidMap.match_bones(PackedStringArray(["Hips", "Neck", "Head"]))
|
||||
almost(HumanoidMap.coverage(full), 1.0, 1e-6, "全命中覆盖率 1.0")
|
||||
ok(HumanoidMap.coverage(partial) < 1.0, "部分命中覆盖率小于 1")
|
||||
ok(HumanoidMap.coverage(partial) > 0.0, "部分命中覆盖率大于 0")
|
||||
|
||||
|
||||
# ---------------- 真实模型 ----------------
|
||||
|
||||
func test_real_vrm_loads_with_skeleton_and_bones() -> void:
|
||||
var path := _find_model_path()
|
||||
if path == "":
|
||||
skip("真实 VRM 加载", "未找到模型文件(放置于 _scratch/sample.vrm 或传 --vrm=<路径>)")
|
||||
return
|
||||
|
||||
var model: VrmLoader.VrmModel = VrmLoader.load_from_file(path)
|
||||
not_null(model, "加载返回结果")
|
||||
|
||||
if not model.available:
|
||||
ok(false, "模型加载失败", model.error)
|
||||
return
|
||||
|
||||
is_true(model.available, "模型可用")
|
||||
eq(model.error, "", "无错误信息")
|
||||
not_null(model.root, "生成场景根节点")
|
||||
not_null(model.skeleton, "找到 Skeleton3D")
|
||||
ok(model.bone_count > 20, "骨骼数量合理", "实际 %d" % model.bone_count)
|
||||
ok(model.mesh_count > 0, "含网格", "实际 %d" % model.mesh_count)
|
||||
is_true(model.is_vrm, "识别为 VRM(含 VRM 扩展)")
|
||||
ne(model.vrm_version, "", "读到 VRM 版本")
|
||||
print(" → 模型:%s,VRM 版本 %s,%d 骨骼 / %d 网格,humanoid 覆盖率 %.0f%%" % [
|
||||
path, model.vrm_version, model.bone_count, model.mesh_count, model.coverage() * 100.0,
|
||||
])
|
||||
|
||||
ok(model.coverage() >= 0.8, "humanoid 骨骼覆盖率 ≥ 80%", "实际 %.0f%%,缺失:%s" % [
|
||||
model.coverage() * 100.0, ", ".join(model.missing_bones()),
|
||||
])
|
||||
is_true(model.is_drivable(), "具备驱动所需的最小骨骼集合", "缺失:%s" % ", ".join(model.missing_bones()))
|
||||
|
||||
# 抽查若干关键骨骼确实指向名字匹配的骨骼
|
||||
var checks := {
|
||||
"hips": "Hips",
|
||||
"leftHand": "Hand",
|
||||
"rightUpperLeg": "UpperLeg",
|
||||
}
|
||||
for canonical in checks:
|
||||
var index := model.bone_index(canonical)
|
||||
ok(index >= 0, "%s 已映射" % canonical)
|
||||
if index >= 0:
|
||||
var bone_name := model.skeleton.get_bone_name(index)
|
||||
contains(bone_name.to_lower(), str(checks[canonical]).to_lower(), "%s 映射到 %s" % [canonical, bone_name])
|
||||
# 辅助骨不得被选用
|
||||
is_false(bone_name.begins_with("J_Aim"), "%s 未误映射到 J_Aim 辅助骨(实际 %s)" % [canonical, bone_name])
|
||||
is_false(bone_name.begins_with("J_Roll"), "%s 未误映射到 J_Roll 辅助骨(实际 %s)" % [canonical, bone_name])
|
||||
|
||||
model.dispose()
|
||||
is_null(model.root, "dispose 后释放场景")
|
||||
|
||||
|
||||
func test_missing_file_reports_clear_error() -> void:
|
||||
var model: VrmLoader.VrmModel = VrmLoader.load_from_file("res://_scratch/definitely-not-here.vrm")
|
||||
is_false(model.available, "不存在的文件标记为不可用")
|
||||
contains(model.error, "不存在", "错误信息说明文件不存在")
|
||||
|
||||
|
||||
func test_vrm_version_detection_from_json() -> void:
|
||||
var v1 := VrmLoader.detect_vrm_version({"extensions": {"VRMC_vrm": {"specVersion": "1.0"}}})
|
||||
is_true(bool(v1["is_vrm"]), "VRMC_vrm 识别为 VRM")
|
||||
eq(str(v1["version"]), "1.0", "VRM 1.0 版本号正确")
|
||||
|
||||
var v0 := VrmLoader.detect_vrm_version({"extensions": {"VRM": {"specVersion": "0.0", "exporterVersion": "0.79"}}})
|
||||
is_true(bool(v0["is_vrm"]), "VRM 识别为 VRM")
|
||||
eq(str(v0["version"]), "0.0", "VRM 0.x 版本号正确")
|
||||
|
||||
var plain := VrmLoader.detect_vrm_version({"extensions": {}})
|
||||
is_false(bool(plain["is_vrm"]), "无 VRM 扩展的普通 glTF 不识别为 VRM")
|
||||
eq(str(plain["version"]), "", "普通 glTF 版本号为空")
|
||||
Reference in New Issue
Block a user