Fix : 리타겟팅 및 옵티 연결부 안정화
This commit is contained in:
parent
88515f51a8
commit
27ac11e2b4
@ -1768,10 +1768,11 @@ public class OptitrackStreamingClient : MonoBehaviour
|
||||
private System.Collections.IEnumerator ConnectCoroutine()
|
||||
{
|
||||
m_receivedFrameSinceConnect = false;
|
||||
|
||||
// --- 메인 스레드: 주소/모드 파싱 (Unity API 미사용, 즉시 완료) ---
|
||||
IPAddress serverAddr;
|
||||
IPAddress localAddr;
|
||||
NatNetConnectionType connType;
|
||||
|
||||
try
|
||||
{
|
||||
serverAddr = IPAddress.Parse( ServerAddress );
|
||||
@ -1780,42 +1781,75 @@ public class OptitrackStreamingClient : MonoBehaviour
|
||||
connType = ConnectionType == ClientConnectionType.Unicast
|
||||
? NatNetConnectionType.NatNetConnectionType_Unicast
|
||||
: NatNetConnectionType.NatNetConnectionType_Multicast;
|
||||
|
||||
m_client = new NatNetClient();
|
||||
m_client.Connect( connType, localAddr, serverAddr );
|
||||
|
||||
// SetProperty는 최초 연결에서만 전송 — 재연결 시 Motive 글로벌 설정 반복 변경 방지
|
||||
if ( !m_hasAppliedServerSettings )
|
||||
{
|
||||
// Remotely change the Skeleton Coordinate property to Global/Local
|
||||
if (SkeletonCoordinates == StreamingCoordinatesValues.Global)
|
||||
m_client.RequestCommand("SetProperty,,Skeleton Coordinates,false");
|
||||
else
|
||||
m_client.RequestCommand("SetProperty,,Skeleton Coordinates,true");
|
||||
|
||||
// Remotely change the Bone Naming Convention to Motive/FBX/BVH
|
||||
if (BoneNamingConvention == OptitrackBoneNameConvention.Motive)
|
||||
m_client.RequestCommand("SetProperty,,Bone Naming Convention,0");
|
||||
else if (BoneNamingConvention == OptitrackBoneNameConvention.FBX)
|
||||
m_client.RequestCommand("SetProperty,,Bone Naming Convention,1");
|
||||
else if (BoneNamingConvention == OptitrackBoneNameConvention.BVH)
|
||||
m_client.RequestCommand("SetProperty,,Bone Naming Convention,2");
|
||||
|
||||
m_hasAppliedServerSettings = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log(GetType().FullName + ": 재연결 — SetProperty 명령 스킵 (최초 연결에서 이미 적용됨).", this);
|
||||
}
|
||||
}
|
||||
catch ( Exception ex )
|
||||
{
|
||||
Debug.LogException( ex, this );
|
||||
Debug.LogError( GetType().FullName + ": Error connecting to server; check your configuration, and make sure the server is currently streaming.", this );
|
||||
if (m_client != null) { m_client.Dispose(); m_client = null; }
|
||||
yield break;
|
||||
}
|
||||
|
||||
// --- 백그라운드 스레드: 블로킹 네이티브 연결 + SetProperty ---
|
||||
// NatNet_Client_Connect 는 서버가 즉시 응답하지 않으면 내부 타임아웃까지 블로킹된다.
|
||||
// 코루틴은 메인 스레드에서 실행되므로 여기서 직접 호출하면 매 재접속마다 화면이 멈춘다.
|
||||
// Task 로 분리하고 메인 스레드는 매 프레임 양보(yield)하며 완료를 폴링한다.
|
||||
bool applyServerSettings = !m_hasAppliedServerSettings;
|
||||
NatNetClient connectedClient = null;
|
||||
Exception connectError = null;
|
||||
|
||||
System.Threading.Tasks.Task connectTask = System.Threading.Tasks.Task.Run( () =>
|
||||
{
|
||||
NatNetClient c = null;
|
||||
try
|
||||
{
|
||||
c = new NatNetClient();
|
||||
c.Connect( connType, localAddr, serverAddr );
|
||||
|
||||
// SetProperty는 최초 연결에서만 전송 — 재연결 시 Motive 글로벌 설정 반복 변경 방지
|
||||
if ( applyServerSettings )
|
||||
{
|
||||
// Remotely change the Skeleton Coordinate property to Global/Local
|
||||
if (SkeletonCoordinates == StreamingCoordinatesValues.Global)
|
||||
c.RequestCommand("SetProperty,,Skeleton Coordinates,false");
|
||||
else
|
||||
c.RequestCommand("SetProperty,,Skeleton Coordinates,true");
|
||||
|
||||
// Remotely change the Bone Naming Convention to Motive/FBX/BVH
|
||||
if (BoneNamingConvention == OptitrackBoneNameConvention.Motive)
|
||||
c.RequestCommand("SetProperty,,Bone Naming Convention,0");
|
||||
else if (BoneNamingConvention == OptitrackBoneNameConvention.FBX)
|
||||
c.RequestCommand("SetProperty,,Bone Naming Convention,1");
|
||||
else if (BoneNamingConvention == OptitrackBoneNameConvention.BVH)
|
||||
c.RequestCommand("SetProperty,,Bone Naming Convention,2");
|
||||
}
|
||||
|
||||
connectedClient = c;
|
||||
}
|
||||
catch ( Exception ex )
|
||||
{
|
||||
connectError = ex;
|
||||
if ( c != null ) { try { c.Dispose(); } catch { } }
|
||||
}
|
||||
} );
|
||||
|
||||
// 메인 스레드는 매 프레임 양보하며 연결 완료를 대기 → 렌더링/입력이 멈추지 않음
|
||||
while ( !connectTask.IsCompleted )
|
||||
yield return null;
|
||||
|
||||
if ( connectError != null || connectedClient == null )
|
||||
{
|
||||
if ( connectError != null )
|
||||
Debug.LogException( connectError, this );
|
||||
Debug.LogError( GetType().FullName + ": Error connecting to server; check your configuration, and make sure the server is currently streaming.", this );
|
||||
yield break;
|
||||
}
|
||||
|
||||
m_client = connectedClient;
|
||||
if ( applyServerSettings )
|
||||
m_hasAppliedServerSettings = true;
|
||||
else
|
||||
Debug.Log(GetType().FullName + ": 재연결 — SetProperty 명령 스킵 (최초 연결에서 이미 적용됨).", this);
|
||||
|
||||
// SetProperty 명령이 서버에 적용될 때까지 대기 (재연결 시 SetProperty 스킵했으면 대기 불필요)
|
||||
if (!m_isReconnecting)
|
||||
yield return new UnityEngine.WaitForSeconds( 0.1f );
|
||||
|
||||
BIN
Assets/Resources/StreamingleDashboard/dashboard_script.txt
(Stored with Git LFS)
BIN
Assets/Resources/StreamingleDashboard/dashboard_script.txt
(Stored with Git LFS)
Binary file not shown.
@ -22,28 +22,9 @@ namespace KindRetargeting
|
||||
// IK 컴포넌트 참조
|
||||
[SerializeField] public TwoBoneIKSolver ikSolver = new TwoBoneIKSolver();
|
||||
|
||||
[Header("힙 위치 보정 (로컬 좌표계 기반)")]
|
||||
[SerializeField, Range(-1, 1)]
|
||||
private float hipsOffsetX = 0f; // 캐릭터 기준 좌우 (항상 Right 방향)
|
||||
[SerializeField, Range(-1, 1)]
|
||||
private float hipsOffsetY = 0f; // 캐릭터 기준 상하 (항상 Up 방향)
|
||||
[SerializeField, Range(-1, 1)]
|
||||
private float hipsOffsetZ = 0f; // 캐릭터 기준 앞뒤 (항상 Forward 방향)
|
||||
|
||||
[HideInInspector] public float HipsWeightOffset = 1f;
|
||||
[HideInInspector] public float ChairSeatHeightOffset = 0f; // 의자 좌석 높이 오프셋 (월드 Y 기준)
|
||||
|
||||
// 축 매핑: 월드 방향(Right/Up/Forward)을 담당하는 로컬 축을 저장
|
||||
// 예: localAxisForWorldRight = (0, 0, 1) 이면 로컬 Z축이 월드 Right 방향을 담당
|
||||
// 부호는 방향을 나타냄: (0, 0, -1)이면 로컬 -Z가 월드 Right를 담당
|
||||
private Vector3 localAxisForWorldRight = Vector3.right; // 월드 좌우(Right)를 담당하는 로컬 축
|
||||
private Vector3 localAxisForWorldUp = Vector3.up; // 월드 상하(Up)를 담당하는 로컬 축
|
||||
private Vector3 localAxisForWorldForward = Vector3.forward; // 월드 앞뒤(Forward)를 담당하는 로컬 축
|
||||
|
||||
[Header("축 정규화 정보 (읽기 전용)")]
|
||||
[SerializeField]
|
||||
public Vector3 debugAxisNormalizer = Vector3.one;
|
||||
|
||||
/// <summary>
|
||||
/// 소스 본 Transform 접근 래퍼 (OptiTrack 매핑 사용)
|
||||
/// </summary>
|
||||
@ -76,13 +57,6 @@ namespace KindRetargeting
|
||||
// HumanBodyBones.LastBone을 이용한 본 순회 범위
|
||||
private int lastBoneIndex = 55; // 0~54: 몸체 + 손가락 전부
|
||||
|
||||
[Header("무릎 안/밖 조정")]
|
||||
[SerializeField, Range(-1f, 1f)]
|
||||
private float kneeInOutWeight = 0f; // 무릎 안/밖 위치 조정 가중치
|
||||
[Header("무릎 앞/뒤 조정")]
|
||||
[SerializeField, Range(-1f, 1f)]
|
||||
private float kneeFrontBackWeight = 0.4f; // 무릎 앞/뒤 위치 조정 가중치
|
||||
|
||||
[Header("발 IK 위치 조정")]
|
||||
[SerializeField, Range(-1f, 1f)]
|
||||
private float footFrontBackOffset = 0f; // 발 앞뒤 오프셋 (+: 앞, -: 뒤)
|
||||
@ -153,11 +127,6 @@ namespace KindRetargeting
|
||||
[System.Serializable]
|
||||
private class RetargetingSettings
|
||||
{
|
||||
public float hipsOffsetX; // 변경: hipsWeight → hipsOffsetX/Y/Z
|
||||
public float hipsOffsetY;
|
||||
public float hipsOffsetZ;
|
||||
public float kneeInOutWeight;
|
||||
public float kneeFrontBackWeight;
|
||||
public float footFrontBackOffset; // 발 앞뒤 오프셋
|
||||
public float footInOutOffset; // 발 안쪽/바깥쪽 오프셋
|
||||
public float floorHeight;
|
||||
@ -203,9 +172,6 @@ namespace KindRetargeting
|
||||
// IK 타겟 생성 (무릎 시각화 오브젝트 포함)
|
||||
CreateIKTargets();
|
||||
|
||||
// T-포즈 전에 축 정규화 계수 계산
|
||||
CalculateAxisNormalizer();
|
||||
|
||||
// 원본 및 대상 아바타를 T-포즈로 복원
|
||||
// OptiTrack 소스는 Humanoid가 아니므로 현재 포즈를 기준으로 캐싱
|
||||
if (optitrackSource != null)
|
||||
@ -275,105 +241,6 @@ namespace KindRetargeting
|
||||
fingerShaped.Initialize(targetAnimator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 타겟 아바타의 로컬 축과 월드 축의 관계를 분석하여 축 매핑을 계산합니다.
|
||||
/// T-포즈 상태에서 힙의 각 로컬 축이 월드의 어느 방향을 가리키는지 분석합니다.
|
||||
///
|
||||
/// 예시:
|
||||
/// - 아바타 A: 로컬 Y가 월드 Up을 가리킴 → localAxisForWorldUp = (0, 1, 0)
|
||||
/// - 아바타 B: 로컬 Z가 월드 Up을 가리킴 → localAxisForWorldUp = (0, 0, 1)
|
||||
/// - 아바타 C: 로컬 -Z가 월드 Up을 가리킴 → localAxisForWorldUp = (0, 0, -1)
|
||||
///
|
||||
/// 이를 통해 hipsOffsetY는 항상 "위/아래" 방향으로 작동합니다.
|
||||
/// </summary>
|
||||
private void CalculateAxisNormalizer()
|
||||
{
|
||||
if (targetAnimator == null) return;
|
||||
|
||||
Transform hips = targetAnimator.GetBoneTransform(HumanBodyBones.Hips);
|
||||
if (hips == null) return;
|
||||
|
||||
// 힙의 각 로컬 축을 월드 공간으로 변환
|
||||
Vector3 localXInWorld = hips.TransformDirection(Vector3.right).normalized;
|
||||
Vector3 localYInWorld = hips.TransformDirection(Vector3.up).normalized;
|
||||
Vector3 localZInWorld = hips.TransformDirection(Vector3.forward).normalized;
|
||||
|
||||
// 월드 Right(X)에 가장 가까운 로컬 축 찾기
|
||||
localAxisForWorldRight = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.right, out string rightAxisName);
|
||||
|
||||
// 월드 Up(Y)에 가장 가까운 로컬 축 찾기
|
||||
localAxisForWorldUp = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.up, out string upAxisName);
|
||||
|
||||
// 월드 Forward(Z)에 가장 가까운 로컬 축 찾기
|
||||
localAxisForWorldForward = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.forward, out string forwardAxisName);
|
||||
|
||||
// 디버그용: 각 오프셋이 어느 로컬 축에 매핑되는지 표시
|
||||
// X: 좌우 오프셋이 사용하는 로컬 축 (1=X, 2=Y, 3=Z, 부호는 방향)
|
||||
// Y: 상하 오프셋이 사용하는 로컬 축
|
||||
// Z: 앞뒤 오프셋이 사용하는 로컬 축
|
||||
debugAxisNormalizer = new Vector3(
|
||||
GetAxisIndex(localAxisForWorldRight),
|
||||
GetAxisIndex(localAxisForWorldUp),
|
||||
GetAxisIndex(localAxisForWorldForward)
|
||||
);
|
||||
|
||||
Debug.Log($"[{gameObject.name}] 축 매핑 분석 완료:\n" +
|
||||
$" 월드 Right(좌우) ← 로컬 {rightAxisName} → 매핑: {localAxisForWorldRight}\n" +
|
||||
$" 월드 Up(상하) ← 로컬 {upAxisName} → 매핑: {localAxisForWorldUp}\n" +
|
||||
$" 월드 Forward(앞뒤) ← 로컬 {forwardAxisName} → 매핑: {localAxisForWorldForward}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 축 벡터를 인덱스로 변환합니다 (디버그용).
|
||||
/// X축=1, Y축=2, Z축=3, 부호는 방향을 나타냄
|
||||
/// </summary>
|
||||
private float GetAxisIndex(Vector3 axisVector)
|
||||
{
|
||||
if (Mathf.Abs(axisVector.x) > 0.5f)
|
||||
return 1f * Mathf.Sign(axisVector.x); // X축
|
||||
else if (Mathf.Abs(axisVector.y) > 0.5f)
|
||||
return 2f * Mathf.Sign(axisVector.y); // Y축
|
||||
else
|
||||
return 3f * Mathf.Sign(axisVector.z); // Z축
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 세 로컬 축 중에서 목표 월드 방향과 가장 일치하는 축을 찾아 로컬 축 벡터를 반환합니다.
|
||||
/// </summary>
|
||||
/// <param name="localXInWorld">로컬 X축의 월드 방향</param>
|
||||
/// <param name="localYInWorld">로컬 Y축의 월드 방향</param>
|
||||
/// <param name="localZInWorld">로컬 Z축의 월드 방향</param>
|
||||
/// <param name="worldDirection">비교할 월드 방향</param>
|
||||
/// <param name="matchedAxisName">매칭된 축 이름 (출력용)</param>
|
||||
/// <returns>해당 월드 방향을 담당하는 로컬 축 벡터 (부호 포함)</returns>
|
||||
private Vector3 FindBestLocalAxisForWorld(Vector3 localXInWorld, Vector3 localYInWorld, Vector3 localZInWorld, Vector3 worldDirection, out string matchedAxisName)
|
||||
{
|
||||
float dotX = Vector3.Dot(localXInWorld, worldDirection);
|
||||
float dotY = Vector3.Dot(localYInWorld, worldDirection);
|
||||
float dotZ = Vector3.Dot(localZInWorld, worldDirection);
|
||||
|
||||
float absDotX = Mathf.Abs(dotX);
|
||||
float absDotY = Mathf.Abs(dotY);
|
||||
float absDotZ = Mathf.Abs(dotZ);
|
||||
|
||||
// 가장 큰 내적값을 가진 축이 해당 월드 방향과 가장 일치하는 축
|
||||
if (absDotX >= absDotY && absDotX >= absDotZ)
|
||||
{
|
||||
matchedAxisName = dotX > 0 ? "+X (Right)" : "-X (Left)";
|
||||
return Vector3.right * Mathf.Sign(dotX); // 로컬 X축 (부호 포함)
|
||||
}
|
||||
else if (absDotY >= absDotX && absDotY >= absDotZ)
|
||||
{
|
||||
matchedAxisName = dotY > 0 ? "+Y (Up)" : "-Y (Down)";
|
||||
return Vector3.up * Mathf.Sign(dotY); // 로컬 Y축 (부호 포함)
|
||||
}
|
||||
else
|
||||
{
|
||||
matchedAxisName = dotZ > 0 ? "+Z (Forward)" : "-Z (Back)";
|
||||
return Vector3.forward * Mathf.Sign(dotZ); // 로컬 Z축 (부호 포함)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HumanPoseHandler를 초기화합니다.
|
||||
/// </summary>
|
||||
@ -457,11 +324,6 @@ namespace KindRetargeting
|
||||
|
||||
var settings = new RetargetingSettings
|
||||
{
|
||||
hipsOffsetX = hipsOffsetX,
|
||||
hipsOffsetY = hipsOffsetY,
|
||||
hipsOffsetZ = hipsOffsetZ,
|
||||
kneeInOutWeight = kneeInOutWeight,
|
||||
kneeFrontBackWeight = kneeFrontBackWeight,
|
||||
footFrontBackOffset = footFrontBackOffset,
|
||||
footInOutOffset = footInOutOffset,
|
||||
floorHeight = floorHeight,
|
||||
@ -515,11 +377,6 @@ namespace KindRetargeting
|
||||
var settings = JsonUtility.FromJson<RetargetingSettings>(json);
|
||||
|
||||
// 설정 적용
|
||||
hipsOffsetX = settings.hipsOffsetX;
|
||||
hipsOffsetY = settings.hipsOffsetY;
|
||||
hipsOffsetZ = settings.hipsOffsetZ;
|
||||
kneeInOutWeight = settings.kneeInOutWeight;
|
||||
kneeFrontBackWeight = settings.kneeFrontBackWeight;
|
||||
footFrontBackOffset = settings.footFrontBackOffset;
|
||||
footInOutOffset = settings.footInOutOffset;
|
||||
floorHeight = settings.floorHeight;
|
||||
@ -928,44 +785,25 @@ namespace KindRetargeting
|
||||
|
||||
if (sourceHips != null && targetHips != null)
|
||||
{
|
||||
// 1. 힙 회전 먼저 동기화 (회전 오프셋 적용)
|
||||
Quaternion finalHipsRotation = sourceHips.rotation;
|
||||
// 1. 힙 회전 동기화 (회전 오프셋 적용)
|
||||
if (rotationOffsets.TryGetValue(HumanBodyBones.Hips, out Quaternion hipsOffset))
|
||||
{
|
||||
finalHipsRotation = sourceHips.rotation * hipsOffset;
|
||||
targetHips.rotation = finalHipsRotation;
|
||||
targetHips.rotation = sourceHips.rotation * hipsOffset;
|
||||
}
|
||||
|
||||
// 2. 캐릭터 기준 로컬 오프셋 계산 (축 정규화 적용)
|
||||
//
|
||||
// 문제: 아바타마다 힙의 로컬 축 방향이 다름
|
||||
// - 아바타 A: 로컬 Y가 "위", 로컬 Z가 "앞"
|
||||
// - 아바타 B: 로컬 Z가 "위", 로컬 X가 "앞"
|
||||
//
|
||||
// 해결: T-포즈에서 계산한 축 매핑을 사용
|
||||
// - localAxisForWorldRight: 실제로 "오른쪽"을 가리키는 로컬 축
|
||||
// - localAxisForWorldUp: 실제로 "위"를 가리키는 로컬 축
|
||||
// - localAxisForWorldForward: 실제로 "앞"을 가리키는 로컬 축
|
||||
//
|
||||
// 이렇게 하면 모든 아바타에서 동일하게 작동합니다.
|
||||
// 2. 힙 위치 동기화 (수동 힙 위치 보정 제거됨 — 소스 위치 직접 사용)
|
||||
Vector3 adjustedPosition = sourceHips.position;
|
||||
|
||||
// 힙의 현재 회전을 기준으로, 정규화된 방향 벡터 계산
|
||||
Vector3 characterRight = finalHipsRotation * localAxisForWorldRight;
|
||||
Vector3 characterUp = finalHipsRotation * localAxisForWorldUp;
|
||||
Vector3 characterForward = finalHipsRotation * localAxisForWorldForward;
|
||||
// 3. 다리 높이 자동 보정 (월드 Y축, 앉기 가중치 HipsWeightOffset 반영)
|
||||
// 타겟 다리가 소스보다 길면 힙을 올려 발 접지를 맞춘다.
|
||||
// 매 프레임 계산되어 avatarScale 변경에도 자동 대응한다
|
||||
// (다리 분절 길이는 포즈와 무관하게 일정하므로 매 프레임 계산해도 안전).
|
||||
adjustedPosition.y += ComputeLegHeightOffset() * HipsWeightOffset;
|
||||
|
||||
Vector3 characterOffset =
|
||||
characterRight * (hipsOffsetX * HipsWeightOffset) + // 캐릭터 기준 좌우
|
||||
characterUp * (hipsOffsetY * HipsWeightOffset) + // 캐릭터 기준 상하
|
||||
characterForward * (hipsOffsetZ * HipsWeightOffset); // 캐릭터 기준 앞뒤
|
||||
|
||||
// 3. 힙 위치 동기화 + 캐릭터 기준 오프셋 적용
|
||||
Vector3 adjustedPosition = sourceHips.position + characterOffset;
|
||||
|
||||
// 4. 바닥 높이 추가 (월드 Y축 - 바닥은 항상 월드 기준)
|
||||
// 4. 바닥 높이 추가 (월드 Y축)
|
||||
adjustedPosition.y += floorHeight;
|
||||
|
||||
// 5. 의자 좌석 높이 오프셋 추가 (월드 Y축 - 로컬 보정과 별개)
|
||||
// 5. 의자 좌석 높이 오프셋 추가 (월드 Y축)
|
||||
adjustedPosition.y += ChairSeatHeightOffset;
|
||||
|
||||
targetHips.position = adjustedPosition;
|
||||
@ -978,6 +816,28 @@ namespace KindRetargeting
|
||||
SyncBoneRotations(skipBone: HumanBodyBones.Hips);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 타겟/소스 왼다리 길이 차이를 반환합니다 (힙 월드 Y 보정용).
|
||||
/// 타겟 다리가 소스보다 길면 양수 → 힙을 올려 발 접지를 맞춘다.
|
||||
/// IK 솔버에 캐싱된 다리 본을 재사용하며, 분절 길이는 포즈와 무관하게 일정하므로
|
||||
/// 매 프레임 호출해도 안전하고 avatarScale 변화에 자동 대응한다.
|
||||
/// </summary>
|
||||
private float ComputeLegHeightOffset()
|
||||
{
|
||||
var leg = ikSolver?.leftLeg;
|
||||
if (leg == null) return 0f;
|
||||
if (leg.upper == null || leg.lower == null || leg.end == null) return 0f;
|
||||
if (leg.sourceUpper == null || leg.sourceLower == null || leg.sourceEnd == null) return 0f;
|
||||
|
||||
float targetLeg = Vector3.Distance(leg.upper.position, leg.lower.position)
|
||||
+ Vector3.Distance(leg.lower.position, leg.end.position);
|
||||
float sourceLeg = Vector3.Distance(leg.sourceUpper.position, leg.sourceLower.position)
|
||||
+ Vector3.Distance(leg.sourceLower.position, leg.sourceEnd.position);
|
||||
|
||||
if (sourceLeg < 0.01f || targetLeg < 0.01f) return 0f;
|
||||
return targetLeg - sourceLeg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 힙을 제외한 모든 본의 회전을 오프셋을 적용하여 동기화합니다.
|
||||
/// </summary>
|
||||
@ -1319,9 +1179,7 @@ namespace KindRetargeting
|
||||
sourceIKpoint = bone == HumanBodyBones.LeftLowerLeg ?
|
||||
sourceIKJoints.leftLowerLeg.position :
|
||||
sourceIKJoints.rightLowerLeg.position;
|
||||
zOffset = kneeFrontBackWeight; // 무릎 앞/뒤 조정
|
||||
yOffset = floorHeight;
|
||||
xOffset = kneeInOutWeight * (bone == HumanBodyBones.LeftLowerLeg ? -1f : 1f); // 무릎 안/밖 조정
|
||||
break;
|
||||
case HumanBodyBones.LeftLowerArm:
|
||||
case HumanBodyBones.RightLowerArm:
|
||||
@ -1430,18 +1288,6 @@ namespace KindRetargeting
|
||||
return File.Exists(filePath);
|
||||
}
|
||||
|
||||
// 무릎 앞/뒤뒤 위치 조정을 위한 public 메서드 추가
|
||||
public void SetKneeFrontBackOffset(float offset)
|
||||
{
|
||||
kneeFrontBackWeight = offset;
|
||||
}
|
||||
|
||||
// 무릎 조정을 위한 public 메서드들
|
||||
public void SetKneeInOutOffset(float offset)
|
||||
{
|
||||
kneeInOutWeight = offset;
|
||||
}
|
||||
|
||||
public void ResetPoseAndCache()
|
||||
{
|
||||
// 캐시 파일 삭제
|
||||
|
||||
@ -31,19 +31,6 @@ namespace KindRetargeting
|
||||
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 };
|
||||
@ -83,58 +70,6 @@ namespace KindRetargeting
|
||||
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()
|
||||
@ -216,6 +151,11 @@ namespace KindRetargeting
|
||||
serializedObject.FindProperty("limbWeight.footHeightMaxThreshold"),
|
||||
0.1f, 1f));
|
||||
|
||||
// 의자 앉기 높이
|
||||
var chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정 (월드 Y 기준)" };
|
||||
chairSlider.BindProperty(serializedObject.FindProperty("limbWeight.chairSeatHeightOffset"));
|
||||
foldout.Add(chairSlider);
|
||||
|
||||
return foldout;
|
||||
}
|
||||
|
||||
@ -345,7 +285,7 @@ namespace KindRetargeting
|
||||
{
|
||||
if (!Application.isPlaying) { Debug.LogWarning("플레이 모드에서만 사용 가능합니다."); return; }
|
||||
AutoCalibrateAll((CustomRetargetingScript)target, serializedObject);
|
||||
}) { text = "전체 자동 보정 (크기 + 힙 높이 + 머리 정면)", tooltip = "아바타 크기, 힙 높이, 머리 정면을 자동 보정합니다." };
|
||||
}) { text = "전체 자동 보정 (크기 + 머리 정면)", tooltip = "아바타 크기와 머리 정면을 자동 보정합니다. 힙 높이는 매 프레임 다리 길이로 자동 유지됩니다." };
|
||||
autoBtn.style.marginTop = 4; autoBtn.style.height = 28;
|
||||
box.Add(autoBtn);
|
||||
|
||||
@ -419,7 +359,6 @@ namespace KindRetargeting
|
||||
|
||||
script.ResetScale();
|
||||
so.FindProperty("avatarScale").floatValue = 1f;
|
||||
so.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script);
|
||||
so.ApplyModifiedProperties();
|
||||
|
||||
EditorApplication.delayCall += () =>
|
||||
@ -440,7 +379,6 @@ namespace KindRetargeting
|
||||
{
|
||||
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");
|
||||
@ -457,37 +395,6 @@ namespace KindRetargeting
|
||||
|
||||
// ========== 유틸리티 ==========
|
||||
|
||||
private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script)
|
||||
{
|
||||
var source = script.optitrackSource;
|
||||
Animator targetAnim = script.targetAnimator;
|
||||
if (source == null || targetAnim == null) return 0f;
|
||||
|
||||
float sourceLeg = GetSourceLegLength(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 float GetSourceLegLength(OptitrackSkeletonAnimator_Mingle source)
|
||||
{
|
||||
Transform upper = source.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
|
||||
Transform lower = source.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
|
||||
Transform foot = source.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;
|
||||
|
||||
@ -195,22 +195,6 @@ public class RetargetingControlWindow : EditorWindow
|
||||
// 가중치 설정
|
||||
panel.Add(BuildWeightSection(script, so));
|
||||
|
||||
// 힙 위치 보정
|
||||
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();
|
||||
@ -245,16 +229,6 @@ public class RetargetingControlWindow : EditorWindow
|
||||
if (headScaleProp != null)
|
||||
scaleContainer.Add(new PropertyField(headScaleProp, "머리 크기"));
|
||||
|
||||
// 아바타 크기 변경 시 다리 길이 자동 보정 (실시간)
|
||||
scaleContainer.TrackPropertyValue(so.FindProperty("avatarScale"), _ =>
|
||||
{
|
||||
if (!Application.isPlaying || script == null) return;
|
||||
var sox = new SerializedObject(script);
|
||||
sox.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script);
|
||||
sox.ApplyModifiedProperties();
|
||||
sox.Dispose();
|
||||
});
|
||||
|
||||
scaleContainer.Bind(so);
|
||||
scaleFoldout.Add(scaleContainer);
|
||||
panel.Add(scaleFoldout);
|
||||
@ -331,71 +305,11 @@ public class RetargetingControlWindow : EditorWindow
|
||||
var smoothField = new PropertyField(so.FindProperty("limbWeight.weightSmoothSpeed"), "가중치 변화 속도");
|
||||
container.Add(smoothField);
|
||||
|
||||
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(() =>
|
||||
{
|
||||
try { if (so == null || so.targetObject == null) return; }
|
||||
catch (System.Exception) { 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 chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정 (월드 Y 기준)" };
|
||||
chairSlider.BindProperty(so.FindProperty("limbWeight.chairSeatHeightOffset"));
|
||||
container.Add(chairSlider);
|
||||
|
||||
// 다리 길이 자동 보정 버튼
|
||||
var autoHipsBtn = new Button(() =>
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
Debug.LogWarning("다리 길이 자동 보정은 플레이 모드에서만 사용 가능합니다.");
|
||||
return;
|
||||
}
|
||||
float offset = CalculateHipsOffsetFromLegDifference(script);
|
||||
var hipsProp = so.FindProperty("hipsOffsetY");
|
||||
hipsProp.floatValue = offset;
|
||||
so.ApplyModifiedProperties();
|
||||
script.SaveSettings();
|
||||
Debug.Log($"자동 보정 완료: hipsOffsetY = {offset:F4}");
|
||||
}) { text = "다리 길이 자동 보정", tooltip = "소스/타겟 다리 길이 차이로 힙 상하 오프셋을 자동 계산합니다. (플레이 모드 전용)" };
|
||||
autoHipsBtn.style.marginTop = 4;
|
||||
autoHipsBtn.style.height = 25;
|
||||
container.Add(autoHipsBtn);
|
||||
|
||||
container.Bind(so);
|
||||
foldout.Add(container);
|
||||
return foldout;
|
||||
}
|
||||
@ -836,7 +750,8 @@ public class RetargetingControlWindow : EditorWindow
|
||||
// ========== Auto Full Calibration ==========
|
||||
|
||||
/// <summary>
|
||||
/// 소스/타겟 목 높이 비율로 avatarScale을 맞추고, 다리 길이 차이로 hipsOffsetY를 보정합니다.
|
||||
/// 소스/타겟 목 높이 비율로 avatarScale을 맞추고 머리 정면을 보정합니다.
|
||||
/// 힙 높이는 매 프레임 다리 길이 자동 보정이 처리하므로 여기서 건드리지 않습니다.
|
||||
/// </summary>
|
||||
private void AutoCalibrateAll(CustomRetargetingScript script, SerializedObject so)
|
||||
{
|
||||
@ -849,11 +764,10 @@ public class RetargetingControlWindow : EditorWindow
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 프레임 1: 스케일 리셋 + 다리 보정 ──
|
||||
// ── 프레임 1: 스케일 리셋 ──
|
||||
script.ResetScale();
|
||||
var scaleProp = so.FindProperty("avatarScale");
|
||||
scaleProp.floatValue = 1f;
|
||||
so.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script);
|
||||
so.ApplyModifiedProperties();
|
||||
|
||||
// ── 프레임 2: 리타게팅 반영 후 목 높이 측정 → avatarScale 설정 ──
|
||||
@ -885,8 +799,6 @@ public class RetargetingControlWindow : EditorWindow
|
||||
if (script == null) return;
|
||||
|
||||
var so3 = new SerializedObject(script);
|
||||
float finalHipsOffset = CalculateHipsOffsetFromLegDifference(script);
|
||||
so3.FindProperty("hipsOffsetY").floatValue = finalHipsOffset;
|
||||
|
||||
var xProp = so3.FindProperty("headRotationOffsetX");
|
||||
var yProp = so3.FindProperty("headRotationOffsetY");
|
||||
@ -898,69 +810,11 @@ public class RetargetingControlWindow : EditorWindow
|
||||
so3.Dispose();
|
||||
|
||||
script.SaveSettings();
|
||||
Debug.Log($"전체 자동 보정 완료: avatarScale = {scaleRatio:F3}, hipsOffsetY = {finalHipsOffset:F4}m");
|
||||
Debug.Log($"전체 자동 보정 완료: avatarScale = {scaleRatio:F3} (힙 높이는 자동 유지)");
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Auto Hips Offset ==========
|
||||
|
||||
/// <summary>
|
||||
/// 소스/타겟 다리 길이 차이로 힙 상하 오프셋을 계산합니다.
|
||||
/// 타겟 다리가 소스보다 짧으면 → 양수 (힙을 올려서 다리를 펴줌)
|
||||
/// 타겟 다리가 소스보다 길면 → 음수 (힙을 내려서 다리를 펴줌)
|
||||
/// </summary>
|
||||
private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script)
|
||||
{
|
||||
var source = script.optitrackSource;
|
||||
Animator target = script.targetAnimator;
|
||||
|
||||
if (source == null || target == null || !target.isHuman)
|
||||
{
|
||||
Debug.LogWarning("소스 OptiTrack 또는 타겟 Animator가 설정되지 않았습니다.");
|
||||
return 0f;
|
||||
}
|
||||
|
||||
float sourceLeg = GetSourceLegLength(source);
|
||||
float targetLeg = GetLegLength(target);
|
||||
|
||||
if (sourceLeg < 0.01f || targetLeg < 0.01f)
|
||||
{
|
||||
Debug.LogWarning("다리 길이를 계산할 수 없습니다. 본이 올바르게 설정되어 있는지 확인해주세요.");
|
||||
return 0f;
|
||||
}
|
||||
|
||||
// 소스 다리가 더 길면 타겟이 뜨므로 힙을 내려야 함 (음수)
|
||||
// 소스 다리가 더 짧으면 타겟 다리가 구부러지므로 힙을 올려야 함 (양수)
|
||||
float diff = targetLeg - sourceLeg;
|
||||
Debug.Log($"소스 다리 길이: {sourceLeg:F4}, 타겟 다리 길이: {targetLeg:F4}, 힙 오프셋: {diff:F4}m");
|
||||
return diff;
|
||||
}
|
||||
|
||||
private float GetLegLength(Animator animator)
|
||||
{
|
||||
Transform upperLeg = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
|
||||
Transform lowerLeg = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
|
||||
Transform foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot);
|
||||
|
||||
if (upperLeg == null || lowerLeg == null || foot == null) return 0f;
|
||||
|
||||
return Vector3.Distance(upperLeg.position, lowerLeg.position)
|
||||
+ Vector3.Distance(lowerLeg.position, foot.position);
|
||||
}
|
||||
|
||||
private float GetSourceLegLength(OptitrackSkeletonAnimator_Mingle source)
|
||||
{
|
||||
Transform upperLeg = source.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
|
||||
Transform lowerLeg = source.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
|
||||
Transform foot = source.GetBoneTransform(HumanBodyBones.LeftFoot);
|
||||
|
||||
if (upperLeg == null || lowerLeg == null || foot == null) return 0f;
|
||||
|
||||
return Vector3.Distance(upperLeg.position, lowerLeg.position)
|
||||
+ Vector3.Distance(lowerLeg.position, foot.position);
|
||||
}
|
||||
|
||||
private void CalibrateHeadToForward(SerializedObject so, SerializedProperty xProp, SerializedProperty yProp, SerializedProperty zProp)
|
||||
{
|
||||
CustomRetargetingScript script = so.targetObject as CustomRetargetingScript;
|
||||
|
||||
BIN
Assets/Scripts/KindRetargeting/README.md
(Stored with Git LFS)
BIN
Assets/Scripts/KindRetargeting/README.md
(Stored with Git LFS)
Binary file not shown.
@ -166,13 +166,6 @@ namespace KindRetargeting.Remote
|
||||
}
|
||||
break;
|
||||
|
||||
case "autoHipsOffset":
|
||||
{
|
||||
string charId = json["characterId"]?.ToString();
|
||||
AutoHipsOffset(charId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "autoCalibrateAll":
|
||||
{
|
||||
string charId = json["characterId"]?.ToString();
|
||||
@ -233,15 +226,6 @@ namespace KindRetargeting.Remote
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
// 힙 위치 보정 (로컬)
|
||||
{ "hipsVertical", GetPrivateField<float>(script, "hipsOffsetY") },
|
||||
{ "hipsForward", GetPrivateField<float>(script, "hipsOffsetZ") },
|
||||
{ "hipsHorizontal", GetPrivateField<float>(script, "hipsOffsetX") },
|
||||
|
||||
// 무릎 위치 조정
|
||||
{ "kneeFrontBackWeight", GetPrivateField<float>(script, "kneeFrontBackWeight") },
|
||||
{ "kneeInOutWeight", GetPrivateField<float>(script, "kneeInOutWeight") },
|
||||
|
||||
// 발 IK 위치 조정
|
||||
{ "feetForwardBackward", GetPrivateField<float>(script, "footFrontBackOffset") },
|
||||
{ "feetNarrow", GetPrivateField<float>(script, "footInOutOffset") },
|
||||
@ -316,25 +300,6 @@ namespace KindRetargeting.Remote
|
||||
|
||||
switch (property)
|
||||
{
|
||||
// 힙 위치 보정
|
||||
case "hipsVertical":
|
||||
SetPrivateField(script, "hipsOffsetY", value);
|
||||
break;
|
||||
case "hipsForward":
|
||||
SetPrivateField(script, "hipsOffsetZ", value);
|
||||
break;
|
||||
case "hipsHorizontal":
|
||||
SetPrivateField(script, "hipsOffsetX", value);
|
||||
break;
|
||||
|
||||
// 무릎 위치 조정
|
||||
case "kneeFrontBackWeight":
|
||||
SetPrivateField(script, "kneeFrontBackWeight", value);
|
||||
break;
|
||||
case "kneeInOutWeight":
|
||||
SetPrivateField(script, "kneeInOutWeight", value);
|
||||
break;
|
||||
|
||||
// 발 IK 위치 조정
|
||||
case "feetForwardBackward":
|
||||
SetPrivateField(script, "footFrontBackOffset", value);
|
||||
@ -570,19 +535,6 @@ namespace KindRetargeting.Remote
|
||||
SendStatus(true, "정면 캘리브레이션 완료");
|
||||
}
|
||||
|
||||
private void AutoHipsOffset(string characterId)
|
||||
{
|
||||
var script = FindCharacter(characterId);
|
||||
if (script == null) return;
|
||||
|
||||
float offset = CalculateHipsOffsetFromLegDifference(script);
|
||||
SetPrivateField(script, "hipsOffsetY", offset);
|
||||
script.SaveSettings();
|
||||
|
||||
SendCharacterData(characterId);
|
||||
SendStatus(true, $"다리 길이 자동 보정 완료: hipsOffsetY={offset:F4}");
|
||||
}
|
||||
|
||||
private void AutoCalibrateAll(string characterId)
|
||||
{
|
||||
var script = FindCharacter(characterId);
|
||||
@ -596,10 +548,9 @@ namespace KindRetargeting.Remote
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: 크기 초기화 + 힙 오프셋 계산
|
||||
// Step 1: 크기 초기화 (힙 높이는 매 프레임 다리 길이 자동 보정이 처리)
|
||||
script.ResetScale();
|
||||
SetPrivateField(script, "avatarScale", 1f);
|
||||
SetPrivateField(script, "hipsOffsetY", CalculateHipsOffsetFromLegDifference(script));
|
||||
|
||||
// Step 2: 1프레임 후 목 높이 비율로 크기 조정
|
||||
StartCoroutine(AutoCalibrateCoroutine(script, characterId));
|
||||
@ -626,8 +577,7 @@ namespace KindRetargeting.Remote
|
||||
|
||||
yield return null; // 1프레임 대기
|
||||
|
||||
// Step 3: 힙 오프셋 재계산 + 머리 정면 캘리브레이션
|
||||
SetPrivateField(script, "hipsOffsetY", CalculateHipsOffsetFromLegDifference(script));
|
||||
// Step 3: 머리 정면 캘리브레이션 (힙 높이는 매 프레임 다리 길이 자동 보정이 처리)
|
||||
script.CalibrateHeadToForward();
|
||||
script.SaveSettings();
|
||||
|
||||
@ -635,37 +585,6 @@ namespace KindRetargeting.Remote
|
||||
SendStatus(true, $"전체 자동 보정 완료: avatarScale={scaleRatio:F3}");
|
||||
}
|
||||
|
||||
private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script)
|
||||
{
|
||||
var source = script.optitrackSource;
|
||||
Animator targetAnim = script.targetAnimator;
|
||||
if (source == null || targetAnim == null) return 0f;
|
||||
|
||||
float sourceLeg = GetSourceLegLength(source);
|
||||
float targetLeg = GetLegLength(targetAnim);
|
||||
if (sourceLeg < 0.01f || targetLeg < 0.01f) return 0f;
|
||||
|
||||
return targetLeg - sourceLeg;
|
||||
}
|
||||
|
||||
private float GetSourceLegLength(OptitrackSkeletonAnimator_Mingle source)
|
||||
{
|
||||
Transform upper = source.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
|
||||
Transform lower = source.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
|
||||
Transform foot = source.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 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 CustomRetargetingScript FindCharacter(string characterId)
|
||||
{
|
||||
foreach (var script in registeredCharacters)
|
||||
|
||||
@ -2,8 +2,8 @@ using UnityEngine;
|
||||
using UnityEditor;
|
||||
using UnityEngine.UIElements;
|
||||
using UnityEditor.UIElements;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
[CustomEditor(typeof(StreamDeckServerManager))]
|
||||
public class StreamDeckServerManagerEditor : Editor
|
||||
@ -12,12 +12,15 @@ public class StreamDeckServerManagerEditor : Editor
|
||||
private const string UssPath = "Assets/Scripts/Streamdeck/Editor/UXML/StreamDeckServerManagerEditor.uss";
|
||||
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||
|
||||
private const string AutoOption = "자동 (auto)";
|
||||
|
||||
private StreamDeckServerManager manager;
|
||||
private VisualElement playStatusContainer;
|
||||
private Label playStatusLabel;
|
||||
private Label lanIPLabel;
|
||||
private Label dashboardUrlLabel;
|
||||
private VisualElement dashboardPortField;
|
||||
private VisualElement networkAdapterField;
|
||||
|
||||
public override VisualElement CreateInspectorGUI()
|
||||
{
|
||||
@ -41,23 +44,24 @@ public class StreamDeckServerManagerEditor : Editor
|
||||
lanIPLabel = root.Q<Label>("lanIPLabel");
|
||||
dashboardUrlLabel = root.Q<Label>("dashboardUrlLabel");
|
||||
dashboardPortField = root.Q("dashboardPortField");
|
||||
networkAdapterField = root.Q("networkAdapterField");
|
||||
|
||||
// Open Dashboard button (LAN IP 우선)
|
||||
// 네트워크 어댑터 드롭다운
|
||||
if (networkAdapterField != null)
|
||||
BuildAdapterDropdown(networkAdapterField);
|
||||
|
||||
// Open Dashboard button (선택 어댑터 IP 우선)
|
||||
var openBtn = root.Q<Button>("openDashboardBtn");
|
||||
if (openBtn != null)
|
||||
openBtn.clicked += () =>
|
||||
{
|
||||
string ip = GetLocalIPAddress();
|
||||
string ip = ResolveHostAddress();
|
||||
string host = !string.IsNullOrEmpty(ip) ? ip : "localhost";
|
||||
Application.OpenURL($"http://{host}:{manager.dashboardPort}");
|
||||
};
|
||||
|
||||
// LAN IP detection
|
||||
string lanIP = GetLocalIPAddress();
|
||||
if (lanIPLabel != null)
|
||||
lanIPLabel.text = !string.IsNullOrEmpty(lanIP) ? $"LAN IP: {lanIP}" : "LAN IP: not available";
|
||||
|
||||
UpdateDashboardUrl(lanIP);
|
||||
// LAN IP / URL 표시
|
||||
UpdateLanLabels(ResolveHostAddress());
|
||||
|
||||
// Track enableDashboard for conditional visibility
|
||||
var enableProp = serializedObject.FindProperty("enableDashboard");
|
||||
@ -67,8 +71,8 @@ public class StreamDeckServerManagerEditor : Editor
|
||||
// Track port changes to update URL display
|
||||
var dashboardPortProp = serializedObject.FindProperty("dashboardPort");
|
||||
var wsPortProp = serializedObject.FindProperty("port");
|
||||
root.TrackPropertyValue(dashboardPortProp, _ => UpdateDashboardUrl(lanIP));
|
||||
root.TrackPropertyValue(wsPortProp, _ => UpdateDashboardUrl(lanIP));
|
||||
root.TrackPropertyValue(dashboardPortProp, _ => UpdateLanLabels(ResolveHostAddress()));
|
||||
root.TrackPropertyValue(wsPortProp, _ => UpdateLanLabels(ResolveHostAddress()));
|
||||
|
||||
// Play mode polling
|
||||
root.schedule.Execute(UpdatePlayModeState).Every(500);
|
||||
@ -76,15 +80,81 @@ public class StreamDeckServerManagerEditor : Editor
|
||||
return root;
|
||||
}
|
||||
|
||||
private void UpdateDashboardUrl(string lanIP)
|
||||
private void BuildAdapterDropdown(VisualElement container)
|
||||
{
|
||||
if (dashboardUrlLabel == null || manager == null) return;
|
||||
container.Clear();
|
||||
|
||||
string local = $"http://localhost:{manager.dashboardPort}";
|
||||
if (!string.IsNullOrEmpty(lanIP))
|
||||
dashboardUrlLabel.text = $"{local} | http://{lanIP}:{manager.dashboardPort}";
|
||||
else
|
||||
dashboardUrlLabel.text = local;
|
||||
var adapters = NetworkInterfaceUtil.GetAdapters();
|
||||
string current = serializedObject.FindProperty("preferredHostAddress").stringValue;
|
||||
|
||||
var choices = new List<string> { AutoOption };
|
||||
var labelToIp = new Dictionary<string, string> { { AutoOption, "" } };
|
||||
foreach (var a in adapters)
|
||||
{
|
||||
string label = $"{a.name} — {a.ip}";
|
||||
if (!labelToIp.ContainsKey(label))
|
||||
{
|
||||
choices.Add(label);
|
||||
labelToIp[label] = a.ip;
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 선택값 라벨 결정 (저장된 IP가 목록에 없으면 별도 항목으로 표시)
|
||||
string currentLabel = AutoOption;
|
||||
if (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
var match = adapters.FirstOrDefault(a => a.ip == current);
|
||||
if (!string.IsNullOrEmpty(match.ip))
|
||||
{
|
||||
currentLabel = $"{match.name} — {match.ip}";
|
||||
}
|
||||
else
|
||||
{
|
||||
currentLabel = $"{current} (현재 없음)";
|
||||
choices.Add(currentLabel);
|
||||
labelToIp[currentLabel] = current;
|
||||
}
|
||||
}
|
||||
|
||||
var dropdown = new DropdownField("Network Adapter")
|
||||
{
|
||||
choices = choices,
|
||||
value = currentLabel,
|
||||
tooltip = "대시보드 주소로 사용할 네트워크 어댑터.\n'자동'은 기본 게이트웨이가 있는 어댑터를 우선 선택합니다.\n서버 자체는 항상 모든 인터페이스+localhost에서 접속 가능합니다."
|
||||
};
|
||||
dropdown.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
string ip = labelToIp.TryGetValue(evt.newValue, out var v) ? v : "";
|
||||
serializedObject.Update();
|
||||
serializedObject.FindProperty("preferredHostAddress").stringValue = ip;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
UpdateLanLabels(NetworkInterfaceUtil.ResolveHostAddress(ip));
|
||||
});
|
||||
container.Add(dropdown);
|
||||
|
||||
var refreshBtn = new Button(() => BuildAdapterDropdown(container)) { text = "어댑터 목록 새로고침" };
|
||||
refreshBtn.style.marginTop = 2;
|
||||
container.Add(refreshBtn);
|
||||
}
|
||||
|
||||
private string ResolveHostAddress()
|
||||
{
|
||||
if (manager == null) return "";
|
||||
return NetworkInterfaceUtil.ResolveHostAddress(manager.preferredHostAddress);
|
||||
}
|
||||
|
||||
private void UpdateLanLabels(string lanIP)
|
||||
{
|
||||
if (lanIPLabel != null)
|
||||
lanIPLabel.text = !string.IsNullOrEmpty(lanIP) ? $"LAN IP: {lanIP}" : "LAN IP: not available";
|
||||
|
||||
if (dashboardUrlLabel != null && manager != null)
|
||||
{
|
||||
string local = $"http://localhost:{manager.dashboardPort}";
|
||||
dashboardUrlLabel.text = !string.IsNullOrEmpty(lanIP)
|
||||
? $"{local} | http://{lanIP}:{manager.dashboardPort}"
|
||||
: local;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDashboardPortVisibility(bool enabled)
|
||||
@ -112,19 +182,4 @@ public class StreamDeckServerManagerEditor : Editor
|
||||
playStatusContainer.RemoveFromClassList("sdm-play-status--visible");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetLocalIPAddress()
|
||||
{
|
||||
try
|
||||
{
|
||||
var host = Dns.GetHostEntry(Dns.GetHostName());
|
||||
foreach (var ip in host.AddressList)
|
||||
{
|
||||
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
||||
return ip.ToString();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
<ui:VisualElement class="section">
|
||||
<ui:Foldout text="WebSocket Server" value="true" class="section-foldout">
|
||||
<uie:PropertyField binding-path="port" label="WebSocket Port"/>
|
||||
<ui:VisualElement name="networkAdapterField"/>
|
||||
</ui:Foldout>
|
||||
</ui:VisualElement>
|
||||
|
||||
|
||||
74
Assets/Scripts/Streamdeck/NetworkInterfaceUtil.cs
Normal file
74
Assets/Scripts/Streamdeck/NetworkInterfaceUtil.cs
Normal file
@ -0,0 +1,74 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
/// <summary>
|
||||
/// 활성 네트워크 어댑터의 IPv4 주소를 열거하고, 선호 주소를 해석하는 유틸리티.
|
||||
/// PC에 NIC이 여러 개(유선/무선/Hyper-V/VPN 등)일 때 서버가 사용/표시할 IP를
|
||||
/// 사용자가 명시적으로 고를 수 있도록 돕는다.
|
||||
/// </summary>
|
||||
public static class NetworkInterfaceUtil
|
||||
{
|
||||
public struct Adapter
|
||||
{
|
||||
public string name; // 어댑터 이름 (예: "이더넷", "Wi-Fi")
|
||||
public string ip; // IPv4 주소 문자열
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 활성(Up) IPv4 어댑터 목록을 반환한다. 루프백/APIPA(169.254.x.x)는 제외.
|
||||
/// 기본 게이트웨이가 있는 어댑터(실제 LAN/인터넷 연결)를 앞쪽에 정렬하여
|
||||
/// Hyper-V/VPN 같은 가상 어댑터보다 우선 노출한다.
|
||||
/// </summary>
|
||||
public static List<Adapter> GetAdapters()
|
||||
{
|
||||
var withGateway = new List<Adapter>();
|
||||
var withoutGateway = new List<Adapter>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||
|
||||
var props = ni.GetIPProperties();
|
||||
bool hasGateway = props.GatewayAddresses.Any(g =>
|
||||
g.Address != null &&
|
||||
g.Address.AddressFamily == AddressFamily.InterNetwork &&
|
||||
!g.Address.Equals(IPAddress.Any));
|
||||
|
||||
foreach (var addr in props.UnicastAddresses)
|
||||
{
|
||||
if (addr.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
||||
if (IPAddress.IsLoopback(addr.Address)) continue;
|
||||
|
||||
byte[] b = addr.Address.GetAddressBytes();
|
||||
if (b[0] == 169 && b[1] == 254) continue; // APIPA(자동 사설 IP) 제외
|
||||
|
||||
var adapter = new Adapter { name = ni.Name, ip = addr.Address.ToString() };
|
||||
(hasGateway ? withGateway : withoutGateway).Add(adapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
withGateway.AddRange(withoutGateway);
|
||||
return withGateway;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 선호 주소(preferred)가 현재 유효한 어댑터 IP면 그대로 사용하고,
|
||||
/// 비었거나 더 이상 존재하지 않으면 자동 선택(첫 번째 = 게이트웨이 우선)한다.
|
||||
/// 사용 가능한 IPv4가 전혀 없으면 빈 문자열.
|
||||
/// </summary>
|
||||
public static string ResolveHostAddress(string preferred)
|
||||
{
|
||||
var adapters = GetAdapters();
|
||||
if (!string.IsNullOrEmpty(preferred) && adapters.Any(a => a.ip == preferred))
|
||||
return preferred;
|
||||
return adapters.Count > 0 ? adapters[0].ip : "";
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Streamdeck/NetworkInterfaceUtil.cs.meta
Normal file
2
Assets/Scripts/Streamdeck/NetworkInterfaceUtil.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9b31a210682a1d244954a689a136c9a5
|
||||
@ -13,6 +13,10 @@ public class StreamDeckServerManager : MonoBehaviour
|
||||
[Header("WebSocket 서버 설정")]
|
||||
public int port = 64211;
|
||||
|
||||
[Tooltip("대시보드 주소로 사용할 네트워크 어댑터 IP. 비우면 자동 선택. (Inspector 드롭다운에서 선택)\n" +
|
||||
"서버 자체는 항상 모든 인터페이스+localhost에서 접속 가능하며, 이 값은 표시·대시보드 바인딩에 사용됩니다.")]
|
||||
public string preferredHostAddress = "";
|
||||
|
||||
[Header("대시보드 설정")]
|
||||
public int dashboardPort = 64210;
|
||||
public bool enableDashboard = true;
|
||||
@ -193,7 +197,7 @@ public class StreamDeckServerManager : MonoBehaviour
|
||||
if (!enableDashboard) return;
|
||||
|
||||
int retargetingWsPort = systemController?.retargetingRemote?.retargetingWsPort ?? 0;
|
||||
dashboardServer = new StreamingleDashboardServer(dashboardPort, port, retargetingWsPort);
|
||||
dashboardServer = new StreamingleDashboardServer(dashboardPort, port, retargetingWsPort, preferredHostAddress);
|
||||
dashboardServer.Start();
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ public class StreamingleDashboardServer
|
||||
private int httpPort;
|
||||
private int wsPort;
|
||||
private int retargetingWsPort;
|
||||
private string preferredHostAddress;
|
||||
|
||||
private string cachedCSS = "";
|
||||
private string cachedTemplate = "";
|
||||
@ -29,11 +30,12 @@ public class StreamingleDashboardServer
|
||||
public bool IsRunning => isRunning;
|
||||
public IReadOnlyList<string> BoundAddresses => boundAddresses;
|
||||
|
||||
public StreamingleDashboardServer(int httpPort, int wsPort, int retargetingWsPort = 0)
|
||||
public StreamingleDashboardServer(int httpPort, int wsPort, int retargetingWsPort = 0, string preferredHostAddress = "")
|
||||
{
|
||||
this.httpPort = httpPort;
|
||||
this.wsPort = wsPort;
|
||||
this.retargetingWsPort = retargetingWsPort;
|
||||
this.preferredHostAddress = preferredHostAddress;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@ -56,7 +58,7 @@ public class StreamingleDashboardServer
|
||||
wildcardBound = true;
|
||||
|
||||
boundAddresses.Add($"http://localhost:{httpPort}");
|
||||
string localIP = GetLocalIPAddress();
|
||||
string localIP = NetworkInterfaceUtil.ResolveHostAddress(preferredHostAddress);
|
||||
if (!string.IsNullOrEmpty(localIP))
|
||||
{
|
||||
boundAddresses.Add($"http://{localIP}:{httpPort}");
|
||||
@ -69,8 +71,8 @@ public class StreamingleDashboardServer
|
||||
try { listener.Close(); } catch { }
|
||||
listener = new HttpListener();
|
||||
|
||||
// LAN IP 직접 바인딩 시도
|
||||
string localIP = GetLocalIPAddress();
|
||||
// LAN IP 직접 바인딩 시도 (사용자가 선택한 어댑터 우선)
|
||||
string localIP = NetworkInterfaceUtil.ResolveHostAddress(preferredHostAddress);
|
||||
bool lanBound = false;
|
||||
|
||||
listener.Prefixes.Add($"http://localhost:{httpPort}/");
|
||||
@ -243,23 +245,6 @@ public class StreamingleDashboardServer
|
||||
return html;
|
||||
}
|
||||
|
||||
private string GetLocalIPAddress()
|
||||
{
|
||||
try
|
||||
{
|
||||
var host = Dns.GetHostEntry(Dns.GetHostName());
|
||||
foreach (var ip in host.AddressList)
|
||||
{
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
return ip.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception) { }
|
||||
return "";
|
||||
}
|
||||
|
||||
private string GetFallbackCSS()
|
||||
{
|
||||
return @"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user