Streamingle_URP/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.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

378 lines
20 KiB
C#

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";
// SerializedProperty
private SerializedProperty sourceAnimatorProp;
private SerializedProperty targetAnimatorProp;
private SerializedProperty hipsOffsetXProp;
private SerializedProperty hipsOffsetYProp;
private SerializedProperty hipsOffsetZProp;
private SerializedProperty debugAxisNormalizerProp;
private SerializedProperty fingerCopyModeProp;
private SerializedProperty useMotionFilterProp;
private SerializedProperty filterBufferSizeProp;
private SerializedProperty useBodyRoughMotionProp;
private SerializedProperty useFingerRoughMotionProp;
private SerializedProperty bodyRoughnessProp;
private SerializedProperty fingerRoughnessProp;
private SerializedProperty kneeInOutWeightProp;
private SerializedProperty kneeFrontBackWeightProp;
private SerializedProperty footFrontBackOffsetProp;
private SerializedProperty footInOutOffsetProp;
private SerializedProperty floorHeightProp;
private SerializedProperty avatarScaleProp;
// Dynamic UI
private VisualElement calibrationContainer;
private Label cacheStatusLabel;
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");
useMotionFilterProp = serializedObject.FindProperty("useMotionFilter");
filterBufferSizeProp = serializedObject.FindProperty("filterBufferSize");
useBodyRoughMotionProp = serializedObject.FindProperty("useBodyRoughMotion");
useFingerRoughMotionProp = serializedObject.FindProperty("useFingerRoughMotion");
bodyRoughnessProp = serializedObject.FindProperty("bodyRoughness");
fingerRoughnessProp = serializedObject.FindProperty("fingerRoughness");
kneeInOutWeightProp = serializedObject.FindProperty("kneeInOutWeight");
kneeFrontBackWeightProp = serializedObject.FindProperty("kneeFrontBackWeight");
footFrontBackOffsetProp = serializedObject.FindProperty("footFrontBackOffset");
footInOutOffsetProp = serializedObject.FindProperty("footInOutOffset");
floorHeightProp = serializedObject.FindProperty("floorHeight");
avatarScaleProp = serializedObject.FindProperty("avatarScale");
}
public override VisualElement CreateInspectorGUI()
{
var root = new VisualElement();
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
if (commonUss != null) root.styleSheets.Add(commonUss);
// 원본 Animator
root.Add(new PropertyField(sourceAnimatorProp, "원본 Animator"));
// 아바타 크기 설정
var scaleFoldout = new Foldout { text = "아바타 크기 설정", value = true };
scaleFoldout.Add(new PropertyField(avatarScaleProp, "아바타 크기"));
root.Add(scaleFoldout);
// 힙 위치 보정
root.Add(BuildHipsSection());
// 무릎 위치 조정
var kneeFoldout = new Foldout { text = "무릎 위치 조정", value = true };
kneeFoldout.Add(new Slider("무릎 앞/뒤 가중치", -1f, 1f) { showInputField = true, tooltip = "음수: 뒤로, 양수: 앞으로" });
kneeFoldout.Q<Slider>().BindProperty(kneeFrontBackWeightProp);
var kneeInOut = new Slider("무릎 안/밖 가중치", -1f, 1f) { showInputField = true, tooltip = "음수: 안쪽, 양수: 바깥쪽" };
kneeInOut.BindProperty(kneeInOutWeightProp);
kneeFoldout.Add(kneeInOut);
root.Add(kneeFoldout);
// 발 IK 위치 조정
var footFoldout = new Foldout { text = "발 IK 위치 조정", value = true };
var footFB = new Slider("발 앞/뒤 오프셋", -1f, 1f) { showInputField = true, tooltip = "+: 앞으로, -: 뒤로" };
footFB.BindProperty(footFrontBackOffsetProp);
footFoldout.Add(footFB);
var footIO = new Slider("발 벌리기/모으기", -1f, 1f) { showInputField = true, tooltip = "+: 벌리기, -: 모으기" };
footIO.BindProperty(footInOutOffsetProp);
footFoldout.Add(footIO);
root.Add(footFoldout);
// 손가락 복제 설정
root.Add(BuildFingerCopySection());
// 모션 필터링 설정
root.Add(BuildMotionFilterSection());
// 러프 모션 설정
root.Add(BuildRoughMotionSection());
// 바닥 높이 조정
var floorFoldout = new Foldout { text = "바닥 높이 조정", value = false };
floorFoldout.Add(new PropertyField(floorHeightProp, "바닥 높이 (-1 ~ 1)"));
root.Add(floorFoldout);
// 캐시 상태 + 캘리브레이션 버튼
root.Add(BuildCacheSection());
// 변경 시 저장
root.TrackSerializedObjectValue(serializedObject, so =>
{
if (target == null) return;
var script = (CustomRetargetingScript)target;
if (script.targetAnimator != null)
script.SaveSettings();
});
return root;
}
private VisualElement BuildHipsSection()
{
var foldout = new Foldout { text = "힙 위치 보정 (로컬 좌표계)" };
// 축 매핑 정보
var axisInfo = new HelpBox("플레이 모드에서 T-포즈 분석 후 축 매핑 정보가 표시됩니다.\n이 매핑은 각 아바타의 힙 로컬 축 방향에 맞춰 자동 계산됩니다.", HelpBoxMessageType.Info);
foldout.Add(axisInfo);
// 주기적으로 축 매핑 정보 갱신
foldout.schedule.Execute(() =>
{
if (target == null || debugAxisNormalizerProp == null) return;
serializedObject.Update();
Vector3 axisMapping = debugAxisNormalizerProp.vector3Value;
if (Application.isPlaying && axisMapping != Vector3.one)
{
string GetAxisName(float value)
{
int axis = Mathf.RoundToInt(Mathf.Abs(value));
string sign = value > 0 ? "+" : "-";
return axis switch { 1 => $"{sign}X", 2 => $"{sign}Y", 3 => $"{sign}Z", _ => "?" };
}
axisInfo.text = "T-포즈에서 분석된 축 매핑:\n" +
$" 좌우 오프셋 → 로컬 {GetAxisName(axisMapping.x)} 축\n" +
$" 상하 오프셋 → 로컬 {GetAxisName(axisMapping.y)} 축\n" +
$" 앞뒤 오프셋 → 로컬 {GetAxisName(axisMapping.z)} 축\n\n" +
"이 매핑 덕분에 모든 아바타에서 동일하게 작동합니다.";
}
else
{
axisInfo.text = "플레이 모드에서 T-포즈 분석 후 축 매핑 정보가 표시됩니다.\n이 매핑은 각 아바타의 힙 로컬 축 방향에 맞춰 자동 계산됩니다.";
}
}).Every(500);
foldout.Add(new PropertyField(hipsOffsetXProp, "좌우 오프셋 (←-/+→)") { tooltip = "캐릭터 기준 왼쪽(-) / 오른쪽(+)" });
foldout.Add(new PropertyField(hipsOffsetYProp, "상하 오프셋 (↓-/+↑)") { tooltip = "캐릭터 기준 아래(-) / 위(+)" });
foldout.Add(new PropertyField(hipsOffsetZProp, "앞뒤 오프셋 (←-/+→)") { tooltip = "캐릭터 기준 뒤(-) / 앞(+)" });
foldout.Add(new HelpBox("로컬 좌표계 기반: 캐릭터의 회전 상태와 관계없이 항상 캐릭터 기준으로 이동합니다.", HelpBoxMessageType.Info));
return foldout;
}
private VisualElement BuildFingerCopySection()
{
var foldout = new Foldout { text = "손가락 복제 설정" };
foldout.Add(new PropertyField(fingerCopyModeProp, "복제 방식") { tooltip = "손가락 포즈를 복제하는 방식을 선택합니다." });
// Mingle 모드 캘리브레이션 컨테이너
calibrationContainer = new VisualElement();
calibrationContainer.style.backgroundColor = new Color(0, 0, 0, 0.1f);
calibrationContainer.style.borderTopLeftRadius = calibrationContainer.style.borderTopRightRadius =
calibrationContainer.style.borderBottomLeftRadius = calibrationContainer.style.borderBottomRightRadius = 4;
calibrationContainer.style.paddingTop = calibrationContainer.style.paddingBottom =
calibrationContainer.style.paddingLeft = calibrationContainer.style.paddingRight = 6;
calibrationContainer.style.marginTop = 4;
calibrationContainer.Add(new Label("Mingle 캘리브레이션") { style = { unityFontStyleAndWeight = FontStyle.Bold } });
calibrationContainer.Add(new HelpBox(
"Mingle 모드는 소스 아바타의 손가락 회전 범위를 캘리브레이션하여 타겟에 적용합니다.\n" +
"1. 손가락을 완전히 펼친 상태에서 '펼침 기록' 클릭\n" +
"2. 손가락을 완전히 모은(주먹) 상태에서 '모음 기록' 클릭",
HelpBoxMessageType.Info));
// 수동 캘리브레이션 버튼
var manualRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
var openBtn = new Button(() => ((CustomRetargetingScript)target).CalibrateMingleOpen()) { text = "펼침 기록 (Open)" };
openBtn.style.flexGrow = 1; openBtn.style.marginRight = 2;
manualRow.Add(openBtn);
var closeBtn = new Button(() => ((CustomRetargetingScript)target).CalibrateMingleClose()) { text = "모음 기록 (Close)" };
closeBtn.style.flexGrow = 1;
manualRow.Add(closeBtn);
calibrationContainer.Add(manualRow);
// 자동 캘리브레이션 버튼
var autoBtn = new Button(() => ((CustomRetargetingScript)target).StartAutoCalibration())
{ text = "자동 캘리브레이션 (3초 펼침 → 3초 모음)" };
autoBtn.style.marginTop = 4;
calibrationContainer.Add(autoBtn);
// 플레이 모드 경고
var playWarning = new HelpBox("캘리브레이션은 플레이 모드에서만 가능합니다.", HelpBoxMessageType.Warning);
calibrationContainer.Add(playWarning);
// 자동 캘리브레이션 진행 상태
var autoCalibStatus = new VisualElement();
autoCalibStatus.style.backgroundColor = new Color(0, 0, 0, 0.15f);
autoCalibStatus.style.borderTopLeftRadius = autoCalibStatus.style.borderTopRightRadius =
autoCalibStatus.style.borderBottomLeftRadius = autoCalibStatus.style.borderBottomRightRadius = 4;
autoCalibStatus.style.paddingTop = autoCalibStatus.style.paddingBottom =
autoCalibStatus.style.paddingLeft = autoCalibStatus.style.paddingRight = 4;
autoCalibStatus.style.marginTop = 4;
var statusLabel = new Label("자동 캘리브레이션 진행 중");
statusLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
autoCalibStatus.Add(statusLabel);
var statusDetailLabel = new Label();
autoCalibStatus.Add(statusDetailLabel);
var timeLabel = new Label();
autoCalibStatus.Add(timeLabel);
var cancelBtn = new Button(() => ((CustomRetargetingScript)target).StopAutoCalibration()) { text = "취소" };
autoCalibStatus.Add(cancelBtn);
calibrationContainer.Add(autoCalibStatus);
foldout.Add(calibrationContainer);
// 주기적으로 Mingle 모드 표시/숨김 + 캘리브레이션 상태 갱신
foldout.schedule.Execute(() =>
{
if (target == null) return;
serializedObject.Update();
bool isMingle = fingerCopyModeProp.enumValueIndex == (int)EnumsList.FingerCopyMode.Mingle;
calibrationContainer.style.display = isMingle ? DisplayStyle.Flex : DisplayStyle.None;
if (isMingle)
{
bool isPlaying = Application.isPlaying;
var script = (CustomRetargetingScript)target;
bool isAutoCalib = isPlaying && script.IsAutoCalibrating;
openBtn.SetEnabled(isPlaying && !isAutoCalib);
closeBtn.SetEnabled(isPlaying && !isAutoCalib);
autoBtn.SetEnabled(isPlaying && !isAutoCalib);
playWarning.style.display = isPlaying ? DisplayStyle.None : DisplayStyle.Flex;
manualRow.style.display = isAutoCalib ? DisplayStyle.None : DisplayStyle.Flex;
autoBtn.style.display = isAutoCalib ? DisplayStyle.None : DisplayStyle.Flex;
autoCalibStatus.style.display = isAutoCalib ? DisplayStyle.Flex : DisplayStyle.None;
if (isAutoCalib)
{
statusDetailLabel.text = $"상태: {script.AutoCalibrationStatus}";
timeLabel.text = $"남은 시간: {script.AutoCalibrationTimeRemaining:F1}초";
}
}
}).Every(200);
// 초기 상태
bool initMingle = fingerCopyModeProp.enumValueIndex == (int)EnumsList.FingerCopyMode.Mingle;
calibrationContainer.style.display = initMingle ? DisplayStyle.Flex : DisplayStyle.None;
autoCalibStatus.style.display = DisplayStyle.None;
return foldout;
}
private VisualElement BuildMotionFilterSection()
{
var foldout = new Foldout { text = "모션 필터링 설정" };
foldout.Add(new PropertyField(useMotionFilterProp, "모션 필터 사용") { tooltip = "모션 필터링을 적용할지 여부를 설정합니다." });
var bufferField = new PropertyField(filterBufferSizeProp, "필터 버퍼 크기") { tooltip = "모션 필터링에 사용할 버퍼의 크기를 설정합니다. (2-10)" };
foldout.Add(bufferField);
foldout.TrackPropertyValue(useMotionFilterProp, prop =>
{
bufferField.style.display = prop.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
});
bufferField.style.display = useMotionFilterProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
return foldout;
}
private VisualElement BuildRoughMotionSection()
{
var foldout = new Foldout { text = "러프 모션 설정" };
// 몸
foldout.Add(new PropertyField(useBodyRoughMotionProp, "몸 러프 모션 사용") { tooltip = "몸의 러프한 움직임을 적용할지 여부를 설정합니다." });
var bodyRoughField = new PropertyField(bodyRoughnessProp, "몸 러프니스") { tooltip = "몸 전체의 러프한 정도 (0: 없음, 1: 최대)" };
foldout.Add(bodyRoughField);
foldout.TrackPropertyValue(useBodyRoughMotionProp, prop =>
{
bodyRoughField.style.display = prop.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
});
bodyRoughField.style.display = useBodyRoughMotionProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
// 손가락
foldout.Add(new PropertyField(useFingerRoughMotionProp, "손가락 러프 모션 사용") { tooltip = "손가락의 러프한 움직임을 적용할지 여부를 설정합니다." });
var fingerRoughField = new PropertyField(fingerRoughnessProp, "손가락 러프니스") { tooltip = "손가락의 러프한 정도 (0: 없음, 1: 최대)" };
foldout.Add(fingerRoughField);
foldout.TrackPropertyValue(useFingerRoughMotionProp, prop =>
{
fingerRoughField.style.display = prop.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
});
fingerRoughField.style.display = useFingerRoughMotionProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
return foldout;
}
private VisualElement BuildCacheSection()
{
var container = new VisualElement { style = { marginTop = 8 } };
var script = (CustomRetargetingScript)target;
bool hasCached = script.HasCachedSettings();
cacheStatusLabel = new Label(hasCached ?
"캘리브레이션 데이터가 저장되어 있습니다." :
"저장된 캘리브레이션 데이터가 없습니다.");
cacheStatusLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
container.Add(cacheStatusLabel);
var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
var calibBtn = new Button(() =>
{
((CustomRetargetingScript)target).I_PoseCalibration();
UpdateCacheStatus();
}) { text = "I-포즈 캘리브레이션" };
calibBtn.style.flexGrow = 1;
calibBtn.style.marginRight = 2;
btnRow.Add(calibBtn);
var deleteCacheBtn = new Button(() =>
{
((CustomRetargetingScript)target).ResetPoseAndCache();
UpdateCacheStatus();
}) { text = "캐시 데이터 삭제" };
deleteCacheBtn.style.flexGrow = 1;
btnRow.Add(deleteCacheBtn);
container.Add(btnRow);
// 주기적으로 캐시 상태 갱신
container.schedule.Execute(() =>
{
if (target == null) return;
bool cached = ((CustomRetargetingScript)target).HasCachedSettings();
deleteCacheBtn.style.display = cached ? DisplayStyle.Flex : DisplayStyle.None;
cacheStatusLabel.text = cached ?
"캘리브레이션 데이터가 저장되어 있습니다." :
"저장된 캘리브레이션 데이터가 없습니다.";
}).Every(1000);
deleteCacheBtn.style.display = hasCached ? DisplayStyle.Flex : DisplayStyle.None;
return container;
}
private void UpdateCacheStatus()
{
if (cacheStatusLabel == null || target == null) return;
bool hasCached = ((CustomRetargetingScript)target).HasCachedSettings();
cacheStatusLabel.text = hasCached ?
"캘리브레이션 데이터가 저장되어 있습니다." :
"저장된 캘리브레이션 데이터가 없습니다.";
}
}
}