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"; protected override void OnDisable() { base.OnDisable(); } public override VisualElement CreateInspectorGUI() { var root = new VisualElement(); var commonUss = AssetDatabase.LoadAssetAtPath(CommonUssPath); if (commonUss != null) root.styleSheets.Add(commonUss); // ── 기본 설정 ── root.Add(new PropertyField(serializedObject.FindProperty("sourceAnimator"), "원본 Animator")); // ── 아바타 크기 ── var scaleFoldout = new Foldout { text = "아바타 크기 설정", value = true }; 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 = 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 = 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 }; footIO.BindProperty(serializedObject.FindProperty("footInOutOffset")); footFoldout.Add(footIO); root.Add(footFoldout); // ── 바닥 높이 ── var floorFoldout = new Foldout { text = "바닥 높이 조정", value = false }; floorFoldout.Add(new PropertyField(serializedObject.FindProperty("floorHeight"), "바닥 높이 (-1 ~ 1)")); floorFoldout.Add(new PropertyField(serializedObject.FindProperty("minimumAnkleHeight"), "최소 발목 높이")); root.Add(floorFoldout); // ── 머리 회전 오프셋 ── 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; var script = (CustomRetargetingScript)target; if (script.targetAnimator != null) script.SaveSettings(); }); return root; } // ========== 힙 위치 보정 ========== private VisualElement BuildHipsSection() { var foldout = new Foldout { text = "힙 위치 보정 (로컬 좌표계)", value = true }; var axisInfo = new HelpBox("플레이 모드에서 축 매핑 정보가 표시됩니다.", HelpBoxMessageType.Info); foldout.Add(axisInfo); foldout.schedule.Execute(() => { if (target == null) return; serializedObject.Update(); 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); 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; } var script = (CustomRetargetingScript)target; float offset = CalculateHipsOffsetFromLegDifference(script); serializedObject.FindProperty("hipsOffsetY").floatValue = offset; serializedObject.ApplyModifiedProperties(); script.SaveSettings(); }) { text = "다리 길이 자동 보정", tooltip = "소스/타겟 다리 길이 차이로 힙 상하 오프셋을 자동 계산합니다." }; autoHipsBtn.style.marginTop = 4; autoHipsBtn.style.height = 25; foldout.Add(autoHipsBtn); return foldout; } // ========== 머리 회전 오프셋 ========== private VisualElement BuildHeadRotationSection() { 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 접지 미세 보정", HelpBoxMessageType.Info)); 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); foldout.Add(new PropertyField(serializedObject.FindProperty("footGrounding.activationHeight"), "활성화 높이") { tooltip = "발목이 이 높이 이상이면 보정 비활성화" }); foldout.Add(new PropertyField(serializedObject.FindProperty("footGrounding.plantThreshold"), "접지 판정 범위")); var smoothField = new Slider("스무딩 속도", 1f, 30f) { showInputField = true }; smoothField.BindProperty(serializedObject.FindProperty("footGrounding.smoothSpeed")); foldout.Add(smoothField); 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 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 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(); }) { text = "I-포즈 캘리브레이션" }; calibBtn.style.flexGrow = 1; calibBtn.style.marginRight = 2; btnRow.Add(calibBtn); var resetBtn = new Button(() => { ((CustomRetargetingScript)target).ResetPoseAndCache(); }) { text = "캘리브레이션 초기화" }; resetBtn.style.flexGrow = 1; btnRow.Add(resetBtn); box.Add(btnRow); // 전체 자동 보정 var autoBtn = new Button(() => { if (!Application.isPlaying) { Debug.LogWarning("플레이 모드에서만 사용 가능합니다."); return; } AutoCalibrateAll((CustomRetargetingScript)target, serializedObject); }) { text = "전체 자동 보정 (크기 + 힙 높이 + 머리 정면)", tooltip = "아바타 크기, 힙 높이, 머리 정면을 자동 보정합니다." }; autoBtn.style.marginTop = 4; autoBtn.style.height = 28; box.Add(autoBtn); box.schedule.Execute(() => { if (target == null) return; bool cached = ((CustomRetargetingScript)target).HasCachedSettings(); cacheLabel.text = cached ? "캘리브레이션 데이터가 저장되어 있습니다." : "저장된 캘리브레이션 데이터가 없습니다."; resetBtn.style.display = cached ? DisplayStyle.Flex : DisplayStyle.None; }).Every(1000); 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 AutoCalibrateAll(CustomRetargetingScript script, SerializedObject so) { 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 targetAnim = script.targetAnimator; if (source == null || targetAnim == null) return 0f; float sourceLeg = GetLegLength(source); float targetLeg = GetLegLength(targetAnim); if (sourceLeg < 0.01f || targetLeg < 0.01f) return 0f; return targetLeg - sourceLeg; } private float GetLegLength(Animator animator) { 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); } private void CalibrateHeadToForward(SerializedObject so, SerializedProperty xProp, SerializedProperty yProp, SerializedProperty zProp) { CustomRetargetingScript script = so.targetObject as CustomRetargetingScript; if (script == null) return; 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); } } }