using UnityEditor; using UnityEngine; using UnityEngine.UIElements; using UnityEditor.UIElements; namespace KindRetargeting { [CustomEditor(typeof(CustomRetargetingScript))] public class CustomRetargetingScriptEditor : BaseRetargetingEditor { private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss"; private SerializedObject groundingSO; // SerializedProperty private SerializedProperty sourceAnimatorProp; private SerializedProperty targetAnimatorProp; private SerializedProperty hipsOffsetXProp; private SerializedProperty hipsOffsetYProp; private SerializedProperty hipsOffsetZProp; private SerializedProperty debugAxisNormalizerProp; private SerializedProperty fingerCopyModeProp; private SerializedProperty kneeInOutWeightProp; private SerializedProperty kneeFrontBackWeightProp; private SerializedProperty footFrontBackOffsetProp; private SerializedProperty footInOutOffsetProp; private SerializedProperty floorHeightProp; private SerializedProperty avatarScaleProp; // Dynamic UI private Label cacheStatusLabel; protected override void OnDisable() { base.OnDisable(); groundingSO = null; } protected override void OnEnable() { base.OnEnable(); sourceAnimatorProp = serializedObject.FindProperty("sourceAnimator"); targetAnimatorProp = serializedObject.FindProperty("targetAnimator"); hipsOffsetXProp = serializedObject.FindProperty("hipsOffsetX"); hipsOffsetYProp = serializedObject.FindProperty("hipsOffsetY"); hipsOffsetZProp = serializedObject.FindProperty("hipsOffsetZ"); debugAxisNormalizerProp = serializedObject.FindProperty("debugAxisNormalizer"); fingerCopyModeProp = serializedObject.FindProperty("fingerCopyMode"); kneeInOutWeightProp = serializedObject.FindProperty("kneeInOutWeight"); kneeFrontBackWeightProp = serializedObject.FindProperty("kneeFrontBackWeight"); footFrontBackOffsetProp = serializedObject.FindProperty("footFrontBackOffset"); footInOutOffsetProp = serializedObject.FindProperty("footInOutOffset"); floorHeightProp = serializedObject.FindProperty("floorHeight"); avatarScaleProp = serializedObject.FindProperty("avatarScale"); } public override VisualElement CreateInspectorGUI() { var root = new VisualElement(); var commonUss = AssetDatabase.LoadAssetAtPath(CommonUssPath); if (commonUss != null) root.styleSheets.Add(commonUss); // 원본 Animator root.Add(new PropertyField(sourceAnimatorProp, "원본 Animator")); // 아바타 크기 설정 var scaleFoldout = new Foldout { text = "아바타 크기 설정", value = true }; scaleFoldout.Add(new PropertyField(avatarScaleProp, "아바타 크기")); root.Add(scaleFoldout); // 힙 위치 보정 root.Add(BuildHipsSection()); // 무릎 위치 조정 var kneeFoldout = new Foldout { text = "무릎 위치 조정", value = true }; kneeFoldout.Add(new Slider("무릎 앞/뒤 가중치", -1f, 1f) { showInputField = true, tooltip = "음수: 뒤로, 양수: 앞으로" }); kneeFoldout.Q().BindProperty(kneeFrontBackWeightProp); var kneeInOut = new Slider("무릎 안/밖 가중치", -1f, 1f) { showInputField = true, tooltip = "음수: 안쪽, 양수: 바깥쪽" }; kneeInOut.BindProperty(kneeInOutWeightProp); kneeFoldout.Add(kneeInOut); root.Add(kneeFoldout); // 발 IK 위치 조정 var footFoldout = new Foldout { text = "발 IK 위치 조정", value = true }; var footFB = new Slider("발 앞/뒤 오프셋", -1f, 1f) { showInputField = true, tooltip = "+: 앞으로, -: 뒤로" }; footFB.BindProperty(footFrontBackOffsetProp); footFoldout.Add(footFB); var footIO = new Slider("발 벌리기/모으기", -1f, 1f) { showInputField = true, tooltip = "+: 벌리기, -: 모으기" }; footIO.BindProperty(footInOutOffsetProp); footFoldout.Add(footIO); root.Add(footFoldout); // 손가락 복제 설정 root.Add(BuildFingerCopySection()); // 바닥 높이 조정 var floorFoldout = new Foldout { text = "바닥 높이 조정", value = false }; floorFoldout.Add(new PropertyField(floorHeightProp, "바닥 높이 (-1 ~ 1)")); root.Add(floorFoldout); // 접지 설정 (FootGroundingController) root.Add(BuildGroundingSection()); // 캐시 상태 + 캘리브레이션 버튼 root.Add(BuildCacheSection()); // 변경 시 저장 root.TrackSerializedObjectValue(serializedObject, so => { if (target == null) return; var script = (CustomRetargetingScript)target; if (script.targetAnimator != null) script.SaveSettings(); }); return root; } private VisualElement BuildHipsSection() { var foldout = new Foldout { text = "힙 위치 보정 (로컬 좌표계)" }; // 축 매핑 정보 var axisInfo = new HelpBox("플레이 모드에서 T-포즈 분석 후 축 매핑 정보가 표시됩니다.\n이 매핑은 각 아바타의 힙 로컬 축 방향에 맞춰 자동 계산됩니다.", HelpBoxMessageType.Info); foldout.Add(axisInfo); // 주기적으로 축 매핑 정보 갱신 foldout.schedule.Execute(() => { if (target == null || debugAxisNormalizerProp == null) return; serializedObject.Update(); Vector3 axisMapping = debugAxisNormalizerProp.vector3Value; if (Application.isPlaying && axisMapping != Vector3.one) { string GetAxisName(float value) { int axis = Mathf.RoundToInt(Mathf.Abs(value)); string sign = value > 0 ? "+" : "-"; return axis switch { 1 => $"{sign}X", 2 => $"{sign}Y", 3 => $"{sign}Z", _ => "?" }; } axisInfo.text = "T-포즈에서 분석된 축 매핑:\n" + $" 좌우 오프셋 → 로컬 {GetAxisName(axisMapping.x)} 축\n" + $" 상하 오프셋 → 로컬 {GetAxisName(axisMapping.y)} 축\n" + $" 앞뒤 오프셋 → 로컬 {GetAxisName(axisMapping.z)} 축\n\n" + "이 매핑 덕분에 모든 아바타에서 동일하게 작동합니다."; } else { axisInfo.text = "플레이 모드에서 T-포즈 분석 후 축 매핑 정보가 표시됩니다.\n이 매핑은 각 아바타의 힙 로컬 축 방향에 맞춰 자동 계산됩니다."; } }).Every(500); foldout.Add(new PropertyField(hipsOffsetXProp, "좌우 오프셋 (←-/+→)") { tooltip = "캐릭터 기준 왼쪽(-) / 오른쪽(+)" }); foldout.Add(new PropertyField(hipsOffsetYProp, "상하 오프셋 (↓-/+↑)") { tooltip = "캐릭터 기준 아래(-) / 위(+)" }); foldout.Add(new PropertyField(hipsOffsetZProp, "앞뒤 오프셋 (←-/+→)") { tooltip = "캐릭터 기준 뒤(-) / 앞(+)" }); foldout.Add(new HelpBox("로컬 좌표계 기반: 캐릭터의 회전 상태와 관계없이 항상 캐릭터 기준으로 이동합니다.", HelpBoxMessageType.Info)); // 다리 길이 자동 보정 버튼 var autoHipsBtn = new Button(() => { if (!Application.isPlaying) { Debug.LogWarning("다리 길이 자동 보정은 플레이 모드에서만 사용 가능합니다."); return; } var script = (CustomRetargetingScript)target; float offset = CalculateHipsOffsetFromLegDifference(script); hipsOffsetYProp.floatValue = offset; serializedObject.ApplyModifiedProperties(); script.SaveSettings(); Debug.Log($"자동 보정 완료: hipsOffsetY = {offset:F4}"); }) { text = "다리 길이 자동 보정", tooltip = "소스/타겟 다리 길이 차이로 힙 상하 오프셋을 자동 계산합니다. (플레이 모드 전용)" }; autoHipsBtn.style.marginTop = 4; foldout.Add(autoHipsBtn); return foldout; } private VisualElement BuildFingerCopySection() { var foldout = new Foldout { text = "손가락 복제 설정" }; foldout.Add(new PropertyField(fingerCopyModeProp, "복제 방식") { tooltip = "손가락 포즈를 복제하는 방식을 선택합니다." }); return foldout; } private VisualElement BuildGroundingSection() { var foldout = new Foldout { text = "접지 설정 (FootGrounding)", value = false }; foldout.Add(new HelpBox( "HIK 스타일 2-Pass 접지 시스템:\n" + "• Pre-IK: IK 타겟을 조정하여 발이 바닥을 뚫지 않도록 보정\n" + "• Post-IK: Foot 회전으로 Toes 접지 잔차 미세 보정\n" + "• Toe Pivot: 발끝 고정 + 발목 회전 자동 감지", HelpBoxMessageType.Info)); // FootGroundingController의 SerializedObject를 직접 바인딩 var script = (CustomRetargetingScript)target; var grounding = script.GetComponent(); if (grounding != null) { groundingSO = new SerializedObject(grounding); var groundHeightField = new PropertyField(groundingSO.FindProperty("groundHeight"), "바닥 높이"); foldout.Add(groundHeightField); var weightSlider = new Slider("접지 강도", 0f, 1f) { showInputField = true }; weightSlider.BindProperty(groundingSO.FindProperty("groundingWeight")); foldout.Add(weightSlider); var activationField = new PropertyField(groundingSO.FindProperty("activationHeight"), "활성화 높이"); activationField.tooltip = "발목이 이 높이 이상이면 접지 보정 비활성화 (점프 등)"; foldout.Add(activationField); var thresholdField = new PropertyField(groundingSO.FindProperty("plantThreshold"), "접지 판정 범위"); thresholdField.tooltip = "Toes가 이 범위 안이면 접지 중으로 판정"; foldout.Add(thresholdField); var smoothField = new PropertyField(groundingSO.FindProperty("smoothSpeed"), "보정 스무딩 속도"); smoothField.tooltip = "보정량 변화 속도 (높을수록 빠른 반응, 낮으면 부드러운 전환)"; foldout.Add(smoothField); foldout.Add(new HelpBox("힙 높이 보정은 '바닥 높이 조정' 섹션의 floorHeight로 제어합니다.", HelpBoxMessageType.Info)); foldout.TrackSerializedObjectValue(groundingSO, so => so.ApplyModifiedProperties()); } else { foldout.Add(new HelpBox("FootGroundingController 컴포넌트를 찾을 수 없습니다.", HelpBoxMessageType.Warning)); } return foldout; } private VisualElement BuildCacheSection() { var container = new VisualElement { style = { marginTop = 8 } }; var script = (CustomRetargetingScript)target; bool hasCached = script.HasCachedSettings(); cacheStatusLabel = new Label(hasCached ? "캘리브레이션 데이터가 저장되어 있습니다." : "저장된 캘리브레이션 데이터가 없습니다."); cacheStatusLabel.style.unityFontStyleAndWeight = FontStyle.Bold; container.Add(cacheStatusLabel); var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } }; var calibBtn = new Button(() => { ((CustomRetargetingScript)target).I_PoseCalibration(); UpdateCacheStatus(); }) { text = "I-포즈 캘리브레이션" }; calibBtn.style.flexGrow = 1; calibBtn.style.marginRight = 2; btnRow.Add(calibBtn); var deleteCacheBtn = new Button(() => { ((CustomRetargetingScript)target).ResetPoseAndCache(); UpdateCacheStatus(); }) { text = "캐시 데이터 삭제" }; deleteCacheBtn.style.flexGrow = 1; btnRow.Add(deleteCacheBtn); container.Add(btnRow); // 주기적으로 캐시 상태 갱신 container.schedule.Execute(() => { if (target == null) return; bool cached = ((CustomRetargetingScript)target).HasCachedSettings(); deleteCacheBtn.style.display = cached ? DisplayStyle.Flex : DisplayStyle.None; cacheStatusLabel.text = cached ? "캘리브레이션 데이터가 저장되어 있습니다." : "저장된 캘리브레이션 데이터가 없습니다."; }).Every(1000); deleteCacheBtn.style.display = hasCached ? DisplayStyle.Flex : DisplayStyle.None; return container; } private void UpdateCacheStatus() { if (cacheStatusLabel == null || target == null) return; bool hasCached = ((CustomRetargetingScript)target).HasCachedSettings(); cacheStatusLabel.text = hasCached ? "캘리브레이션 데이터가 저장되어 있습니다." : "저장된 캘리브레이션 데이터가 없습니다."; } /// /// 소스/타겟 다리 길이 차이로 힙 상하 오프셋을 계산합니다. /// 소스 다리가 더 길면 → 음수 (힙을 내려서 타겟이 뜨지 않게) /// 소스 다리가 더 짧으면 → 양수 (힙을 올려서 타겟 다리를 펴줌) /// private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script) { Animator source = script.sourceAnimator; Animator target = script.targetAnimator; if (source == null || target == null || !source.isHuman || !target.isHuman) { Debug.LogWarning("소스/타겟 Animator가 없거나 Humanoid가 아닙니다."); return 0f; } float sourceLeg = GetLegLength(source); float targetLeg = GetLegLength(target); if (sourceLeg < 0.01f || targetLeg < 0.01f) { Debug.LogWarning("다리 길이를 계산할 수 없습니다. 본이 올바르게 설정되어 있는지 확인해주세요."); return 0f; } // 소스 다리가 더 길면 타겟이 뜨므로 힙을 내려야 함 (음수) // 소스 다리가 더 짧으면 타겟 다리가 구부러지므로 힙을 올려야 함 (양수) float diff = targetLeg - sourceLeg; Debug.Log($"소스 다리 길이: {sourceLeg:F4}, 타겟 다리 길이: {targetLeg:F4}, 힙 오프셋: {diff:F4}m"); return diff; } private float GetLegLength(Animator animator) { Transform upperLeg = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg); Transform lowerLeg = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg); Transform foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot); if (upperLeg == null || lowerLeg == null || foot == null) return 0f; float upper = Vector3.Distance(upperLeg.position, lowerLeg.position); float lower = Vector3.Distance(lowerLeg.position, foot.position); return upper + lower; } } }