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 版本号为空")