996 lines
48 KiB
C#
996 lines
48 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// SlashArcMeasure.cs — 이펙트 프리팹의 "호(크레센트)" · "찌르기 축" 기하 실측
|
||
//
|
||
// PD 지시 #792 (2026-09-07 01:00 / 01:0x):
|
||
// ① "무기 궤적을 그대로 따라가야 한다"가 아니라 **무기를 휘두르는 방향에 맞게
|
||
// 이펙트의 각도·위치·크기를 무기에 딱 맞게 배치**해서 자연스럽게 보이게 할 것.
|
||
// ② 휘두르기 = NamuFX `Slash_B_recolor_1` · 찌르기 = EricWang `FX_Blue Stab`.
|
||
//
|
||
// ── 왜 새 실측 코드가 필요한가 (기존 WeaponTrailDriver.Measure 의 결함) ──────
|
||
// 기존 Measure() 는 이미터 메시의 **바운딩 박스**만 보고
|
||
// 법선 = 최단축 · "호가 뻗는 방향" = 최장축
|
||
// 으로 프레임을 잡은 뒤, 그 최장축을 스윙의 `outward`(= 칼끝 − 손잡이 = 반경 방향)에
|
||
// 맞췄다. 그런데 크레센트(원호 리본)의 바운딩 박스 최장축은 **현(chord) 방향**,
|
||
// 즉 호의 시작→끝을 잇는 접선 방향이지 반경 방향이 아니다.
|
||
// · 반각 θ 인 호의 현 길이 = 2·r·sinθ, 반경 방향 폭 = r_o − r_i·cosθ
|
||
// θ=60° → 1.73r vs 0.77r · θ=90° → 2.00r vs 1.00r · θ=135° → 2.00r vs 1.71r
|
||
// 어느 경우에도 현이 길다. 즉 최장축은 항상 현이다.
|
||
// → 기존 방식은 호를 스윙 평면 **안에서 90° 돌려** 놓는다. 이것이 PD 가 지적한
|
||
// "무기를 휘두르는 방향에 맞지 않는다"의 기하학적 원인이다.
|
||
//
|
||
// ── 이 파일이 대신 재는 것 ─────────────────────────────────────────────────
|
||
// 메시 정점을 직접 읽어 **호를 원(circle)으로 피팅**한다:
|
||
// · 평면 법선 n — 삼각형 법선의 면적 가중 합(부호 정렬). 뒷면이 상쇄되지 않는다.
|
||
// · 원 중심 c — 평면에 투영한 뒤 Kåsa 대수 원피팅(선형 최소자승)
|
||
// · 반경 r_i/r_o — |p−c| 의 최소/최대 (리본의 안/바깥 가장자리)
|
||
// · 스윕 구간 — 각도 정렬 후 **최대 공백(gap)** 을 찾아 그 바깥이 호 구간
|
||
// · 시작/끝 방향 — UV 의 u 축 오름차순을 호 진행 방향으로 본다(플래그로 반전 가능)
|
||
// 이렇게 재면 호를 스윙에 맞추는 일이 "프레임 대 프레임" 문제로 환원된다:
|
||
// Q · n_mesh = n_swing (부호까지 · 스윙 진행 방향 일치)
|
||
// Q · bisector_mesh = bisector_swing (볼록 방향 = 스윙 바깥)
|
||
// s = r_swingTip / r_o (균등 스케일)
|
||
// pos = c_swing − Q·(s · c_mesh) (호의 원 중심 = 스윙 피벗)
|
||
//
|
||
// 🔴 런타임에서 메시 정점을 읽지 않는다.
|
||
// 임포트된 FBX 는 Read/Write 가 꺼져 있는 것이 기본이라 **빌드된 플레이어에서
|
||
// Mesh.vertices 가 실패**한다(에디터에서만 우연히 통한다). 그래서 이 클래스의
|
||
// 정점 기반 경로는 **에디터 베이크 전용**이고, 결과 CrescentFrame 은
|
||
// SlashTrailSettings 에 데이터로 구워 둔다(C45 — 값의 SOT 는 SO 하나).
|
||
// 런타임은 구워진 프레임만 읽으며, 프레임이 없으면 바운딩 박스 폴백으로
|
||
// 조용히 떨어진다(C8 롤백 경로 — 게임이 멈추지 않는다).
|
||
//
|
||
// 🔴 어셈블리 주의: 힘민지 Assets/Script/ 에는 .asmdef 가 없다(전부 Assembly-CSharp).
|
||
// Assets/WL/Combat/ 에도 .asmdef 를 만들지 말 것.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
|
||
namespace WL.Combat
|
||
{
|
||
/// <summary>
|
||
/// 이펙트 프리팹 안에서 실측한 "호" 1개의 기하.
|
||
/// 모든 값은 **프리팹 루트 로컬 · 루트 스케일 1 · 루트 회전 항등** 기준이다
|
||
/// (측정 시 루트를 원점·항등·스케일1 로 잠깐 옮겨 놓고 잰다 — 아래 MeasureInstance 참고).
|
||
/// </summary>
|
||
[System.Serializable]
|
||
public struct CrescentFrame
|
||
{
|
||
[Tooltip("유효한 실측인가. false 면 호출부는 바운딩 박스 폴백을 쓴다")]
|
||
public bool valid;
|
||
|
||
[Tooltip("실측에 쓴 이미터 GameObject 이름(추적성)")]
|
||
public string emitter;
|
||
|
||
[Tooltip("호가 놓인 평면의 법선. 호의 시작→끝이 이 축에서 볼 때 반시계(CCW)가 되도록 부호를 잡는다")]
|
||
public Vector3 normal;
|
||
|
||
[Tooltip("호가 그리는 원의 중심(루트 로컬). 스윙 피벗(손잡이 궤적 중심)에 맞춘다")]
|
||
public Vector3 center;
|
||
|
||
[Tooltip("원 중심 → 호 중앙(볼록 방향, 단위 벡터). 스윙 바깥(칼끝 방향)에 맞춘다")]
|
||
public Vector3 bisector;
|
||
|
||
[Tooltip("원 중심 → 호 시작(단위 벡터). 검증용 — 호 방향 오차를 재는 데 쓴다")]
|
||
public Vector3 startDir;
|
||
|
||
[Tooltip("호 바깥 가장자리 반지름(m · 루트 스케일 1)")]
|
||
public float radiusOuter;
|
||
|
||
[Tooltip("호 안쪽 가장자리 반지름(m · 루트 스케일 1)")]
|
||
public float radiusInner;
|
||
|
||
[Tooltip("호가 덮는 각도(도)")]
|
||
public float sweepDeg;
|
||
|
||
public static CrescentFrame Invalid { get { return new CrescentFrame { valid = false, emitter = "(none)" }; } }
|
||
|
||
/// <summary>메시 프레임 → 목표 프레임 변환 회전. (법선, 볼록 방향) 두 축을 동시에 맞춘다.</summary>
|
||
public Quaternion FrameRotation
|
||
{
|
||
get { return Quaternion.LookRotation(normal, bisector); }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 이펙트 프리팹 안에서 실측한 "직선(찌르기) 축" 기하.
|
||
/// 좌표계 규약은 <see cref="CrescentFrame"/> 과 같다.
|
||
/// </summary>
|
||
[System.Serializable]
|
||
public struct StabFrame
|
||
{
|
||
public bool valid;
|
||
|
||
[Tooltip("실측 근거 메모(추적성)")]
|
||
public string note;
|
||
|
||
[Tooltip("찌르기 진행 방향(단위 벡터 · 꼬리 → 칼끝)")]
|
||
public Vector3 axis;
|
||
|
||
[Tooltip("부착 기준점 = 프리팹 루트를 축에 투영한 지점(루트 로컬). 여기를 칼끝 출발점에 맞춘다")]
|
||
public Vector3 start;
|
||
|
||
[Tooltip("기준점에서 앞끝까지의 도달 거리(m · 루트 스케일 1). 뒤로 흐르는 꼬리는 길이에 넣지 않는다")]
|
||
public float length;
|
||
|
||
[Tooltip("축에 수직인 방향의 시각 두께(m). 롤(축 회전)을 맞출 때 참고")]
|
||
public float thickness;
|
||
|
||
[Tooltip("이펙트가 놓인 납작한 평면의 법선(루트 로컬). 2D 스프라이트 계열의 '앞면'이다")]
|
||
public Vector3 planeNormal;
|
||
|
||
public static StabFrame Invalid { get { return new StabFrame { valid = false, note = "(none)" }; } }
|
||
}
|
||
|
||
/// <summary>
|
||
/// 이펙트 프리팹의 호·직선 기하를 실측한다.
|
||
///
|
||
/// 사용 시점:
|
||
/// · <b>에디터 베이크</b> — 정점 기반 정밀 실측(<see cref="MeasureCrescent"/>).
|
||
/// 결과를 SlashTrailSettings 에 구워 둔다.
|
||
/// · <b>런타임</b> — 구워진 프레임이 없을 때만 바운딩 박스 폴백
|
||
/// (<see cref="MeasureCrescentFallback"/>). 정점을 읽지 않는다.
|
||
/// </summary>
|
||
public static class SlashArcMeasure
|
||
{
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 공통 — 인스턴스를 원점·항등·스케일1 로 잠깐 놓고 재기
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 인스턴스의 루트를 원점·항등 회전·스케일 1 로 옮긴 뒤 <paramref name="body"/> 를 실행하고
|
||
/// 원래 자세로 되돌린다. 이렇게 하면 자식의 world 좌표가 곧 "루트 로컬 · 스케일 1" 좌표가 된다.
|
||
///
|
||
/// Unity 의 트랜스폼 계층은 즉시 갱신되므로(지연 없음) 비활성 오브젝트에서도 정확하다.
|
||
/// InGameInfo.Show_EffectEx 는 actSpawned 를 SetActive **직전에** 부르므로,
|
||
/// 이 함수를 그 콜백 안에서 호출해도 파티클이 한 프레임도 잘못된 자리에 뜨지 않는다.
|
||
/// </summary>
|
||
public static void InNeutralPose(Transform root, System.Action body)
|
||
{
|
||
if (root == null || body == null) return;
|
||
|
||
Vector3 p = root.position;
|
||
Quaternion r = root.rotation;
|
||
Vector3 s = root.localScale;
|
||
try
|
||
{
|
||
root.position = Vector3.zero;
|
||
root.rotation = Quaternion.identity;
|
||
root.localScale = Vector3.one;
|
||
body();
|
||
}
|
||
finally
|
||
{
|
||
root.position = p;
|
||
root.rotation = r;
|
||
root.localScale = s;
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 호(크레센트) 실측 — 정점 기반 (에디터 베이크 전용)
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 프리팹 인스턴스에서 호 이미터를 골라 원 피팅으로 프레임을 실측한다.
|
||
///
|
||
/// <paramref name="preferredEmitters"/> 에 이름이 있으면 1순위로 쓴다(C45 — 어느 이미터가
|
||
/// 호 본체인지는 데이터다). 없으면 "카메라 정렬이 아닌 Mesh 렌더 이미터 중 가장 큰 것"을 고른다.
|
||
///
|
||
/// 🔴 메시 정점을 읽으므로 **에디터에서만** 부른다. isReadable 이 꺼진 FBX 는
|
||
/// 빌드된 플레이어에서 실패한다. 실패하면 Invalid 를 돌려준다(예외를 던지지 않는다).
|
||
/// </summary>
|
||
public static CrescentFrame MeasureCrescent(GameObject instance, string[] preferredEmitters, bool flipSweep)
|
||
{
|
||
if (instance == null) return CrescentFrame.Invalid;
|
||
|
||
var result = CrescentFrame.Invalid;
|
||
InNeutralPose(instance.transform, () =>
|
||
{
|
||
ParticleSystem best;
|
||
float bestSize;
|
||
if (!PickCrescentEmitter(instance, preferredEmitters, out best, out bestSize)) return;
|
||
|
||
var psr = best.GetComponent<ParticleSystemRenderer>();
|
||
var mesh = psr != null ? psr.mesh : null;
|
||
if (mesh == null) return;
|
||
|
||
Vector3[] verts;
|
||
int[] tris;
|
||
Vector2[] uvs;
|
||
if (!TryReadMesh(mesh, out verts, out tris, out uvs)) return;
|
||
|
||
// 파티클 메시는 startSize 로 스케일된 뒤 이미터 트랜스폼에 놓인다.
|
||
// (RenderAlignment=Local · SimulationSpace=Local · 방출 위치 = 이미터 원점 가정)
|
||
var tm = best.transform;
|
||
var pts = new Vector3[verts.Length];
|
||
for (int i = 0; i < verts.Length; i++)
|
||
pts[i] = tm.TransformPoint(verts[i] * bestSize);
|
||
|
||
result = FitCrescent(pts, tris, uvs, flipSweep);
|
||
result.emitter = best.gameObject.name;
|
||
});
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// <b>시간 순서대로 들어온</b> 점들을 원호로 피팅한다 — 스윙 궤적(칼끝 샘플)용.
|
||
///
|
||
/// 정점 피팅(<see cref="FitCrescent"/>)과 달리 점의 순서가 곧 호의 진행 방향이므로
|
||
/// UV·테이퍼 같은 간접 단서가 필요 없다. 법선의 부호는 pts[0]→pts[count-1] 이
|
||
/// +normal 축에서 볼 때 반시계가 되도록 잡는다(= 스윙 진행 방향을 법선이 담는다).
|
||
/// </summary>
|
||
public static CrescentFrame FitArcOrdered(Vector3[] pts, int count)
|
||
{
|
||
var bad = CrescentFrame.Invalid;
|
||
if (pts == null || count < 3) return bad;
|
||
|
||
// ① 중심(무게) · Newell 법선
|
||
Vector3 origin = Vector3.zero;
|
||
for (int i = 0; i < count; i++) origin += pts[i];
|
||
origin /= count;
|
||
|
||
Vector3 n = Vector3.zero;
|
||
for (int i = 0; i + 1 < count; i++)
|
||
n += Vector3.Cross(pts[i] - origin, pts[i + 1] - origin);
|
||
if (n.sqrMagnitude < 1e-12f)
|
||
{
|
||
// 거의 직선이다 — 평면을 못 정한다. 주성분 두 개의 외적으로 근사한다.
|
||
Vector3 a1 = PrincipalAxis(pts, count, origin);
|
||
Vector3 a2 = SecondaryAxis(pts, count, origin, a1);
|
||
n = Vector3.Cross(a1, a2);
|
||
if (n.sqrMagnitude < 1e-12f) return bad;
|
||
}
|
||
n.Normalize();
|
||
|
||
// ② 평면 기저 · 2D 투영
|
||
Vector3 u = Vector3.Cross(n, Mathf.Abs(n.y) < 0.9f ? Vector3.up : Vector3.right);
|
||
if (u.sqrMagnitude < 1e-12f) return bad;
|
||
u.Normalize();
|
||
Vector3 v = Vector3.Cross(n, u);
|
||
|
||
var xs = new float[count];
|
||
var ys = new float[count];
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
Vector3 d = pts[i] - origin;
|
||
xs[i] = Vector3.Dot(d, u);
|
||
ys[i] = Vector3.Dot(d, v);
|
||
}
|
||
|
||
// ③ 원 피팅
|
||
float cx, cy;
|
||
if (!FitCircleKasa(xs, ys, out cx, out cy)) return bad;
|
||
Vector3 center = origin + u * cx + v * cy;
|
||
|
||
// ④ 반지름 · 시작/끝 방향 · 스윕(부호 있음)
|
||
float rSum = 0f, rMin = float.MaxValue, rMax = 0f;
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
float r = Vector3.Distance(pts[i], center);
|
||
rSum += r;
|
||
if (r < rMin) rMin = r;
|
||
if (r > rMax) rMax = r;
|
||
}
|
||
float rMean = rSum / count;
|
||
if (rMean < 1e-4f) return bad;
|
||
|
||
Vector3 s0 = pts[0] - center;
|
||
Vector3 s1 = pts[count - 1] - center;
|
||
s0 -= n * Vector3.Dot(s0, n);
|
||
s1 -= n * Vector3.Dot(s1, n);
|
||
if (s0.sqrMagnitude < 1e-10f || s1.sqrMagnitude < 1e-10f) return bad;
|
||
s0.Normalize(); s1.Normalize();
|
||
|
||
float signed = Vector3.SignedAngle(s0, s1, n); // (-180, 180]
|
||
// 스윙이 180° 를 넘으면 부호가 뒤집혀 나온다. 누적 각으로 총량을 다시 잰다.
|
||
float accum = 0f;
|
||
Vector3 prev = s0;
|
||
for (int i = 1; i < count; i++)
|
||
{
|
||
Vector3 d = pts[i] - center;
|
||
d -= n * Vector3.Dot(d, n);
|
||
if (d.sqrMagnitude < 1e-10f) continue;
|
||
d.Normalize();
|
||
accum += Vector3.SignedAngle(prev, d, n);
|
||
prev = d;
|
||
}
|
||
if (Mathf.Abs(accum) > 1e-3f) signed = accum;
|
||
|
||
// 법선은 "진행 방향이 CCW" 가 되도록 잡는다.
|
||
if (signed < 0f) { n = -n; signed = -signed; }
|
||
if (signed < 1e-3f) return bad;
|
||
|
||
Vector3 bisector = (Quaternion.AngleAxis(signed * 0.5f, n) * s0).normalized;
|
||
|
||
return new CrescentFrame
|
||
{
|
||
valid = true,
|
||
emitter = "(swing)",
|
||
normal = n,
|
||
center = center,
|
||
bisector = bisector,
|
||
startDir = s0,
|
||
radiusOuter = rMax,
|
||
radiusInner = rMin,
|
||
sweepDeg = signed
|
||
};
|
||
}
|
||
|
||
/// <summary>점들의 무게중심을 평면(법선 normal · 기준점 onPlane)에 투영해 돌려준다.</summary>
|
||
public static Vector3 CentroidOnPlane(Vector3[] pts, int count, Vector3 normal, Vector3 onPlane)
|
||
{
|
||
if (pts == null || count <= 0) return onPlane;
|
||
Vector3 c = Vector3.zero;
|
||
for (int i = 0; i < count; i++) c += pts[i];
|
||
c /= count;
|
||
return c - normal * Vector3.Dot(c - onPlane, normal);
|
||
}
|
||
|
||
/// <summary>중심에서 잰 반지름의 표준편차. 원 피팅이 실제로 원을 찾았는지 보는 척도.</summary>
|
||
public static float RadiusDeviation(Vector3[] pts, int count, Vector3 center, Vector3 normal, float mean)
|
||
{
|
||
if (pts == null || count <= 0) return 0f;
|
||
float s = 0f;
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
Vector3 d = pts[i] - center;
|
||
d -= normal * Vector3.Dot(d, normal);
|
||
float e = d.magnitude - mean;
|
||
s += e * e;
|
||
}
|
||
return Mathf.Sqrt(s / count);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 피벗을 바꾼 뒤 시작 방향 · 볼록 방향 · 스윕을 다시 잰다.
|
||
/// 진행 방향이 CCW 가 되도록 <paramref name="normal"/> 의 부호도 함께 맞춘다.
|
||
/// </summary>
|
||
public static bool RecomputeAboutPivot(Vector3[] pts, int count, Vector3 pivot, ref Vector3 normal,
|
||
out Vector3 startDir, out Vector3 bisector, out float sweepDeg)
|
||
{
|
||
startDir = Vector3.forward;
|
||
bisector = Vector3.forward;
|
||
sweepDeg = 0f;
|
||
if (pts == null || count < 2) return false;
|
||
|
||
Vector3 s0 = pts[0] - pivot;
|
||
s0 -= normal * Vector3.Dot(s0, normal);
|
||
if (s0.sqrMagnitude < 1e-10f) return false;
|
||
s0.Normalize();
|
||
|
||
float accum = 0f;
|
||
Vector3 prev = s0;
|
||
for (int i = 1; i < count; i++)
|
||
{
|
||
Vector3 d = pts[i] - pivot;
|
||
d -= normal * Vector3.Dot(d, normal);
|
||
if (d.sqrMagnitude < 1e-10f) continue;
|
||
d.Normalize();
|
||
accum += Vector3.SignedAngle(prev, d, normal);
|
||
prev = d;
|
||
}
|
||
if (accum < 0f) { normal = -normal; accum = -accum; }
|
||
if (accum < 1e-3f) return false;
|
||
|
||
startDir = s0;
|
||
sweepDeg = accum;
|
||
bisector = (Quaternion.AngleAxis(accum * 0.5f, normal) * s0).normalized;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>중심에서 잰 평균 반지름 — 손잡이 반경처럼 "이 원 기준으로 얼마인가"를 잴 때.</summary>
|
||
public static float MeanRadius(Vector3[] pts, int count, Vector3 center, Vector3 normal)
|
||
{
|
||
if (pts == null || count <= 0) return 0f;
|
||
float sum = 0f;
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
Vector3 d = pts[i] - center;
|
||
d -= normal * Vector3.Dot(d, normal);
|
||
sum += d.magnitude;
|
||
}
|
||
return sum / count;
|
||
}
|
||
|
||
/// <summary>
|
||
/// #816v — 칼끝 궤적 창에서 **감아올리기(한 바퀴 넘게 감기는 구간)** 를 잘라 낸다.
|
||
///
|
||
/// 속도 트림(<see cref="SlashTrailSettings.swingTrimSpeedRatio"/>)은 "칼이 멈춘 꼬리"만 자른다.
|
||
/// 1타처럼 클립이 타격 뒤에도 빠르게 계속 감기면 속도 트림은 그 구간을 남기고, 그러면 칼끝이
|
||
/// 한 바퀴를 넘겨 원 피팅이 깨진다(반경 편차비 0.27) → 호출부가 손잡이 중심 폴백으로 떨어져
|
||
/// 피벗이 머리 높이·몸 옆으로 올라간다.
|
||
///
|
||
/// 여기서는 창 끝을 한 프레임씩 앞으로 당기며 **편차비가 기준 이하가 되는 가장 긴 창**의
|
||
/// 길이를 돌려준다. 기준을 만족하는 창이 없으면 원래 길이를 그대로 돌려준다(동작 불변).
|
||
/// </summary>
|
||
/// <param name="tips">칼끝 궤적(창 시작부터 순서대로)</param>
|
||
/// <param name="count">현재 창 길이</param>
|
||
/// <param name="maxDevRatio">합격 기준 = 반경 편차 / 평균 반경</param>
|
||
/// <param name="minSamples">이보다 짧게는 자르지 않는다</param>
|
||
/// <returns>새 창 길이(≤ count)</returns>
|
||
public static int TrimWindupWrap(Vector3[] tips, int count, float maxDevRatio, int minSamples)
|
||
{
|
||
if (tips == null || count < 4) return count;
|
||
int floor = Mathf.Max(minSamples, 3);
|
||
if (count <= floor) return count;
|
||
float th = Mathf.Max(maxDevRatio, 0.01f);
|
||
|
||
for (int n = count; n >= floor; n--)
|
||
{
|
||
var fit = FitArcOrdered(tips, n);
|
||
if (!fit.valid) continue;
|
||
float r = MeanRadius(tips, n, fit.center, fit.normal);
|
||
if (r < 1e-3f) continue;
|
||
if (RadiusDeviation(tips, n, fit.center, fit.normal, r) / r <= th) return n;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
/// <summary>정점 집합을 원호로 피팅한다. 실패하면 Invalid.</summary>
|
||
public static CrescentFrame FitCrescent(Vector3[] pts, int[] tris, Vector2[] uvs, bool flipSweep)
|
||
{
|
||
var bad = CrescentFrame.Invalid;
|
||
if (pts == null || pts.Length < 8) return bad;
|
||
|
||
// ① 평면 법선 — 삼각형 법선의 면적 가중 합. 양면 메시에서 앞/뒤가 상쇄되지 않도록
|
||
// 가장 큰 삼각형의 법선을 기준으로 부호를 정렬한 뒤 더한다.
|
||
Vector3 n = AreaWeightedNormal(pts, tris);
|
||
if (n.sqrMagnitude < 1e-12f) return bad;
|
||
n.Normalize();
|
||
|
||
// ② 평면 기저
|
||
Vector3 u = Vector3.Cross(n, Mathf.Abs(n.y) < 0.9f ? Vector3.up : Vector3.right);
|
||
if (u.sqrMagnitude < 1e-12f) return bad;
|
||
u.Normalize();
|
||
Vector3 v = Vector3.Cross(n, u); // (u, v, n) 이 오른손 좌표계
|
||
|
||
// ③ 평면 위 2D 좌표
|
||
int cnt = pts.Length;
|
||
var xs = new float[cnt];
|
||
var ys = new float[cnt];
|
||
Vector3 origin = Vector3.zero;
|
||
for (int i = 0; i < cnt; i++) origin += pts[i];
|
||
origin /= cnt;
|
||
for (int i = 0; i < cnt; i++)
|
||
{
|
||
Vector3 d = pts[i] - origin;
|
||
xs[i] = Vector3.Dot(d, u);
|
||
ys[i] = Vector3.Dot(d, v);
|
||
}
|
||
|
||
// ④ 원 피팅 — Kåsa 대수 피팅 뒤 **바깥 가장자리만 남기며 재피팅**한다.
|
||
//
|
||
// 🔴 전체 정점에 한 번만 걸면 중심이 치우친다(실측 2026-09-07):
|
||
// 호는 안/바깥 반지름이 크게 다른 고리라 대수 잔차가 큰 반지름 쪽으로 끌린다.
|
||
// SlashMesh 에서 중심이 0.22 m 어긋났고, 그 탓에 안/바깥 비가 0.47 → 0.27 로 왜곡됐다.
|
||
// 바깥 경계만 남기면 그것은 **진짜 원**이라 정확히 수렴한다
|
||
// (재피팅 후 잔차 σ = 0.0000 · 안/바깥 = 0.470 으로 구 WL 독립 실측치와 일치).
|
||
float cx, cy;
|
||
if (!FitCircleKasa(xs, ys, out cx, out cy)) return bad;
|
||
|
||
var keepX = new List<float>(xs);
|
||
var keepY = new List<float>(ys);
|
||
for (int pass = 0; pass < 5 && keepX.Count >= 12; pass++)
|
||
{
|
||
var rr = new List<float>(keepX.Count);
|
||
for (int i = 0; i < keepX.Count; i++)
|
||
{
|
||
float dx = keepX[i] - cx, dy = keepY[i] - cy;
|
||
rr.Add(Mathf.Sqrt(dx * dx + dy * dy));
|
||
}
|
||
var sortedR = new List<float>(rr);
|
||
sortedR.Sort();
|
||
float cut = sortedR[sortedR.Count / 2]; // 중앙값 이상만 남긴다
|
||
var nx = new List<float>();
|
||
var ny = new List<float>();
|
||
for (int i = 0; i < keepX.Count; i++)
|
||
if (rr[i] >= cut) { nx.Add(keepX[i]); ny.Add(keepY[i]); }
|
||
if (nx.Count < 8) break;
|
||
keepX = nx; keepY = ny;
|
||
float ncx, ncy;
|
||
if (!FitCircleKasa(keepX.ToArray(), keepY.ToArray(), out ncx, out ncy)) break;
|
||
cx = ncx; cy = ncy;
|
||
}
|
||
|
||
Vector3 center = origin + u * cx + v * cy;
|
||
|
||
// ⑤ 반지름 · 각도
|
||
float rMin = float.MaxValue, rMax = 0f;
|
||
var ang = new float[cnt];
|
||
for (int i = 0; i < cnt; i++)
|
||
{
|
||
float dx = xs[i] - cx, dy = ys[i] - cy;
|
||
float r = Mathf.Sqrt(dx * dx + dy * dy);
|
||
if (r < rMin) rMin = r;
|
||
if (r > rMax) rMax = r;
|
||
ang[i] = Mathf.Atan2(dy, dx); // (-π, π]
|
||
}
|
||
if (rMax < 1e-5f) return bad;
|
||
|
||
// ⑥ 스윕 구간 — 각도를 정렬해 **최대 공백**을 찾는다. 호는 그 공백의 바깥이다.
|
||
// (원 전체를 덮는 링이면 공백이 거의 0 이라 sweep ≈ 360° 로 나온다)
|
||
var sorted = (float[])ang.Clone();
|
||
System.Array.Sort(sorted);
|
||
float gap = sorted[0] + Mathf.PI * 2f - sorted[cnt - 1];
|
||
float startAng = sorted[0]; // 기본 = 공백 다음(= 최소각)
|
||
for (int i = 1; i < cnt; i++)
|
||
{
|
||
float g = sorted[i] - sorted[i - 1];
|
||
if (g > gap) { gap = g; startAng = sorted[i]; }
|
||
}
|
||
float sweep = Mathf.PI * 2f - gap;
|
||
if (sweep < 1e-3f) return bad;
|
||
|
||
// ⑦ 호 진행 방향 — UV 의 u 축이 오름차순인 쪽을 시작으로 본다.
|
||
// (NamuFX 슬래시 메시는 텍스처가 호를 따라 흐른다) UV 가 없으면 CCW 를 그대로 쓴다.
|
||
bool reverse = flipSweep;
|
||
if (uvs != null && uvs.Length == cnt)
|
||
reverse ^= UvRunsAgainstAngle(uvs, ang, startAng, sweep);
|
||
|
||
float endAng = startAng + sweep;
|
||
float midAng = startAng + sweep * 0.5f;
|
||
|
||
Vector3 startDir = (u * Mathf.Cos(startAng) + v * Mathf.Sin(startAng)).normalized;
|
||
Vector3 endDir = (u * Mathf.Cos(endAng) + v * Mathf.Sin(endAng)).normalized;
|
||
Vector3 bisector = (u * Mathf.Cos(midAng) + v * Mathf.Sin(midAng)).normalized;
|
||
|
||
// 진행 방향이 반대라면 시작/끝만 맞바꾸고 법선을 뒤집는다.
|
||
// (호가 차지하는 구간·볼록 방향은 그대로이므로 bisector 는 건드리지 않는다)
|
||
if (reverse)
|
||
{
|
||
n = -n;
|
||
Vector3 tmp = startDir; startDir = endDir; endDir = tmp;
|
||
}
|
||
|
||
return new CrescentFrame
|
||
{
|
||
valid = true,
|
||
emitter = "(fit)",
|
||
normal = n,
|
||
center = center,
|
||
bisector = bisector,
|
||
startDir = startDir,
|
||
radiusOuter = rMax,
|
||
radiusInner = rMin,
|
||
sweepDeg = sweep * Mathf.Rad2Deg
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 정점을 못 읽을 때의 폴백 — 바운딩 박스로 대략의 프레임을 만든다.
|
||
/// 최단축 = 법선 · 최장축 = 현(chord) 으로 보고, 현에 수직인 평면 내 축을 볼록 방향으로 잡는다.
|
||
/// 반경은 "반원(180°)" 을 가정해 현 길이의 절반으로 둔다 — 정확하지 않으므로
|
||
/// 어디까지나 베이크가 없을 때의 안전 경로다(C8).
|
||
/// </summary>
|
||
public static CrescentFrame MeasureCrescentFallback(GameObject instance, string[] preferredEmitters)
|
||
{
|
||
if (instance == null) return CrescentFrame.Invalid;
|
||
|
||
var result = CrescentFrame.Invalid;
|
||
InNeutralPose(instance.transform, () =>
|
||
{
|
||
ParticleSystem best;
|
||
float bestSize;
|
||
if (!PickCrescentEmitter(instance, preferredEmitters, out best, out bestSize)) return;
|
||
|
||
var psr = best.GetComponent<ParticleSystemRenderer>();
|
||
Bounds b;
|
||
if (psr != null && psr.renderMode == ParticleSystemRenderMode.Mesh && psr.mesh != null)
|
||
b = psr.mesh.bounds;
|
||
else
|
||
b = new Bounds(Vector3.zero, Vector3.one);
|
||
|
||
var tm = best.transform;
|
||
Vector3 size = new Vector3(
|
||
b.size.x * Mathf.Abs(tm.lossyScale.x),
|
||
b.size.y * Mathf.Abs(tm.lossyScale.y),
|
||
b.size.z * Mathf.Abs(tm.lossyScale.z)) * bestSize;
|
||
|
||
int thin = MinAxis(size);
|
||
int longest = MaxAxis(size);
|
||
if (longest == thin) longest = (thin + 1) % 3;
|
||
int mid = 3 - thin - longest;
|
||
|
||
Vector3 nLocal = AxisVec(thin);
|
||
Vector3 bLocal = AxisVec(mid); // 현에 수직인 평면 내 축 = 볼록 방향 근사
|
||
|
||
Vector3 nWorld = tm.TransformDirection(nLocal).normalized;
|
||
Vector3 bWorld = tm.TransformDirection(bLocal).normalized;
|
||
float radius = Mathf.Max(size[longest], size[mid]) * 0.5f;
|
||
|
||
result = new CrescentFrame
|
||
{
|
||
valid = true,
|
||
emitter = best.gameObject.name + " (bbox fallback)",
|
||
normal = nWorld,
|
||
center = tm.TransformPoint(b.center) - bWorld * radius * 0.5f,
|
||
bisector = bWorld,
|
||
startDir = Vector3.Cross(nWorld, bWorld).normalized,
|
||
radiusOuter = radius,
|
||
radiusInner = radius * 0.5f,
|
||
sweepDeg = 180f
|
||
};
|
||
});
|
||
return result;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 찌르기 축 실측
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 찌르기 이펙트의 진행 축·시작점·길이를 실측한다.
|
||
///
|
||
/// 정점이 필요 없다 — 활성 이미터의 **위치와 방향성 크기**만으로 충분하다.
|
||
/// 축 = 이미터 위치의 주성분(가장 퍼진 방향)
|
||
/// 부호 = 무게중심에서 가장 멀리 떨어진 이미터 쪽을 칼끝으로 본다
|
||
/// (찌르기 이펙트는 칼끝 쪽으로 길게 뻗고 꼬리는 촘촘하다)
|
||
/// 길이 = **프리팹 루트에서 앞쪽으로 뻗은 도달 거리**
|
||
///
|
||
/// 🔴 길이를 "전체 범위"로 잡으면 안 된다(실측 2026-09-07):
|
||
/// FX_Blue Stab 은 뒤로 흐르는 스피드 라인(lb·line_flash)이 커서 전체 범위가 12.78 m 인데,
|
||
/// 작가가 부착점으로 둔 루트에서 앞으로 뻗은 거리는 5.31 m 다. 전체 범위로 크기를 맞추면
|
||
/// 이펙트가 의도의 2.4 배로 작아진다. 그래서 **루트를 축에 투영한 지점 → 앞끝** 을 기준으로 쓴다.
|
||
///
|
||
/// 등방 반경이 아니라 **축에 투영한 OBB 지지 함수**로 각 이미터의 축 방향 반크기를 잰다.
|
||
/// (등방 max 로 재면 8.5 m 짜리 가로 스트리크가 축 방향 길이를 통째로 부풀린다)
|
||
///
|
||
/// 부호 판정이 틀리면 <paramref name="flipAxis"/> 로 뒤집는다(SO 데이터 · 캡처로 확정).
|
||
/// <paramref name="keepNames"/> 를 주면 그 이미터만 재고 나머지는 무시한다 —
|
||
/// 드로우 예산 때문에 이미터를 줄이면 시각 길이도 함께 변하므로 두 결정은 같은 데이터다.
|
||
/// </summary>
|
||
public static StabFrame MeasureStab(GameObject instance, bool flipAxis, string[] keepNames)
|
||
{
|
||
if (instance == null) return StabFrame.Invalid;
|
||
|
||
var result = StabFrame.Invalid;
|
||
InNeutralPose(instance.transform, () =>
|
||
{
|
||
var systems = instance.GetComponentsInChildren<ParticleSystem>(true);
|
||
int n = 0;
|
||
var pos = new Vector3[systems.Length];
|
||
var e0 = new Vector3[systems.Length]; // 월드 반변 3개 (OBB)
|
||
var e1 = new Vector3[systems.Length];
|
||
var e2 = new Vector3[systems.Length];
|
||
var nrm = new Vector3[systems.Length];
|
||
var wgt = new float[systems.Length];
|
||
|
||
for (int i = 0; i < systems.Length; i++)
|
||
{
|
||
var ps = systems[i];
|
||
var psr = ps.GetComponent<ParticleSystemRenderer>();
|
||
if (psr == null || !psr.enabled) continue;
|
||
if (keepNames != null && keepNames.Length > 0 &&
|
||
System.Array.IndexOf(keepNames, ps.gameObject.name) < 0) continue;
|
||
|
||
float size = Mathf.Max(ps.main.startSize.constantMax, 0f);
|
||
if (size <= 0f) continue;
|
||
|
||
var tm = ps.transform;
|
||
float sx = Mathf.Abs(tm.lossyScale.x), sy = Mathf.Abs(tm.lossyScale.y), sz = Mathf.Abs(tm.lossyScale.z);
|
||
|
||
Vector3 ext;
|
||
if (psr.renderMode == ParticleSystemRenderMode.Mesh && psr.mesh != null)
|
||
{
|
||
Vector3 ms = psr.mesh.bounds.size;
|
||
ext = new Vector3(ms.x * sx, ms.y * sy, ms.z * sz) * size;
|
||
}
|
||
else
|
||
{
|
||
ext = new Vector3(size * sx, size * sy, 0f);
|
||
}
|
||
|
||
pos[n] = tm.position;
|
||
e0[n] = tm.right * (ext.x * 0.5f);
|
||
e1[n] = tm.up * (ext.y * 0.5f);
|
||
e2[n] = tm.forward * (ext.z * 0.5f);
|
||
// 납작한 축 = 이 이미터 면의 법선
|
||
nrm[n] = tm.TransformDirection(AxisVec(MinAxis(ext))).normalized;
|
||
wgt[n] = Mathf.Max(ext.x, Mathf.Max(ext.y, ext.z));
|
||
n++;
|
||
}
|
||
if (n < 2) return;
|
||
|
||
// 무게중심
|
||
Vector3 c = Vector3.zero;
|
||
float wsum = 0f;
|
||
for (int i = 0; i < n; i++) { c += pos[i] * wgt[i]; wsum += wgt[i]; }
|
||
if (wsum < 1e-6f) return;
|
||
c /= wsum;
|
||
|
||
// 주성분(멱승법 3회면 충분하다 — 축이 뚜렷하다)
|
||
Vector3 axis = PrincipalAxis(pos, n, c);
|
||
if (axis.sqrMagnitude < 1e-10f) return;
|
||
axis.Normalize();
|
||
|
||
// 부호 — 무게중심에서 가장 멀리 떨어진 이미터 쪽
|
||
float far = 0f; float farT = 0f;
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
float t = Vector3.Dot(pos[i] - c, axis);
|
||
if (Mathf.Abs(t) > far) { far = Mathf.Abs(t); farT = t; }
|
||
}
|
||
if (farT < 0f) axis = -axis;
|
||
if (flipAxis) axis = -axis;
|
||
|
||
// 축 방향 범위 · 수직 두께 — OBB 지지 함수로 축에 투영한다
|
||
float tMin = float.MaxValue, tMax = float.MinValue, thick = 0f;
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
float t = Vector3.Dot(pos[i] - c, axis);
|
||
float ha = Mathf.Abs(Vector3.Dot(e0[i], axis))
|
||
+ Mathf.Abs(Vector3.Dot(e1[i], axis))
|
||
+ Mathf.Abs(Vector3.Dot(e2[i], axis));
|
||
if (t - ha < tMin) tMin = t - ha;
|
||
if (t + ha > tMax) tMax = t + ha;
|
||
|
||
Vector3 perp = (pos[i] - c) - axis * t;
|
||
float hp = (e0[i] - axis * Vector3.Dot(e0[i], axis)).magnitude
|
||
+ (e1[i] - axis * Vector3.Dot(e1[i], axis)).magnitude
|
||
+ (e2[i] - axis * Vector3.Dot(e2[i], axis)).magnitude;
|
||
float th = perp.magnitude + hp;
|
||
if (th > thick) thick = th;
|
||
}
|
||
if (tMax - tMin < 1e-4f) return;
|
||
|
||
// 기준점 = 프리팹 루트를 축에 투영한 지점(작가가 둔 부착점) · 길이 = 거기서 앞끝까지
|
||
float tRoot = Vector3.Dot(Vector3.zero - c, axis);
|
||
float len = tMax - tRoot;
|
||
if (len < 1e-4f) return;
|
||
|
||
// 이펙트 면의 법선 — 가장 큰 이미터의 납작한 축을 축에 수직으로 정사영
|
||
Vector3 planeN = Vector3.zero;
|
||
for (int i = 0; i < n; i++) planeN += nrm[i] * wgt[i];
|
||
planeN -= axis * Vector3.Dot(planeN, axis);
|
||
if (planeN.sqrMagnitude < 1e-8f) planeN = Vector3.Cross(axis, Vector3.up);
|
||
if (planeN.sqrMagnitude < 1e-8f) planeN = Vector3.Cross(axis, Vector3.right);
|
||
planeN.Normalize();
|
||
|
||
result = new StabFrame
|
||
{
|
||
valid = true,
|
||
note = string.Format("emitters={0} forward={1:F2} total={2:F2} thick={3:F2}",
|
||
n, len, tMax - tMin, thick * 2f),
|
||
axis = axis,
|
||
start = c + axis * tRoot,
|
||
length = len,
|
||
thickness = thick * 2f,
|
||
planeNormal = planeN
|
||
};
|
||
});
|
||
return result;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 내부 유틸
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>호 본체 이미터를 고른다. 이름 지정 → 방향 정보 보유(비 View 정렬) → 큰 것 순.</summary>
|
||
private static bool PickCrescentEmitter(GameObject go, string[] preferred, out ParticleSystem best, out float bestStartSize)
|
||
{
|
||
best = null;
|
||
bestStartSize = 0f;
|
||
|
||
var systems = go.GetComponentsInChildren<ParticleSystem>(true);
|
||
float bestScore = 0f;
|
||
bool bestNamed = false, bestOriented = false;
|
||
|
||
for (int i = 0; i < systems.Length; i++)
|
||
{
|
||
var ps = systems[i];
|
||
var psr = ps.GetComponent<ParticleSystemRenderer>();
|
||
if (psr == null || !psr.enabled) continue;
|
||
|
||
float size = Mathf.Max(ps.main.startSize.constantMax, 0f);
|
||
if (size <= 0f) continue;
|
||
|
||
bool named = Contains(preferred, ps.gameObject.name);
|
||
bool oriented = psr.renderMode == ParticleSystemRenderMode.Mesh
|
||
? psr.alignment != ParticleSystemRenderSpace.View
|
||
: psr.alignment == ParticleSystemRenderSpace.Local;
|
||
|
||
Vector3 ls = ps.transform.lossyScale;
|
||
float score = size * Mathf.Max(Mathf.Abs(ls.x), Mathf.Max(Mathf.Abs(ls.y), Mathf.Abs(ls.z)));
|
||
if (psr.renderMode == ParticleSystemRenderMode.Mesh && psr.mesh != null)
|
||
{
|
||
Vector3 ms = psr.mesh.bounds.size;
|
||
score *= Mathf.Max(ms.x, Mathf.Max(ms.y, ms.z));
|
||
}
|
||
|
||
bool better;
|
||
if (named != bestNamed) better = named;
|
||
else if (oriented != bestOriented) better = oriented;
|
||
else better = score > bestScore;
|
||
if (!better) continue;
|
||
|
||
best = ps;
|
||
bestStartSize = size;
|
||
bestScore = score;
|
||
bestNamed = named;
|
||
bestOriented = oriented;
|
||
}
|
||
return best != null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 메시 정점을 읽는다. 실패하면 false — 예외를 던지지 않는다.
|
||
///
|
||
/// 🔴 <c>isReadable</c> 로 미리 거르지 않는다(실측 2026-09-07).
|
||
/// 임포트된 FBX 는 Read/Write 가 꺼져 있는 것이 기본이지만 **에디터에서는 정점을 읽을 수 있다**
|
||
/// (SlashMesh 는 isReadable=False 인데도 160 정점을 정상적으로 읽었다). 미리 거르면
|
||
/// 에디터 베이크까지 막혀 정밀 실측을 못 한다. 대신 빌드된 플레이어에서는 진짜로 실패하므로
|
||
/// 이 경로는 **에디터 베이크 전용**이고 런타임은 구워 둔 값만 읽는다.
|
||
/// </summary>
|
||
private static bool TryReadMesh(Mesh mesh, out Vector3[] verts, out int[] tris, out Vector2[] uvs)
|
||
{
|
||
verts = null; tris = null; uvs = null;
|
||
if (mesh == null) return false;
|
||
try
|
||
{
|
||
verts = mesh.vertices;
|
||
tris = mesh.triangles;
|
||
uvs = mesh.uv;
|
||
if (uvs != null && uvs.Length != verts.Length) uvs = null;
|
||
}
|
||
catch (System.Exception e)
|
||
{
|
||
Debug.LogWarning("[WL #792] 메시 읽기 실패: " + e.Message);
|
||
return false;
|
||
}
|
||
return verts != null && verts.Length >= 8;
|
||
}
|
||
|
||
/// <summary>삼각형 법선의 면적 가중 합. 가장 큰 삼각형을 기준으로 부호를 정렬해 양면 상쇄를 막는다.</summary>
|
||
private static Vector3 AreaWeightedNormal(Vector3[] pts, int[] tris)
|
||
{
|
||
if (tris == null || tris.Length < 3)
|
||
{
|
||
// 삼각형이 없으면 공분산의 최소 고유벡터 대신 "가장 퍼진 두 축의 외적"으로 근사
|
||
Vector3 c = Vector3.zero;
|
||
for (int i = 0; i < pts.Length; i++) c += pts[i];
|
||
c /= pts.Length;
|
||
Vector3 a1 = PrincipalAxis(pts, pts.Length, c);
|
||
Vector3 a2 = SecondaryAxis(pts, pts.Length, c, a1);
|
||
return Vector3.Cross(a1, a2);
|
||
}
|
||
|
||
// 기준 법선 = 면적이 가장 큰 삼각형
|
||
Vector3 refN = Vector3.zero;
|
||
float refA = 0f;
|
||
for (int i = 0; i + 2 < tris.Length; i += 3)
|
||
{
|
||
Vector3 cr = Vector3.Cross(pts[tris[i + 1]] - pts[tris[i]], pts[tris[i + 2]] - pts[tris[i]]);
|
||
float a = cr.magnitude;
|
||
if (a > refA) { refA = a; refN = cr; }
|
||
}
|
||
if (refA < 1e-12f) return Vector3.zero;
|
||
refN /= refA;
|
||
|
||
Vector3 sum = Vector3.zero;
|
||
for (int i = 0; i + 2 < tris.Length; i += 3)
|
||
{
|
||
Vector3 cr = Vector3.Cross(pts[tris[i + 1]] - pts[tris[i]], pts[tris[i + 2]] - pts[tris[i]]);
|
||
sum += Vector3.Dot(cr, refN) >= 0f ? cr : -cr;
|
||
}
|
||
return sum;
|
||
}
|
||
|
||
/// <summary>Kåsa 대수 원피팅. 정규방정식 3×3 을 크래머 공식으로 푼다.</summary>
|
||
private static bool FitCircleKasa(float[] xs, float[] ys, out float cx, out float cy)
|
||
{
|
||
cx = cy = 0f;
|
||
int n = xs.Length;
|
||
if (n < 3) return false;
|
||
|
||
double sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0, sxz = 0, syz = 0, sz = 0;
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
double x = xs[i], y = ys[i], z = x * x + y * y;
|
||
sx += x; sy += y; sz += z;
|
||
sxx += x * x; syy += y * y; sxy += x * y;
|
||
sxz += x * z; syz += y * z;
|
||
}
|
||
|
||
// [sxx sxy sx][D] [-sxz]
|
||
// [sxy syy sy][E] = [-syz]
|
||
// [sx sy n ][F] [-sz ]
|
||
double a11 = sxx, a12 = sxy, a13 = sx;
|
||
double a21 = sxy, a22 = syy, a23 = sy;
|
||
double a31 = sx, a32 = sy, a33 = n;
|
||
double b1 = -sxz, b2 = -syz, b3 = -sz;
|
||
|
||
double det = a11 * (a22 * a33 - a23 * a32) - a12 * (a21 * a33 - a23 * a31) + a13 * (a21 * a32 - a22 * a31);
|
||
if (System.Math.Abs(det) < 1e-12) return false;
|
||
|
||
double dD = b1 * (a22 * a33 - a23 * a32) - a12 * (b2 * a33 - a23 * b3) + a13 * (b2 * a32 - a22 * b3);
|
||
double dE = a11 * (b2 * a33 - a23 * b3) - b1 * (a21 * a33 - a23 * a31) + a13 * (a21 * b3 - b2 * a31);
|
||
|
||
cx = (float)(-(dD / det) * 0.5);
|
||
cy = (float)(-(dE / det) * 0.5);
|
||
return !float.IsNaN(cx) && !float.IsNaN(cy) && !float.IsInfinity(cx) && !float.IsInfinity(cy);
|
||
}
|
||
|
||
/// <summary>UV 의 u 가 각도 증가 방향과 반대로 흐르는가 (상관계수 부호).</summary>
|
||
private static bool UvRunsAgainstAngle(Vector2[] uvs, float[] ang, float startAng, float sweep)
|
||
{
|
||
int n = ang.Length;
|
||
double su = 0, sa = 0;
|
||
var uu = new double[n];
|
||
var aa = new double[n];
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
float rel = Mathf.Repeat(ang[i] - startAng, Mathf.PI * 2f);
|
||
if (rel > sweep + 1e-3f) rel -= Mathf.PI * 2f;
|
||
uu[i] = uvs[i].x;
|
||
aa[i] = rel;
|
||
su += uu[i]; sa += aa[i];
|
||
}
|
||
su /= n; sa /= n;
|
||
|
||
double cov = 0;
|
||
for (int i = 0; i < n; i++) cov += (uu[i] - su) * (aa[i] - sa);
|
||
return cov < 0;
|
||
}
|
||
|
||
/// <summary>멱승법으로 최대 분산 축을 구한다.</summary>
|
||
private static Vector3 PrincipalAxis(Vector3[] pts, int count, Vector3 center)
|
||
{
|
||
// 공분산 (대칭 3×3)
|
||
float xx = 0, xy = 0, xz = 0, yy = 0, yz = 0, zz = 0;
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
Vector3 d = pts[i] - center;
|
||
xx += d.x * d.x; xy += d.x * d.y; xz += d.x * d.z;
|
||
yy += d.y * d.y; yz += d.y * d.z; zz += d.z * d.z;
|
||
}
|
||
|
||
Vector3 v = new Vector3(1f, 0.7f, 0.3f);
|
||
for (int it = 0; it < 24; it++)
|
||
{
|
||
Vector3 w = new Vector3(
|
||
xx * v.x + xy * v.y + xz * v.z,
|
||
xy * v.x + yy * v.y + yz * v.z,
|
||
xz * v.x + yz * v.y + zz * v.z);
|
||
float m = w.magnitude;
|
||
if (m < 1e-12f) return Vector3.zero;
|
||
v = w / m;
|
||
}
|
||
return v;
|
||
}
|
||
|
||
/// <summary>주축을 제거한 뒤 다시 멱승법 — 두 번째 축.</summary>
|
||
private static Vector3 SecondaryAxis(Vector3[] pts, int count, Vector3 center, Vector3 primary)
|
||
{
|
||
var proj = new Vector3[count];
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
Vector3 d = pts[i] - center;
|
||
proj[i] = center + (d - primary * Vector3.Dot(d, primary));
|
||
}
|
||
return PrincipalAxis(proj, count, center);
|
||
}
|
||
|
||
private static bool Contains(string[] names, string value)
|
||
{
|
||
if (names == null) return false;
|
||
for (int i = 0; i < names.Length; i++) if (names[i] == value) return true;
|
||
return false;
|
||
}
|
||
|
||
private static Vector3 AxisVec(int i) { var v = Vector3.zero; v[i] = 1f; return v; }
|
||
|
||
private static int MinAxis(Vector3 s)
|
||
{
|
||
if (s.x <= s.y && s.x <= s.z) return 0;
|
||
return s.y <= s.z ? 1 : 2;
|
||
}
|
||
|
||
private static int MaxAxis(Vector3 s)
|
||
{
|
||
if (s.x >= s.y && s.x >= s.z) return 0;
|
||
return s.y >= s.z ? 1 : 2;
|
||
}
|
||
}
|
||
}
|