Streamingle_URP/Assets/Scripts/KindRetargeting/Editor/RetargetingControlWindow.cs
user 4a49ecd772 Refactor: 배경/프랍 브라우저 IMGUI→UI Toolkit 전환 + USS 리디자인
- BackgroundSceneLoaderWindow: OnGUI → CreateGUI (Toolbar + ToolbarSearchField)
- PropBrowserWindow: OnGUI → CreateGUI (Toolbar + ToolbarSearchField)
- StreamingleCommon.uss: 브라우저 공통 스타일 추가 (그리드/리스트/뷰토글/액션바/상태바)
- excludeFromWeb 상태 새로고침 시 보존 수정
- 삭제된 배경 리소스 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 01:55:48 +09:00

1014 lines
43 KiB
C#

using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;
using UnityEditor.UIElements;
using System.Collections.Generic;
using KindRetargeting;
using KindRetargeting.EnumsList;
public class RetargetingControlWindow : EditorWindow
{
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
private CustomRetargetingScript[] retargetingScripts;
private VisualElement characterContainer;
private Label emptyLabel;
private bool isDirty = false;
private double lastUpdateTime;
private const double UPDATE_INTERVAL = 0.25;
private readonly List<SerializedObject> trackedSerializedObjects = new List<SerializedObject>();
[MenuItem("Tools/리타게팅 컨트롤 패널")]
public static void ShowWindow()
{
GetWindow<RetargetingControlWindow>("리타게팅 컨트롤");
}
public void CreateGUI()
{
var root = rootVisualElement;
root.AddToClassList("tool-root");
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
if (commonUss != null) root.styleSheets.Add(commonUss);
root.Add(new Label("리타게팅 컨트롤 패널") { name = "title" });
root.Q<Label>("title").AddToClassList("tool-title");
// 전역 설정
var globalFoldout = new Foldout { text = "전역 설정", value = false };
var globalBtnRow = new VisualElement { style = { flexDirection = FlexDirection.Row } };
var allCalibBtn = new Button(() =>
{
var scripts = FindObjectsByType<CustomRetargetingScript>(FindObjectsSortMode.None);
foreach (var s in scripts) s.I_PoseCalibration();
AssetDatabase.SaveAssets();
RebuildCharacterPanels();
}) { text = "모든 캐릭터 I-포즈 캘리브레이션" };
allCalibBtn.style.flexGrow = 1;
allCalibBtn.style.marginRight = 2;
globalBtnRow.Add(allCalibBtn);
var allResetBtn = new Button(() =>
{
var scripts = FindObjectsByType<CustomRetargetingScript>(FindObjectsSortMode.None);
foreach (var s in scripts) { s.ResetPoseAndCache(); EditorUtility.SetDirty(s); }
AssetDatabase.SaveAssets();
RebuildCharacterPanels();
}) { text = "모든 캐릭터 캘리브레이션 초기화" };
allResetBtn.style.flexGrow = 1;
globalBtnRow.Add(allResetBtn);
globalFoldout.Add(globalBtnRow);
root.Add(globalFoldout);
// 리프레시 버튼
var refreshBtn = new Button(() => RefreshAndRebuild()) { text = "리타게팅 스크립트 리프레시" };
refreshBtn.style.height = 30;
refreshBtn.style.marginTop = 4;
root.Add(refreshBtn);
// 빈 상태 메시지
emptyLabel = new Label();
emptyLabel.style.display = DisplayStyle.None;
root.Add(emptyLabel);
// 캐릭터 목록
var scrollView = new ScrollView { style = { flexGrow = 1, marginTop = 8 } };
characterContainer = new VisualElement();
scrollView.Add(characterContainer);
root.Add(scrollView);
// 에디터 업데이트 (dirty tracking)
EditorApplication.update += OnEditorUpdate;
Undo.undoRedoPerformed += OnUndoRedoPerformed;
root.RegisterCallback<DetachFromPanelEvent>(_ =>
{
EditorApplication.update -= OnEditorUpdate;
Undo.undoRedoPerformed -= OnUndoRedoPerformed;
DisposeTrackedSerializedObjects();
});
RefreshAndRebuild();
}
private void OnEditorUpdate()
{
if (Application.isPlaying) return;
if (EditorApplication.timeSinceStartup - lastUpdateTime < UPDATE_INTERVAL) return;
if (isDirty)
{
RefreshScripts();
isDirty = false;
}
lastUpdateTime = EditorApplication.timeSinceStartup;
}
private void OnUndoRedoPerformed() { isDirty = true; }
private SerializedObject CreateTrackedSO(Object target)
{
var so = new SerializedObject(target);
trackedSerializedObjects.Add(so);
return so;
}
private void DisposeTrackedSerializedObjects()
{
foreach (var so in trackedSerializedObjects)
so?.Dispose();
trackedSerializedObjects.Clear();
}
private void RefreshScripts()
{
if (Application.isPlaying || retargetingScripts == null) return;
foreach (var s in retargetingScripts)
{
if (s != null && s.isActiveAndEnabled)
EditorUtility.SetDirty(s);
}
if (retargetingScripts.Length > 0 && retargetingScripts[0] != null && !Application.isPlaying)
{
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(retargetingScripts[0].gameObject.scene);
}
}
private void RefreshAndRebuild()
{
retargetingScripts = FindObjectsByType<CustomRetargetingScript>(FindObjectsSortMode.None);
// 필요한 컴포넌트 자동 추가
foreach (var s in retargetingScripts)
{
if (s == null) continue;
if (s.GetComponent<LimbWeightController>() == null)
s.gameObject.AddComponent<LimbWeightController>();
if (s.GetComponent<FingerShapedController>() == null)
s.gameObject.AddComponent<FingerShapedController>();
if (s.GetComponent<PropLocationController>() == null)
s.gameObject.AddComponent<PropLocationController>();
EditorUtility.SetDirty(s.gameObject);
}
RebuildCharacterPanels();
}
private void RebuildCharacterPanels()
{
if (characterContainer == null) return;
characterContainer.Clear();
DisposeTrackedSerializedObjects();
if (retargetingScripts == null || retargetingScripts.Length == 0)
{
emptyLabel.text = "씬에서 리타게팅 스크립트를 찾을 수 없습니다.";
emptyLabel.style.display = DisplayStyle.Flex;
return;
}
emptyLabel.style.display = DisplayStyle.None;
foreach (var script in retargetingScripts)
{
if (script == null) continue;
characterContainer.Add(BuildCharacterPanel(script));
}
}
// ========== Character Panel ==========
private VisualElement BuildCharacterPanel(CustomRetargetingScript script)
{
var panel = new VisualElement();
panel.style.backgroundColor = new Color(0, 0, 0, 0.1f);
panel.style.borderTopLeftRadius = panel.style.borderTopRightRadius =
panel.style.borderBottomLeftRadius = panel.style.borderBottomRightRadius = 6;
panel.style.paddingTop = panel.style.paddingBottom =
panel.style.paddingLeft = panel.style.paddingRight = 8;
panel.style.marginBottom = 8;
var so = CreateTrackedSO(script);
// 헤더
panel.Add(BuildHeader(script));
// 가중치 설정
panel.Add(BuildWeightSection(script));
// 힙 위치 보정
panel.Add(BuildHipsSection(script, so));
// 무릎 위치 조정
var kneeFoldout = new Foldout { text = "무릎 위치 조정", value = false };
var kneeContainer = new VisualElement();
var kneeFB = new Slider("무릎 앞/뒤 가중치", -1f, 1f) { showInputField = true };
kneeFB.BindProperty(so.FindProperty("kneeFrontBackWeight"));
kneeContainer.Add(kneeFB);
var kneeIO = new Slider("무릎 안/밖 가중치", -1f, 1f) { showInputField = true };
kneeIO.BindProperty(so.FindProperty("kneeInOutWeight"));
kneeContainer.Add(kneeIO);
kneeFoldout.Add(kneeContainer);
kneeContainer.Bind(so);
panel.Add(kneeFoldout);
// 발 IK 위치 조정
var footFoldout = new Foldout { text = "발 IK 위치 조정", value = false };
var footContainer = new VisualElement();
var footFB = new Slider("발 앞/뒤 오프셋", -1f, 1f) { showInputField = true };
footFB.BindProperty(so.FindProperty("footFrontBackOffset"));
footContainer.Add(footFB);
var footIO = new Slider("발 벌리기/모으기", -1f, 1f) { showInputField = true };
footIO.BindProperty(so.FindProperty("footInOutOffset"));
footContainer.Add(footIO);
footFoldout.Add(footContainer);
footContainer.Bind(so);
panel.Add(footFoldout);
// 손가락 제어 설정
panel.Add(BuildFingerControlSection(script));
// 손가락 복제 설정
panel.Add(BuildFingerCopySection(script, so));
// 모션 설정
panel.Add(BuildMotionSection(so));
// 바닥 높이 설정
var floorFoldout = new Foldout { text = "바닥 높이 설정", value = false };
var floorField = new PropertyField(so.FindProperty("floorHeight"), "바닥 높이 (-1 ~ 1)");
floorFoldout.Add(floorField);
floorField.Bind(so);
panel.Add(floorFoldout);
// 아바타 크기 설정
var scaleFoldout = new Foldout { text = "아바타 크기 설정", value = false };
var scaleContainer = new VisualElement();
scaleContainer.Add(new PropertyField(so.FindProperty("avatarScale"), "아바타 크기"));
var headScaleProp = so.FindProperty("headScale");
if (headScaleProp != null)
scaleContainer.Add(new PropertyField(headScaleProp, "머리 크기"));
scaleContainer.Bind(so);
scaleFoldout.Add(scaleContainer);
panel.Add(scaleFoldout);
// 머리 회전 오프셋
panel.Add(BuildHeadRotationSection(script, so));
// 프랍 설정
panel.Add(BuildPropSection(script));
// 캘리브레이션
panel.Add(BuildCalibrationSection(script));
// 변경 감지
panel.TrackSerializedObjectValue(so, _ =>
{
if (script == null) return;
isDirty = true;
if (script.targetAnimator != null) script.SaveSettings();
SceneView.RepaintAll();
});
return panel;
}
// ========== Header ==========
private VisualElement BuildHeader(CustomRetargetingScript script)
{
var header = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center, marginBottom = 6 } };
header.Add(new Label($"캐릭터: {script.gameObject.name}") { style = { unityFontStyleAndWeight = FontStyle.Bold, flexGrow = 1 } });
var animator = script.GetComponent<Animator>();
if (animator != null)
{
void FrameBone(HumanBodyBones? bone)
{
Transform t = bone.HasValue ? animator.GetBoneTransform(bone.Value) : script.transform;
if (t != null)
{
Selection.activeGameObject = t.gameObject;
SceneView.lastActiveSceneView?.FrameSelected();
}
}
var allBtn = new Button(() => FrameBone(null)) { text = "전체" }; allBtn.style.width = 50; header.Add(allBtn);
var headBtn = new Button(() => FrameBone(HumanBodyBones.Head)) { text = "머리" }; headBtn.style.width = 50; header.Add(headBtn);
var lhBtn = new Button(() => FrameBone(HumanBodyBones.LeftHand)) { text = "왼손" }; lhBtn.style.width = 50; header.Add(lhBtn);
var rhBtn = new Button(() => FrameBone(HumanBodyBones.RightHand)) { text = "오른손" }; rhBtn.style.width = 50; header.Add(rhBtn);
}
return header;
}
// ========== Weight Settings ==========
private VisualElement BuildWeightSection(CustomRetargetingScript script)
{
var foldout = new Foldout { text = "가중치 설정", value = false };
var limb = script.GetComponent<LimbWeightController>();
if (limb == null) { foldout.Add(new HelpBox("LimbWeightController가 없습니다.", HelpBoxMessageType.Warning)); return foldout; }
var limbSO = CreateTrackedSO(limb);
var container = new VisualElement();
container.Add(BuildMinMaxRange("손과 프랍과의 범위 (가중치 1 → 0)",
limbSO.FindProperty("minDistance"), limbSO.FindProperty("maxDistance"), 0f, 1f, limbSO));
container.Add(BuildMinMaxRange("의자와 허리 거리 범위 (가중치 1 → 0)",
limbSO.FindProperty("hipsMinDistance"), limbSO.FindProperty("hipsMaxDistance"), 0f, 1f, limbSO));
container.Add(BuildMinMaxRange("바닥과 허리 높이에 의한 블렌딩 (가중치 0 → 1)",
limbSO.FindProperty("groundHipsMinHeight"), limbSO.FindProperty("groundHipsMaxHeight"), 0f, 2f, limbSO));
container.Add(BuildMinMaxRange("지면으로부터 발의 범위에 의한 IK 블렌딩 (가중치 1 → 0)",
limbSO.FindProperty("footHeightMinThreshold"), limbSO.FindProperty("footHeightMaxThreshold"), 0.1f, 1f, limbSO));
var smoothField = new PropertyField(limbSO.FindProperty("weightSmoothSpeed"), "가중치 변화 속도");
container.Add(smoothField);
container.Bind(limbSO);
foldout.Add(container);
return foldout;
}
// ========== Hips Settings ==========
private VisualElement BuildHipsSection(CustomRetargetingScript script, SerializedObject so)
{
var foldout = new Foldout { text = "힙 위치 보정 (로컬)", value = false };
var container = new VisualElement();
// 축 매핑 정보
var axisLabel = new Label { style = { fontSize = 10, color = new Color(0.6f, 0.6f, 0.6f) } };
container.Add(axisLabel);
container.schedule.Execute(() =>
{
if (so == null || so.targetObject == null) return;
so.Update();
var normProp = so.FindProperty("debugAxisNormalizer");
if (normProp == null || !Application.isPlaying) { axisLabel.text = ""; return; }
Vector3 m = normProp.vector3Value;
if (m == Vector3.one) { axisLabel.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"), _ => "?" };
axisLabel.text = $"축 매핑: 좌우→{A(m.x)} 상하→{A(m.y)} 앞뒤→{A(m.z)}";
}).Every(500);
var hx = new Slider("← 좌우 →", -1f, 1f) { showInputField = true };
hx.BindProperty(so.FindProperty("hipsOffsetX"));
container.Add(hx);
var hy = new Slider("↓ 상하 ↑", -1f, 1f) { showInputField = true };
hy.BindProperty(so.FindProperty("hipsOffsetY"));
container.Add(hy);
var hz = new Slider("← 앞뒤 →", -1f, 1f) { showInputField = true };
hz.BindProperty(so.FindProperty("hipsOffsetZ"));
container.Add(hz);
// 의자 앉기 높이
var limb = script.GetComponent<LimbWeightController>();
if (limb != null)
{
var limbSO = CreateTrackedSO(limb);
var chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정 (월드 Y 기준)" };
chairSlider.BindProperty(limbSO.FindProperty("chairSeatHeightOffset"));
container.Add(chairSlider);
chairSlider.Bind(limbSO);
}
container.Bind(so);
foldout.Add(container);
return foldout;
}
// ========== Finger Control ==========
private VisualElement BuildFingerControlSection(CustomRetargetingScript script)
{
var foldout = new Foldout { text = "손가락 제어 설정", value = false };
var fingerController = script.GetComponent<FingerShapedController>();
if (fingerController == null)
{
foldout.Add(new HelpBox("FingerShapedController가 없습니다.", HelpBoxMessageType.Warning));
var addBtn = new Button(() =>
{
script.gameObject.AddComponent<FingerShapedController>();
EditorUtility.SetDirty(script.gameObject);
RebuildCharacterPanels();
}) { text = "FingerShapedController 추가" };
foldout.Add(addBtn);
return foldout;
}
var fso = CreateTrackedSO(fingerController);
var container = new VisualElement();
// 활성화 토글
var enableToggle = new Toggle("손가락 제어 활성화") { value = fingerController.enabled };
enableToggle.RegisterValueChangedCallback(evt =>
{
fingerController.enabled = evt.newValue;
EditorUtility.SetDirty(fingerController);
});
container.Add(enableToggle);
// 왼손
container.Add(BuildHandSection("왼손", "left", fso, fingerController));
// 오른손
container.Add(BuildHandSection("오른손", "right", fso, fingerController));
// 프리셋
container.Add(BuildFingerPresets(fingerController));
container.Bind(fso);
foldout.Add(container);
return foldout;
}
private VisualElement BuildHandSection(string label, string prefix, SerializedObject fso, FingerShapedController fc)
{
var box = new VisualElement();
box.style.backgroundColor = new Color(0, 0, 0, 0.08f);
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;
box.style.marginTop = 4;
var header = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center } };
var handFoldout = new Foldout { text = label, value = false };
var handEnabledProp = fso.FindProperty($"{prefix}HandEnabled");
header.Add(new PropertyField(handEnabledProp, "활성화") { style = { flexGrow = 1 } });
var resetBtn = new Button(() =>
{
string[] props = { "ThumbCurl", "IndexCurl", "MiddleCurl", "RingCurl", "PinkyCurl", "SpreadFingers" };
foreach (var p in props) fso.FindProperty($"{prefix}{p}").floatValue = 0f;
fso.ApplyModifiedProperties();
}) { text = "초기화" };
resetBtn.style.width = 60;
header.Add(resetBtn);
handFoldout.Add(header);
// 손가락 슬라이더
string[] fingerNames = { "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 < fingerNames.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 = fso.FindProperty($"{prefix}{fingerNames[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 = 100;
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(fso.FindProperty($"{prefix}SpreadFingers"));
handFoldout.Add(spreadSlider);
// 비활성 시 숨김
handFoldout.schedule.Execute(() =>
{
if (fso == null || fso.targetObject == null) return;
fso.Update();
bool enabled = fso.FindProperty($"{prefix}HandEnabled").boolValue;
slidersRow.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None;
spreadSlider.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None;
}).Every(300);
box.Add(handFoldout);
return box;
}
private VisualElement BuildFingerPresets(FingerShapedController controller)
{
var container = new VisualElement { style = { marginTop = 6 } };
container.Add(new Label("손 모양 프리셋") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginBottom = 4 } });
string[,] presets = { { "가위", "바위", "보" }, { "브이", "검지", "초기화" } };
for (int row = 0; row < 2; row++)
{
var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, justifyContent = Justify.Center, marginBottom = 4 } };
for (int col = 0; col < 3; col++)
{
string name = presets[row, col];
var btn = new Button(() => ApplyFingerPreset(controller, name)) { text = name };
btn.style.height = 30; btn.style.width = 100; btn.style.marginLeft = btn.style.marginRight = 4;
btnRow.Add(btn);
}
container.Add(btnRow);
}
return container;
}
// ========== Finger Copy ==========
private VisualElement BuildFingerCopySection(CustomRetargetingScript script, SerializedObject so)
{
var foldout = new Foldout { text = "손가락 복제 설정", value = false };
var container = new VisualElement();
var copyModeProp = so.FindProperty("fingerCopyMode");
container.Add(new PropertyField(copyModeProp, "복제 방식"));
// Mingle 캘리브레이션 컨테이너
var mingleBox = new VisualElement();
mingleBox.style.backgroundColor = new Color(0, 0, 0, 0.1f);
mingleBox.style.borderTopLeftRadius = mingleBox.style.borderTopRightRadius =
mingleBox.style.borderBottomLeftRadius = mingleBox.style.borderBottomRightRadius = 4;
mingleBox.style.paddingTop = mingleBox.style.paddingBottom =
mingleBox.style.paddingLeft = mingleBox.style.paddingRight = 6;
mingleBox.style.marginTop = 4;
mingleBox.Add(new Label("Mingle 캘리브레이션") { style = { unityFontStyleAndWeight = FontStyle.Bold } });
mingleBox.Add(new HelpBox(
"1. 손가락을 완전히 펼친 상태에서 '펼침 기록' 클릭\n2. 손가락을 완전히 모은(주먹) 상태에서 '모음 기록' 클릭",
HelpBoxMessageType.Info));
var manualRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
var openBtn = new Button(() => script.CalibrateMingleOpen()) { text = "펼침 기록" };
openBtn.style.flexGrow = 1; openBtn.style.marginRight = 2;
manualRow.Add(openBtn);
var closeBtn = new Button(() => script.CalibrateMingleClose()) { text = "모음 기록" };
closeBtn.style.flexGrow = 1;
manualRow.Add(closeBtn);
mingleBox.Add(manualRow);
var autoBtn = new Button(() => script.StartAutoCalibration()) { text = "자동 캘리브레이션 (3초 펼침 → 3초 모음)" };
autoBtn.style.marginTop = 4;
mingleBox.Add(autoBtn);
var playWarning = new HelpBox("캘리브레이션은 플레이 모드에서만 가능합니다.", HelpBoxMessageType.Warning);
mingleBox.Add(playWarning);
// 자동 캘리브레이션 상태
var autoStatus = new VisualElement();
autoStatus.style.display = DisplayStyle.None;
var statusLabel = new Label();
var timeLabel = new Label();
var cancelBtn = new Button(() => script.StopAutoCalibration()) { text = "취소" };
autoStatus.Add(statusLabel);
autoStatus.Add(timeLabel);
autoStatus.Add(cancelBtn);
mingleBox.Add(autoStatus);
container.Add(mingleBox);
// 주기적 갱신
container.schedule.Execute(() =>
{
if (so == null || so.targetObject == null || script == null) return;
so.Update();
bool isMingle = so.FindProperty("fingerCopyMode").enumValueIndex == (int)FingerCopyMode.Mingle;
mingleBox.style.display = isMingle ? DisplayStyle.Flex : DisplayStyle.None;
if (!isMingle) return;
bool playing = Application.isPlaying;
bool autoCalib = playing && script.IsAutoCalibrating;
openBtn.SetEnabled(playing && !autoCalib);
closeBtn.SetEnabled(playing && !autoCalib);
autoBtn.SetEnabled(playing && !autoCalib);
playWarning.style.display = playing ? DisplayStyle.None : DisplayStyle.Flex;
manualRow.style.display = autoCalib ? DisplayStyle.None : DisplayStyle.Flex;
autoBtn.style.display = autoCalib ? DisplayStyle.None : DisplayStyle.Flex;
autoStatus.style.display = autoCalib ? DisplayStyle.Flex : DisplayStyle.None;
if (autoCalib)
{
statusLabel.text = $"상태: {script.AutoCalibrationStatus}";
timeLabel.text = $"남은 시간: {script.AutoCalibrationTimeRemaining:F1}초";
}
}).Every(200);
container.Bind(so);
foldout.Add(container);
return foldout;
}
// ========== Motion Settings ==========
private VisualElement BuildMotionSection(SerializedObject so)
{
var foldout = new Foldout { text = "모션 설정", value = false };
var container = new VisualElement();
var useFilterProp = so.FindProperty("useMotionFilter");
container.Add(new PropertyField(useFilterProp, "모션 필터 사용"));
var bufferField = new PropertyField(so.FindProperty("filterBufferSize"), "필터 버퍼 크기");
container.Add(bufferField);
var useBodyRoughProp = so.FindProperty("useBodyRoughMotion");
container.Add(new PropertyField(useBodyRoughProp, "몸 러프 모션 사용"));
var bodyRoughField = new PropertyField(so.FindProperty("bodyRoughness"), "몸 러프니스");
container.Add(bodyRoughField);
// 조건부 표시
container.schedule.Execute(() =>
{
if (so == null || so.targetObject == null) return;
so.Update();
bufferField.style.display = so.FindProperty("useMotionFilter").boolValue ? DisplayStyle.Flex : DisplayStyle.None;
bodyRoughField.style.display = so.FindProperty("useBodyRoughMotion").boolValue ? DisplayStyle.Flex : DisplayStyle.None;
}).Every(300);
bufferField.style.display = useFilterProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
bodyRoughField.style.display = useBodyRoughProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
container.Bind(so);
foldout.Add(container);
return foldout;
}
// ========== Head Rotation ==========
private VisualElement BuildHeadRotationSection(CustomRetargetingScript script, SerializedObject so)
{
var foldout = new Foldout { text = "머리 회전 오프셋", value = false };
var container = new VisualElement();
var xProp = so.FindProperty("headRotationOffsetX");
var yProp = so.FindProperty("headRotationOffsetY");
var zProp = so.FindProperty("headRotationOffsetZ");
var xSlider = new Slider("X (Roll) - 좌우 기울기", -180f, 180f) { showInputField = true };
xSlider.BindProperty(xProp);
container.Add(xSlider);
var ySlider = new Slider("Y (Yaw) - 좌우 회전", -180f, 180f) { showInputField = true };
ySlider.BindProperty(yProp);
container.Add(ySlider);
var zSlider = new Slider("Z (Pitch) - 상하 회전", -180f, 180f) { showInputField = true };
zSlider.BindProperty(zProp);
container.Add(zSlider);
var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
var resetBtn = new Button(() =>
{
xProp.floatValue = yProp.floatValue = zProp.floatValue = 0f;
so.ApplyModifiedProperties();
EditorUtility.SetDirty(so.targetObject);
}) { text = "회전 초기화" };
resetBtn.style.flexGrow = 1; resetBtn.style.height = 25; resetBtn.style.marginRight = 2;
btnRow.Add(resetBtn);
var calibBtn = new Button(() => CalibrateHeadToForward(so, xProp, yProp, zProp)) { text = "정면 캘리브레이션" };
calibBtn.style.flexGrow = 1; calibBtn.style.height = 25;
btnRow.Add(calibBtn);
container.Add(btnRow);
var playWarning = new HelpBox("정면 캘리브레이션은 플레이 모드에서만 사용 가능합니다.", HelpBoxMessageType.Info);
container.Add(playWarning);
container.schedule.Execute(() =>
{
calibBtn.SetEnabled(Application.isPlaying);
playWarning.style.display = Application.isPlaying ? DisplayStyle.None : DisplayStyle.Flex;
}).Every(500);
container.Bind(so);
foldout.Add(container);
return foldout;
}
// ========== Prop Controls ==========
private VisualElement BuildPropSection(CustomRetargetingScript script)
{
var foldout = new Foldout { text = "프랍 설정", value = false };
var propController = script.GetComponent<PropLocationController>();
if (propController == null)
{
foldout.Add(new HelpBox("PropLocationController가 없습니다.", HelpBoxMessageType.Warning));
var addBtn = new Button(() =>
{
script.gameObject.AddComponent<PropLocationController>();
EditorUtility.SetDirty(script.gameObject);
RebuildCharacterPanels();
}) { text = "PropLocationController 추가" };
foldout.Add(addBtn);
return foldout;
}
var dynamicContainer = new VisualElement();
foldout.Add(dynamicContainer);
// 프랍 이동 버튼
foldout.Add(new Label("프랍 위치 이동") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginTop = 8 } });
var moveRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
var headBtn = new Button(() => propController.MoveToHead()) { text = "머리로 이동" };
headBtn.style.flexGrow = 1; headBtn.style.height = 30; headBtn.style.marginRight = 2;
moveRow.Add(headBtn);
var lhBtn = new Button(() => propController.MoveToLeftHand()) { text = "왼손으로 이동" };
lhBtn.style.flexGrow = 1; lhBtn.style.height = 30; lhBtn.style.marginRight = 2;
moveRow.Add(lhBtn);
var rhBtn = new Button(() => propController.MoveToRightHand()) { text = "오른손으로 이동" };
rhBtn.style.flexGrow = 1; rhBtn.style.height = 30;
moveRow.Add(rhBtn);
foldout.Add(moveRow);
var detachBtn = new Button(() =>
{
if (Selection.activeGameObject != null)
Undo.RecordObject(Selection.activeGameObject.transform, "Detach Prop");
propController.DetachProp();
}) { text = "프랍 해제" };
detachBtn.style.height = 30; detachBtn.style.marginTop = 4;
foldout.Add(detachBtn);
// 주기적으로 오프셋 + 프랍 목록 갱신
foldout.schedule.Execute(() => RebuildPropDynamicContent(dynamicContainer, propController)).Every(500);
RebuildPropDynamicContent(dynamicContainer, propController);
return foldout;
}
private void RebuildPropDynamicContent(VisualElement container, PropLocationController pc)
{
if (pc == null) return;
container.Clear();
// 오프셋 섹션
container.Add(new Label("오프셋 조정") { style = { unityFontStyleAndWeight = FontStyle.Bold } });
BuildOffsetFields(container, "왼손 오프셋", pc.GetLeftHandOffset());
BuildOffsetFields(container, "오른손 오프셋", pc.GetRightHandOffset());
BuildOffsetFields(container, "머리 오프셋", pc.GetHeadOffset());
// 프랍 목록
container.Add(new Label("부착된 프랍 목록") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginTop = 8 } });
BuildPropList(container, "머리 프랍", pc.GetHeadProps());
BuildPropList(container, "왼손 프랍", pc.GetLeftHandProps());
BuildPropList(container, "오른손 프랍", pc.GetRightHandProps());
}
private void BuildOffsetFields(VisualElement parent, string label, Transform offset)
{
if (offset == null) return;
var foldout = new Foldout { text = label, value = false };
var posField = new Vector3Field("위치") { value = offset.localPosition };
posField.RegisterValueChangedCallback(evt =>
{
Undo.RecordObject(offset, $"Update {label}");
offset.localPosition = evt.newValue;
EditorUtility.SetDirty(offset);
});
foldout.Add(posField);
var rotField = new Vector3Field("회전") { value = offset.localRotation.eulerAngles };
rotField.RegisterValueChangedCallback(evt =>
{
Undo.RecordObject(offset, $"Update {label}");
offset.localRotation = Quaternion.Euler(evt.newValue);
EditorUtility.SetDirty(offset);
});
foldout.Add(rotField);
parent.Add(foldout);
}
private void BuildPropList(VisualElement parent, string title, GameObject[] props)
{
var box = new VisualElement();
box.style.backgroundColor = new Color(0, 0, 0, 0.08f);
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 = 4;
box.style.marginBottom = 4;
box.Add(new Label(title) { style = { unityFontStyleAndWeight = FontStyle.Bold } });
if (props.Length > 0)
{
foreach (var prop in props)
{
if (prop == null) continue;
var row = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center, marginTop = 2 } };
row.Add(new Label(prop.name) { style = { flexGrow = 1 } });
var selectBtn = new Button(() => Selection.activeGameObject = prop) { text = "선택" };
selectBtn.style.width = 60;
row.Add(selectBtn);
box.Add(row);
}
}
else
{
box.Add(new Label("부착된 프랍 없음") { style = { color = new Color(0.6f, 0.6f, 0.6f) } });
}
parent.Add(box);
}
// ========== Calibration ==========
private VisualElement BuildCalibrationSection(CustomRetargetingScript script)
{
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 } };
Button resetBtn = null;
var calibBtn = new Button(() => { script.I_PoseCalibration(); UpdateCacheLabel(); }) { text = "I-포즈 캘리브레이션" };
calibBtn.style.flexGrow = 1; calibBtn.style.marginRight = 2;
btnRow.Add(calibBtn);
resetBtn = new Button(() => { script.ResetPoseAndCache(); UpdateCacheLabel(); }) { text = "캘리브레이션 초기화" };
resetBtn.style.flexGrow = 1;
btnRow.Add(resetBtn);
box.Add(btnRow);
void UpdateCacheLabel()
{
if (script == null) return;
bool cached = script.HasCachedSettings();
cacheLabel.text = cached ? "캘리브레이션 데이터가 저장되어 있습니다." : "저장된 캘리브레이션 데이터가 없습니다.";
resetBtn.style.display = cached ? DisplayStyle.Flex : DisplayStyle.None;
}
box.schedule.Execute(UpdateCacheLabel).Every(1000);
UpdateCacheLabel();
return box;
}
// ========== Helpers ==========
private VisualElement BuildMinMaxRange(string label, SerializedProperty minProp, SerializedProperty maxProp, float limitMin, float limitMax, SerializedObject so)
{
var container = new VisualElement { style = { marginBottom = 6 } };
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;
so.ApplyModifiedProperties();
EditorUtility.SetDirty(so.targetObject);
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;
so.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;
so.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 ApplyFingerPreset(FingerShapedController controller, string presetName)
{
if (!controller.enabled) controller.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 (controller.leftHandEnabled)
{
controller.leftThumbCurl = t; controller.leftIndexCurl = i; controller.leftMiddleCurl = m;
controller.leftRingCurl = r; controller.leftPinkyCurl = p; controller.leftSpreadFingers = s;
}
if (controller.rightHandEnabled)
{
controller.rightThumbCurl = t; controller.rightIndexCurl = i; controller.rightMiddleCurl = m;
controller.rightRingCurl = r; controller.rightPinkyCurl = p; controller.rightSpreadFingers = s;
}
EditorUtility.SetDirty(controller);
}
// ========== Head Calibration ==========
private void CalibrateHeadToForward(SerializedObject so, SerializedProperty xProp, SerializedProperty yProp, SerializedProperty zProp)
{
CustomRetargetingScript script = so.targetObject as CustomRetargetingScript;
if (script == null) { Debug.LogWarning("CustomRetargetingScript가 없습니다."); return; }
Animator targetAnimator = script.GetComponent<Animator>();
if (targetAnimator == null) { Debug.LogWarning("타겟 Animator가 없습니다."); return; }
Transform targetHead = targetAnimator.GetBoneTransform(HumanBodyBones.Head);
if (targetHead == null) { Debug.LogWarning("Head 본을 찾을 수 없습니다."); return; }
Vector3 tPoseForward = script.tPoseHeadForward;
Vector3 tPoseUp = script.tPoseHeadUp;
if (tPoseForward.sqrMagnitude < 0.001f) { Debug.LogWarning("T-포즈 머리 방향이 캐싱되지 않았습니다."); 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);
Debug.Log($"정면 캘리브레이션 완료 - Offset X: {euler.x:F1}°, Y: {euler.y:F1}°, Z: {euler.z:F1}°");
}
}
[System.Serializable]
public class RetargetingPreset
{
public int ikIterations;
public float ikDeltaThreshold;
public float shoulderBlendStrength;
public float shoulderMaxHeightDiff;
public float limbMaxDistance;
public float limbMinDistance;
public float footHeightMinThreshold;
public float footHeightMaxThreshold;
}