From e17cfc003c38575d201eee94630927ebce55a7ee Mon Sep 17 00:00:00 2001 From: user Date: Sat, 7 Mar 2026 23:34:33 +0900 Subject: [PATCH] =?UTF-8?q?Refactor=20:=20CustomRetargetingScriptEditor=20?= =?UTF-8?q?=EB=A6=AC=EB=94=94=EC=9E=90=EC=9D=B8=20+=20RemoteController=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inspector에 모든 내부 모듈 섹션 추가: - 어깨 보정 (blendStrength, maxBlend, heightDiff, curve) - 사지 가중치 (IK 토글, 거리/높이 범위, 스무딩) - 접지 설정 (groundHeight, weight, activationHeight, plantThreshold, smoothSpeed) - 손가락 셰이핑 (활성화, 손가락별 슬라이더, 프리셋 버튼) - 머리 회전 오프셋 (정면 캘리브레이션 포함) - 머리/아바타 크기 - RetargetingRemoteController에 추가된 원격 제어 데이터: - enableLeftArmIK, enableRightArmIK - shoulderBlendStrength, shoulderMaxBlend, shoulderMaxHeightDiff, shoulderMinHeightDiff - groundingWeight, groundingGroundHeight, groundingActivationHeight, groundingPlantThreshold, groundingSmoothSpeed Co-Authored-By: Claude Opus 4.6 --- .../Editor/CustomRetargetingScriptEditor.cs | 649 +++++++++++++----- .../Remote/RetargetingRemoteController.cs | 62 +- 2 files changed, 517 insertions(+), 194 deletions(-) diff --git a/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.cs b/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.cs index 469202c70..3bf161e96 100644 --- a/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.cs +++ b/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.cs @@ -10,46 +10,9 @@ namespace KindRetargeting { private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss"; - // 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 삭제됨 — footGrounding은 CRS 내부 모듈 - } - - 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() @@ -59,52 +22,68 @@ namespace KindRetargeting var commonUss = AssetDatabase.LoadAssetAtPath(CommonUssPath); if (commonUss != null) root.styleSheets.Add(commonUss); - // 원본 Animator - root.Add(new PropertyField(sourceAnimatorProp, "원본 Animator")); + // ── 기본 설정 ── + root.Add(new PropertyField(serializedObject.FindProperty("sourceAnimator"), "원본 Animator")); - // 아바타 크기 설정 + // ── 아바타 크기 ── var scaleFoldout = new Foldout { text = "아바타 크기 설정", value = true }; - scaleFoldout.Add(new PropertyField(avatarScaleProp, "아바타 크기")); + scaleFoldout.Add(new PropertyField(serializedObject.FindProperty("avatarScale"), "아바타 크기")); + scaleFoldout.Add(new PropertyField(serializedObject.FindProperty("headScale"), "머리 크기")); 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); + // ── 무릎 위치 조정 ── + var kneeFoldout = new Foldout { text = "무릎 위치 조정", value = false }; + var kneeFB = new Slider("무릎 앞/뒤", -1f, 1f) { showInputField = true }; + kneeFB.BindProperty(serializedObject.FindProperty("kneeFrontBackWeight")); + kneeFoldout.Add(kneeFB); + var kneeIO = new Slider("무릎 안/밖", -1f, 1f) { showInputField = true }; + kneeIO.BindProperty(serializedObject.FindProperty("kneeInOutWeight")); + kneeFoldout.Add(kneeIO); root.Add(kneeFoldout); - // 발 IK 위치 조정 - var footFoldout = new Foldout { text = "발 IK 위치 조정", value = true }; - var footFB = new Slider("발 앞/뒤 오프셋", -1f, 1f) { showInputField = true, tooltip = "+: 앞으로, -: 뒤로" }; - footFB.BindProperty(footFrontBackOffsetProp); + // ── 발 IK 위치 조정 ── + var footFoldout = new Foldout { text = "발 IK 위치 조정", value = false }; + var footFB = new Slider("발 앞/뒤", -1f, 1f) { showInputField = true }; + footFB.BindProperty(serializedObject.FindProperty("footFrontBackOffset")); footFoldout.Add(footFB); - var footIO = new Slider("발 벌리기/모으기", -1f, 1f) { showInputField = true, tooltip = "+: 벌리기, -: 모으기" }; - footIO.BindProperty(footInOutOffsetProp); + var footIO = new Slider("발 벌리기/모으기", -1f, 1f) { showInputField = true }; + footIO.BindProperty(serializedObject.FindProperty("footInOutOffset")); footFoldout.Add(footIO); root.Add(footFoldout); - // 손가락 복제 설정 - root.Add(BuildFingerCopySection()); - - // 바닥 높이 조정 + // ── 바닥 높이 ── var floorFoldout = new Foldout { text = "바닥 높이 조정", value = false }; - floorFoldout.Add(new PropertyField(floorHeightProp, "바닥 높이 (-1 ~ 1)")); - + floorFoldout.Add(new PropertyField(serializedObject.FindProperty("floorHeight"), "바닥 높이 (-1 ~ 1)")); + floorFoldout.Add(new PropertyField(serializedObject.FindProperty("minimumAnkleHeight"), "최소 발목 높이")); root.Add(floorFoldout); - // 접지 설정 (FootGroundingController) + // ── 머리 회전 오프셋 ── + root.Add(BuildHeadRotationSection()); + + // ── 어깨 보정 (ShoulderCorrection) ── + root.Add(BuildShoulderSection()); + + // ── 사지 가중치 (LimbWeight) ── + root.Add(BuildLimbWeightSection()); + + // ── 접지 설정 (FootGrounding) ── root.Add(BuildGroundingSection()); - // 캐시 상태 + 캘리브레이션 버튼 + // ── 손가락 복제 설정 ── + var fingerCopyFoldout = new Foldout { text = "손가락 복제 설정", value = false }; + fingerCopyFoldout.Add(new PropertyField(serializedObject.FindProperty("fingerCopyMode"), "복제 방식")); + root.Add(fingerCopyFoldout); + + // ── 손가락 셰이핑 (FingerShaped) ── + root.Add(BuildFingerShapedSection()); + + // ── 캘리브레이션 ── root.Add(BuildCacheSection()); - // 변경 시 저장 + // ── 변경 시 저장 ── root.TrackSerializedObjectValue(serializedObject, so => { if (target == null) return; @@ -116,212 +95,504 @@ namespace KindRetargeting return root; } + // ========== 힙 위치 보정 ========== + private VisualElement BuildHipsSection() { - var foldout = new Foldout { text = "힙 위치 보정 (로컬 좌표계)" }; + var foldout = new Foldout { text = "힙 위치 보정 (로컬 좌표계)", value = true }; - // 축 매핑 정보 - var axisInfo = new HelpBox("플레이 모드에서 T-포즈 분석 후 축 매핑 정보가 표시됩니다.\n이 매핑은 각 아바타의 힙 로컬 축 방향에 맞춰 자동 계산됩니다.", HelpBoxMessageType.Info); + var axisInfo = new HelpBox("플레이 모드에서 축 매핑 정보가 표시됩니다.", HelpBoxMessageType.Info); foldout.Add(axisInfo); - // 주기적으로 축 매핑 정보 갱신 foldout.schedule.Execute(() => { - if (target == null || debugAxisNormalizerProp == null) return; + if (target == 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이 매핑은 각 아바타의 힙 로컬 축 방향에 맞춰 자동 계산됩니다."; - } + var axisProp = serializedObject.FindProperty("debugAxisNormalizer"); + if (axisProp == null || !Application.isPlaying) { axisInfo.text = "플레이 모드에서 축 매핑 정보가 표시됩니다."; return; } + Vector3 m = axisProp.vector3Value; + if (m == Vector3.one) { axisInfo.text = "플레이 모드에서 축 매핑 정보가 표시됩니다."; return; } + string A(float v) => Mathf.RoundToInt(Mathf.Abs(v)) switch { 1 => (v > 0 ? "+X" : "-X"), 2 => (v > 0 ? "+Y" : "-Y"), 3 => (v > 0 ? "+Z" : "-Z"), _ => "?" }; + axisInfo.text = $"축 매핑: 좌우→{A(m.x)} 상하→{A(m.y)} 앞뒤→{A(m.z)}"; }).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 hx = new Slider("← 좌우 →", -1f, 1f) { showInputField = true }; + hx.BindProperty(serializedObject.FindProperty("hipsOffsetX")); + foldout.Add(hx); + var hy = new Slider("↓ 상하 ↑", -1f, 1f) { showInputField = true }; + hy.BindProperty(serializedObject.FindProperty("hipsOffsetY")); + foldout.Add(hy); + var hz = new Slider("← 앞뒤 →", -1f, 1f) { showInputField = true }; + hz.BindProperty(serializedObject.FindProperty("hipsOffsetZ")); + foldout.Add(hz); + + // 의자 앉기 높이 + var chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정" }; + chairSlider.BindProperty(serializedObject.FindProperty("limbWeight.chairSeatHeightOffset")); + foldout.Add(chairSlider); // 다리 길이 자동 보정 버튼 var autoHipsBtn = new Button(() => { - if (!Application.isPlaying) - { - Debug.LogWarning("다리 길이 자동 보정은 플레이 모드에서만 사용 가능합니다."); - return; - } + if (!Application.isPlaying) { Debug.LogWarning("플레이 모드에서만 사용 가능합니다."); return; } var script = (CustomRetargetingScript)target; float offset = CalculateHipsOffsetFromLegDifference(script); - hipsOffsetYProp.floatValue = offset; + serializedObject.FindProperty("hipsOffsetY").floatValue = offset; serializedObject.ApplyModifiedProperties(); script.SaveSettings(); - Debug.Log($"자동 보정 완료: hipsOffsetY = {offset:F4}"); - }) { text = "다리 길이 자동 보정", tooltip = "소스/타겟 다리 길이 차이로 힙 상하 오프셋을 자동 계산합니다. (플레이 모드 전용)" }; - autoHipsBtn.style.marginTop = 4; + }) { text = "다리 길이 자동 보정", tooltip = "소스/타겟 다리 길이 차이로 힙 상하 오프셋을 자동 계산합니다." }; + autoHipsBtn.style.marginTop = 4; autoHipsBtn.style.height = 25; foldout.Add(autoHipsBtn); return foldout; } - private VisualElement BuildFingerCopySection() + // ========== 머리 회전 오프셋 ========== + + private VisualElement BuildHeadRotationSection() { - var foldout = new Foldout { text = "손가락 복제 설정" }; - foldout.Add(new PropertyField(fingerCopyModeProp, "복제 방식") { tooltip = "손가락 포즈를 복제하는 방식을 선택합니다." }); + var foldout = new Foldout { text = "머리 회전 오프셋", value = false }; + + var xProp = serializedObject.FindProperty("headRotationOffsetX"); + var yProp = serializedObject.FindProperty("headRotationOffsetY"); + var zProp = serializedObject.FindProperty("headRotationOffsetZ"); + + var xSlider = new Slider("X (Roll)", -180f, 180f) { showInputField = true }; + xSlider.BindProperty(xProp); + foldout.Add(xSlider); + var ySlider = new Slider("Y (Yaw)", -180f, 180f) { showInputField = true }; + ySlider.BindProperty(yProp); + foldout.Add(ySlider); + var zSlider = new Slider("Z (Pitch)", -180f, 180f) { showInputField = true }; + zSlider.BindProperty(zProp); + foldout.Add(zSlider); + + var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } }; + var resetBtn = new Button(() => + { + xProp.floatValue = yProp.floatValue = zProp.floatValue = 0f; + serializedObject.ApplyModifiedProperties(); + EditorUtility.SetDirty(target); + }) { text = "초기화" }; + resetBtn.style.flexGrow = 1; resetBtn.style.height = 25; resetBtn.style.marginRight = 2; + btnRow.Add(resetBtn); + + var calibBtn = new Button(() => + { + if (!Application.isPlaying) { Debug.LogWarning("플레이 모드에서만 사용 가능합니다."); return; } + CalibrateHeadToForward(serializedObject, xProp, yProp, zProp); + }) { text = "정면 캘리브레이션" }; + calibBtn.style.flexGrow = 1; calibBtn.style.height = 25; + btnRow.Add(calibBtn); + foldout.Add(btnRow); + + foldout.schedule.Execute(() => + { + calibBtn.SetEnabled(Application.isPlaying); + }).Every(500); + return foldout; } + // ========== 어깨 보정 ========== + + private VisualElement BuildShoulderSection() + { + var foldout = new Foldout { text = "어깨 보정 (ShoulderCorrection)", value = false }; + + var strength = new Slider("블렌드 강도", 0f, 5f) { showInputField = true }; + strength.BindProperty(serializedObject.FindProperty("shoulderCorrection.blendStrength")); + foldout.Add(strength); + + var maxBlend = new Slider("최대 블렌드", 0f, 1f) { showInputField = true }; + maxBlend.BindProperty(serializedObject.FindProperty("shoulderCorrection.maxShoulderBlend")); + foldout.Add(maxBlend); + + foldout.Add(new PropertyField(serializedObject.FindProperty("shoulderCorrection.reverseLeftRotation"), "왼쪽 회전 반전")); + foldout.Add(new PropertyField(serializedObject.FindProperty("shoulderCorrection.reverseRightRotation"), "오른쪽 회전 반전")); + + foldout.Add(BuildMinMaxRange("높이 차이 범위", + serializedObject.FindProperty("shoulderCorrection.minHeightDifference"), + serializedObject.FindProperty("shoulderCorrection.maxHeightDifference"), + -0.5f, 2f)); + + foldout.Add(new PropertyField(serializedObject.FindProperty("shoulderCorrection.shoulderCorrectionCurve"), "보정 커브")); + + return foldout; + } + + // ========== 사지 가중치 ========== + + private VisualElement BuildLimbWeightSection() + { + var foldout = new Foldout { text = "사지 가중치 (LimbWeight)", value = false }; + + // IK 활성화 토글 + foldout.Add(new PropertyField(serializedObject.FindProperty("limbWeight.enableLeftArmIK"), "왼팔 IK 활성화")); + foldout.Add(new PropertyField(serializedObject.FindProperty("limbWeight.enableRightArmIK"), "오른팔 IK 활성화")); + + foldout.Add(BuildMinMaxRange("손-프랍 거리 범위 (가중치 1→0)", + serializedObject.FindProperty("limbWeight.minDistance"), + serializedObject.FindProperty("limbWeight.maxDistance"), + 0f, 1f)); + + var smoothField = new Slider("가중치 변화 속도", 0.1f, 20f) { showInputField = true }; + smoothField.BindProperty(serializedObject.FindProperty("limbWeight.weightSmoothSpeed")); + foldout.Add(smoothField); + + foldout.Add(BuildMinMaxRange("의자-허리 거리 범위 (가중치 1→0)", + serializedObject.FindProperty("limbWeight.hipsMinDistance"), + serializedObject.FindProperty("limbWeight.hipsMaxDistance"), + 0f, 1f)); + + foldout.Add(BuildMinMaxRange("바닥-허리 높이 블렌딩 (가중치 0→1)", + serializedObject.FindProperty("limbWeight.groundHipsMinHeight"), + serializedObject.FindProperty("limbWeight.groundHipsMaxHeight"), + 0f, 2f)); + + foldout.Add(BuildMinMaxRange("발 높이 IK 블렌딩 (가중치 1→0)", + serializedObject.FindProperty("limbWeight.footHeightMinThreshold"), + serializedObject.FindProperty("limbWeight.footHeightMaxThreshold"), + 0.1f, 1f)); + + 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: 발끝 고정 + 발목 회전 자동 감지", + "• Pre-IK: 발이 바닥을 뚫지 않도록 IK 타겟 보정\n" + + "• Post-IK: Foot 회전으로 Toes 접지 미세 보정", HelpBoxMessageType.Info)); - // FootGroundingController는 CRS 내부 모듈 — serializedObject의 프로퍼티 경로로 접근 - var groundHeightField = new PropertyField(serializedObject.FindProperty("footGrounding.groundHeight"), "바닥 높이"); - foldout.Add(groundHeightField); + foldout.Add(new PropertyField(serializedObject.FindProperty("footGrounding.groundHeight"), "바닥 높이")); var weightSlider = new Slider("접지 강도", 0f, 1f) { showInputField = true }; weightSlider.BindProperty(serializedObject.FindProperty("footGrounding.groundingWeight")); foldout.Add(weightSlider); - var activationField = new PropertyField(serializedObject.FindProperty("footGrounding.activationHeight"), "활성화 높이"); - activationField.tooltip = "발목이 이 높이 이상이면 접지 보정 비활성화 (점프 등)"; - foldout.Add(activationField); + foldout.Add(new PropertyField(serializedObject.FindProperty("footGrounding.activationHeight"), "활성화 높이") { tooltip = "발목이 이 높이 이상이면 보정 비활성화" }); + foldout.Add(new PropertyField(serializedObject.FindProperty("footGrounding.plantThreshold"), "접지 판정 범위")); - var thresholdField = new PropertyField(serializedObject.FindProperty("footGrounding.plantThreshold"), "접지 판정 범위"); - thresholdField.tooltip = "Toes가 이 범위 안이면 접지 중으로 판정"; - foldout.Add(thresholdField); - - var smoothField = new PropertyField(serializedObject.FindProperty("footGrounding.smoothSpeed"), "보정 스무딩 속도"); - smoothField.tooltip = "보정량 변화 속도 (높을수록 빠른 반응, 낮으면 부드러운 전환)"; + var smoothField = new Slider("스무딩 속도", 1f, 30f) { showInputField = true }; + smoothField.BindProperty(serializedObject.FindProperty("footGrounding.smoothSpeed")); foldout.Add(smoothField); - foldout.Add(new HelpBox("힙 높이 보정은 '바닥 높이 조정' 섹션의 floorHeight로 제어합니다.", HelpBoxMessageType.Info)); - return foldout; } + // ========== 손가락 셰이핑 ========== + + private VisualElement BuildFingerShapedSection() + { + var foldout = new Foldout { text = "손가락 셰이핑 (FingerShaped)", value = false }; + + foldout.Add(new PropertyField(serializedObject.FindProperty("fingerShaped.enabled"), "셰이핑 활성화")); + foldout.Add(BuildHandSliders("왼손", "left")); + foldout.Add(BuildHandSliders("오른손", "right")); + + // 프리셋 버튼 + var presetLabel = new Label("프리셋") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginTop = 6 } }; + foldout.Add(presetLabel); + string[,] presets = { { "가위", "바위", "보" }, { "브이", "검지", "초기화" } }; + for (int row = 0; row < 2; row++) + { + var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, justifyContent = Justify.Center, marginBottom = 2 } }; + for (int col = 0; col < 3; col++) + { + string name = presets[row, col]; + var btn = new Button(() => ApplyFingerPreset(name)) { text = name }; + btn.style.height = 26; btn.style.width = 80; btn.style.marginLeft = btn.style.marginRight = 2; + btnRow.Add(btn); + } + foldout.Add(btnRow); + } + + return foldout; + } + + private VisualElement BuildHandSliders(string label, string prefix) + { + var handFoldout = new Foldout { text = label, value = false }; + + handFoldout.Add(new PropertyField(serializedObject.FindProperty($"fingerShaped.{prefix}HandEnabled"), "활성화")); + + string[] names = { "Thumb", "Index", "Middle", "Ring", "Pinky" }; + string[] korNames = { "엄지", "검지", "중지", "약지", "새끼" }; + + var slidersRow = new VisualElement { style = { flexDirection = FlexDirection.Row, justifyContent = Justify.Center, marginTop = 4 } }; + for (int i = 0; i < names.Length; i++) + { + var col = new VisualElement { style = { alignItems = Align.Center, width = 45, marginLeft = 2, marginRight = 2 } }; + col.Add(new Label(korNames[i]) { style = { fontSize = 10, color = new Color(0.6f, 0.6f, 0.6f) } }); + + var prop = serializedObject.FindProperty($"fingerShaped.{prefix}{names[i]}Curl"); + var valLabel = new Label(prop.floatValue.ToString("F1")) { style = { fontSize = 10, color = new Color(0.6f, 0.6f, 0.6f) } }; + col.Add(valLabel); + + var slider = new Slider(-1f, 1f) { direction = SliderDirection.Vertical }; + slider.style.height = 80; slider.style.width = 25; + slider.BindProperty(prop); + slider.RegisterValueChangedCallback(evt => valLabel.text = evt.newValue.ToString("F1")); + col.Add(slider); + slidersRow.Add(col); + } + handFoldout.Add(slidersRow); + + var spreadSlider = new Slider("벌리기", -1f, 1f) { showInputField = true }; + spreadSlider.BindProperty(serializedObject.FindProperty($"fingerShaped.{prefix}SpreadFingers")); + handFoldout.Add(spreadSlider); + + return handFoldout; + } + + private void ApplyFingerPreset(string presetName) + { + var script = (CustomRetargetingScript)target; + var fc = script.fingerShaped; + if (!fc.enabled) fc.enabled = true; + + (float t, float i, float m, float r, float p, float s) = presetName switch + { + "가위" => (1f, 1f, -1f, -1f, -1f, 0.3f), + "바위" => (-1f, -1f, -1f, -1f, -1f, 0f), + "보" => (1f, 1f, 1f, 1f, 1f, 1f), + "브이" => (-1f, 1f, 1f, -1f, -1f, 1f), + "검지" => (-1f, 1f, -1f, -1f, -1f, 0f), + "초기화" => (0.8f, 0.8f, 0.8f, 0.8f, 0.8f, 0.8f), + _ => (0f, 0f, 0f, 0f, 0f, 0f) + }; + + if (fc.leftHandEnabled) + { + fc.leftThumbCurl = t; fc.leftIndexCurl = i; fc.leftMiddleCurl = m; + fc.leftRingCurl = r; fc.leftPinkyCurl = p; fc.leftSpreadFingers = s; + } + if (fc.rightHandEnabled) + { + fc.rightThumbCurl = t; fc.rightIndexCurl = i; fc.rightMiddleCurl = m; + fc.rightRingCurl = r; fc.rightPinkyCurl = p; fc.rightSpreadFingers = s; + } + EditorUtility.SetDirty(target); + } + + // ========== 캘리브레이션 ========== + private VisualElement BuildCacheSection() { - var container = new VisualElement { style = { marginTop = 8 } }; + var box = new VisualElement { style = { marginTop = 8 } }; + box.style.backgroundColor = new Color(0, 0, 0, 0.1f); + box.style.borderTopLeftRadius = box.style.borderTopRightRadius = + box.style.borderBottomLeftRadius = box.style.borderBottomRightRadius = 4; + box.style.paddingTop = box.style.paddingBottom = + box.style.paddingLeft = box.style.paddingRight = 6; - var script = (CustomRetargetingScript)target; - bool hasCached = script.HasCachedSettings(); - - cacheStatusLabel = new Label(hasCached ? - "캘리브레이션 데이터가 저장되어 있습니다." : - "저장된 캘리브레이션 데이터가 없습니다."); - cacheStatusLabel.style.unityFontStyleAndWeight = FontStyle.Bold; - container.Add(cacheStatusLabel); + var cacheLabel = new Label(); + cacheLabel.style.unityFontStyleAndWeight = FontStyle.Bold; + box.Add(cacheLabel); 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; + var calibBtn = new Button(() => { ((CustomRetargetingScript)target).I_PoseCalibration(); }) { text = "I-포즈 캘리브레이션" }; + calibBtn.style.flexGrow = 1; calibBtn.style.marginRight = 2; btnRow.Add(calibBtn); - var deleteCacheBtn = new Button(() => + var resetBtn = new Button(() => { ((CustomRetargetingScript)target).ResetPoseAndCache(); }) { text = "캘리브레이션 초기화" }; + resetBtn.style.flexGrow = 1; + btnRow.Add(resetBtn); + box.Add(btnRow); + + // 전체 자동 보정 + var autoBtn = new Button(() => { - ((CustomRetargetingScript)target).ResetPoseAndCache(); - UpdateCacheStatus(); - }) { text = "캐시 데이터 삭제" }; - deleteCacheBtn.style.flexGrow = 1; - btnRow.Add(deleteCacheBtn); + if (!Application.isPlaying) { Debug.LogWarning("플레이 모드에서만 사용 가능합니다."); return; } + AutoCalibrateAll((CustomRetargetingScript)target, serializedObject); + }) { text = "전체 자동 보정 (크기 + 힙 높이 + 머리 정면)", tooltip = "아바타 크기, 힙 높이, 머리 정면을 자동 보정합니다." }; + autoBtn.style.marginTop = 4; autoBtn.style.height = 28; + box.Add(autoBtn); - container.Add(btnRow); - - // 주기적으로 캐시 상태 갱신 - container.schedule.Execute(() => + box.schedule.Execute(() => { if (target == null) return; bool cached = ((CustomRetargetingScript)target).HasCachedSettings(); - deleteCacheBtn.style.display = cached ? DisplayStyle.Flex : DisplayStyle.None; - cacheStatusLabel.text = cached ? - "캘리브레이션 데이터가 저장되어 있습니다." : - "저장된 캘리브레이션 데이터가 없습니다."; + cacheLabel.text = cached ? "캘리브레이션 데이터가 저장되어 있습니다." : "저장된 캘리브레이션 데이터가 없습니다."; + resetBtn.style.display = cached ? DisplayStyle.Flex : DisplayStyle.None; }).Every(1000); - deleteCacheBtn.style.display = hasCached ? DisplayStyle.Flex : DisplayStyle.None; + return box; + } + // ========== MinMax 헬퍼 ========== + + private VisualElement BuildMinMaxRange(string label, SerializedProperty minProp, SerializedProperty maxProp, float limitMin, float limitMax) + { + var container = new VisualElement { style = { marginBottom = 4 } }; + if (minProp == null || maxProp == null) + { + container.Add(new HelpBox($"'{label}' 프로퍼티를 찾을 수 없습니다.", HelpBoxMessageType.Warning)); + return container; + } + container.Add(new Label(label) { style = { marginBottom = 2, fontSize = 11 } }); + + var row = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center } }; + var minField = new FloatField { value = minProp.floatValue, style = { width = 50 } }; + var slider = new MinMaxSlider(minProp.floatValue, maxProp.floatValue, limitMin, limitMax); + slider.style.flexGrow = 1; slider.style.marginLeft = slider.style.marginRight = 4; + var maxField = new FloatField { value = maxProp.floatValue, style = { width = 50 } }; + + slider.RegisterValueChangedCallback(evt => + { + minProp.floatValue = evt.newValue.x; maxProp.floatValue = evt.newValue.y; + serializedObject.ApplyModifiedProperties(); + minField.SetValueWithoutNotify(evt.newValue.x); maxField.SetValueWithoutNotify(evt.newValue.y); + }); + minField.RegisterValueChangedCallback(evt => + { + float v = Mathf.Clamp(evt.newValue, limitMin, maxProp.floatValue); + minProp.floatValue = v; serializedObject.ApplyModifiedProperties(); + slider.SetValueWithoutNotify(new Vector2(v, maxProp.floatValue)); minField.SetValueWithoutNotify(v); + }); + maxField.RegisterValueChangedCallback(evt => + { + float v = Mathf.Clamp(evt.newValue, minProp.floatValue, limitMax); + maxProp.floatValue = v; serializedObject.ApplyModifiedProperties(); + slider.SetValueWithoutNotify(new Vector2(minProp.floatValue, v)); maxField.SetValueWithoutNotify(v); + }); + + container.TrackPropertyValue(minProp, p => { minField.SetValueWithoutNotify(p.floatValue); slider.SetValueWithoutNotify(new Vector2(p.floatValue, maxProp.floatValue)); }); + container.TrackPropertyValue(maxProp, p => { maxField.SetValueWithoutNotify(p.floatValue); slider.SetValueWithoutNotify(new Vector2(minProp.floatValue, p.floatValue)); }); + + row.Add(minField); row.Add(slider); row.Add(maxField); + container.Add(row); return container; } - private void UpdateCacheStatus() + // ========== 자동 보정 ========== + + private void AutoCalibrateAll(CustomRetargetingScript script, SerializedObject so) { - if (cacheStatusLabel == null || target == null) return; - bool hasCached = ((CustomRetargetingScript)target).HasCachedSettings(); - cacheStatusLabel.text = hasCached ? - "캘리브레이션 데이터가 저장되어 있습니다." : - "저장된 캘리브레이션 데이터가 없습니다."; + Animator source = script.sourceAnimator; + Animator targetAnim = script.targetAnimator; + if (source == null || targetAnim == null || !source.isHuman || !targetAnim.isHuman) + { + Debug.LogWarning("소스/타겟 Animator가 없거나 Humanoid가 아닙니다."); + return; + } + + script.ResetScale(); + so.FindProperty("avatarScale").floatValue = 1f; + so.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script); + so.ApplyModifiedProperties(); + + EditorApplication.delayCall += () => + { + if (script == null) return; + Transform sourceNeck = source.GetBoneTransform(HumanBodyBones.Neck); + Transform targetNeck = targetAnim.GetBoneTransform(HumanBodyBones.Neck); + if (sourceNeck == null || targetNeck == null) return; + + float scaleRatio = Mathf.Clamp(sourceNeck.position.y / Mathf.Max(targetNeck.position.y, 0.01f), 0.1f, 3f); + script.SetAvatarScale(scaleRatio); + + var so2 = new SerializedObject(script); + so2.FindProperty("avatarScale").floatValue = scaleRatio; + so2.ApplyModifiedProperties(); + + EditorApplication.delayCall += () => + { + if (script == null) return; + var so3 = new SerializedObject(script); + so3.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script); + + var xP = so3.FindProperty("headRotationOffsetX"); + var yP = so3.FindProperty("headRotationOffsetY"); + var zP = so3.FindProperty("headRotationOffsetZ"); + if (xP != null && yP != null && zP != null) + CalibrateHeadToForward(so3, xP, yP, zP); + + so3.ApplyModifiedProperties(); + script.SaveSettings(); + Debug.Log($"전체 자동 보정 완료: avatarScale={scaleRatio:F3}"); + }; + }; } - /// - /// 소스/타겟 다리 길이 차이로 힙 상하 오프셋을 계산합니다. - /// 소스 다리가 더 길면 → 음수 (힙을 내려서 타겟이 뜨지 않게) - /// 소스 다리가 더 짧으면 → 양수 (힙을 올려서 타겟 다리를 펴줌) - /// + // ========== 유틸리티 ========== + 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; - } + Animator targetAnim = script.targetAnimator; + if (source == null || targetAnim == null) return 0f; float sourceLeg = GetLegLength(source); - float targetLeg = GetLegLength(target); + float targetLeg = GetLegLength(targetAnim); + if (sourceLeg < 0.01f || targetLeg < 0.01f) return 0f; - 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; + return targetLeg - sourceLeg; } private float GetLegLength(Animator animator) { - Transform upperLeg = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg); - Transform lowerLeg = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg); + Transform upper = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg); + Transform lower = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg); Transform foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot); + if (upper == null || lower == null || foot == null) return 0f; + return Vector3.Distance(upper.position, lower.position) + Vector3.Distance(lower.position, foot.position); + } - if (upperLeg == null || lowerLeg == null || foot == null) return 0f; + private void CalibrateHeadToForward(SerializedObject so, SerializedProperty xProp, SerializedProperty yProp, SerializedProperty zProp) + { + CustomRetargetingScript script = so.targetObject as CustomRetargetingScript; + if (script == null) return; - float upper = Vector3.Distance(upperLeg.position, lowerLeg.position); - float lower = Vector3.Distance(lowerLeg.position, foot.position); - return upper + lower; + Animator targetAnimator = script.GetComponent(); + if (targetAnimator == null) return; + + Transform targetHead = targetAnimator.GetBoneTransform(HumanBodyBones.Head); + if (targetHead == null) return; + + Vector3 tPoseForward = script.tPoseHeadForward; + Vector3 tPoseUp = script.tPoseHeadUp; + if (tPoseForward.sqrMagnitude < 0.001f) return; + + float prevX = xProp.floatValue, prevY = yProp.floatValue, prevZ = zProp.floatValue; + Quaternion currentLocalRot = targetHead.localRotation; + Quaternion prevOffset = Quaternion.Euler(prevX, prevY, prevZ); + Quaternion baseLocalRot = currentLocalRot * Quaternion.Inverse(prevOffset); + + Transform headParent = targetHead.parent; + Quaternion parentWorldRot = headParent != null ? headParent.rotation : Quaternion.identity; + Quaternion baseWorldRot = parentWorldRot * baseLocalRot; + + Vector3 currentHeadForward = baseWorldRot * Vector3.forward; + Vector3 currentHeadUp = baseWorldRot * Vector3.up; + + Quaternion forwardCorrection = Quaternion.FromToRotation(currentHeadForward, tPoseForward); + Vector3 correctedUp = forwardCorrection * currentHeadUp; + float rollAngle = Vector3.SignedAngle(correctedUp, tPoseUp, tPoseForward); + Quaternion rollCorrection = Quaternion.AngleAxis(rollAngle, tPoseForward); + Quaternion worldCorrection = rollCorrection * forwardCorrection; + + Quaternion correctedWorldRot = worldCorrection * baseWorldRot; + Quaternion correctedLocalRot = Quaternion.Inverse(parentWorldRot) * correctedWorldRot; + Quaternion offsetQuat = Quaternion.Inverse(baseLocalRot) * correctedLocalRot; + + Vector3 euler = offsetQuat.eulerAngles; + if (euler.x > 180f) euler.x -= 360f; + if (euler.y > 180f) euler.y -= 360f; + if (euler.z > 180f) euler.z -= 360f; + + xProp.floatValue = Mathf.Clamp(euler.x, -180f, 180f); + yProp.floatValue = Mathf.Clamp(euler.y, -180f, 180f); + zProp.floatValue = Mathf.Clamp(euler.z, -180f, 180f); + so.ApplyModifiedProperties(); + EditorUtility.SetDirty(so.targetObject); } } } diff --git a/Assets/Scripts/KindRetargeting/Remote/RetargetingRemoteController.cs b/Assets/Scripts/KindRetargeting/Remote/RetargetingRemoteController.cs index 816cb3cc9..fbf07c675 100644 --- a/Assets/Scripts/KindRetargeting/Remote/RetargetingRemoteController.cs +++ b/Assets/Scripts/KindRetargeting/Remote/RetargetingRemoteController.cs @@ -261,12 +261,27 @@ namespace KindRetargeting.Remote { "footHeightMinThreshold", script.limbWeight.footHeightMinThreshold }, { "footHeightMaxThreshold", script.limbWeight.footHeightMaxThreshold }, { "chairSeatHeightOffset", script.limbWeight.chairSeatHeightOffset }, - }; + { "enableLeftArmIK", script.limbWeight.enableLeftArmIK }, + { "enableRightArmIK", script.limbWeight.enableRightArmIK }, - // FingerShapedController 데이터 - data["handPoseEnabled"] = script.fingerShaped.enabled; - data["leftHandEnabled"] = script.fingerShaped.leftHandEnabled; - data["rightHandEnabled"] = script.fingerShaped.rightHandEnabled; + // ShoulderCorrection 데이터 + { "shoulderBlendStrength", script.shoulderCorrection.blendStrength }, + { "shoulderMaxBlend", script.shoulderCorrection.maxShoulderBlend }, + { "shoulderMaxHeightDiff", script.shoulderCorrection.maxHeightDifference }, + { "shoulderMinHeightDiff", script.shoulderCorrection.minHeightDifference }, + + // FootGrounding 데이터 + { "groundingWeight", script.footGrounding.groundingWeight }, + { "groundingGroundHeight", script.footGrounding.groundHeight }, + { "groundingActivationHeight", script.footGrounding.activationHeight }, + { "groundingPlantThreshold", script.footGrounding.plantThreshold }, + { "groundingSmoothSpeed", script.footGrounding.smoothSpeed }, + + // FingerShapedController 데이터 + { "handPoseEnabled", script.fingerShaped.enabled }, + { "leftHandEnabled", script.fingerShaped.leftHandEnabled }, + { "rightHandEnabled", script.fingerShaped.rightHandEnabled }, + }; var response = new { @@ -374,6 +389,43 @@ namespace KindRetargeting.Remote case "chairSeatHeightOffset": script.limbWeight.chairSeatHeightOffset = value; break; + case "enableLeftArmIK": + script.limbWeight.enableLeftArmIK = value > 0.5f; + break; + case "enableRightArmIK": + script.limbWeight.enableRightArmIK = value > 0.5f; + break; + + // ShoulderCorrection 속성 + case "shoulderBlendStrength": + script.shoulderCorrection.blendStrength = value; + break; + case "shoulderMaxBlend": + script.shoulderCorrection.maxShoulderBlend = value; + break; + case "shoulderMaxHeightDiff": + script.shoulderCorrection.maxHeightDifference = value; + break; + case "shoulderMinHeightDiff": + script.shoulderCorrection.minHeightDifference = value; + break; + + // FootGrounding 속성 + case "groundingWeight": + script.footGrounding.groundingWeight = value; + break; + case "groundingGroundHeight": + script.footGrounding.groundHeight = value; + break; + case "groundingActivationHeight": + script.footGrounding.activationHeight = value; + break; + case "groundingPlantThreshold": + script.footGrounding.plantThreshold = value; + break; + case "groundingSmoothSpeed": + script.footGrounding.smoothSpeed = value; + break; // FingerShapedController 속성 case "handPoseEnabled":