// ─────────────────────────────────────────────────────────────────────────────
// 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
{
///
/// 이펙트 프리팹 안에서 실측한 "호" 1개의 기하.
/// 모든 값은 **프리팹 루트 로컬 · 루트 스케일 1 · 루트 회전 항등** 기준이다
/// (측정 시 루트를 원점·항등·스케일1 로 잠깐 옮겨 놓고 잰다 — 아래 MeasureInstance 참고).
///
[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)" }; } }
/// 메시 프레임 → 목표 프레임 변환 회전. (법선, 볼록 방향) 두 축을 동시에 맞춘다.
public Quaternion FrameRotation
{
get { return Quaternion.LookRotation(normal, bisector); }
}
}
///
/// 이펙트 프리팹 안에서 실측한 "직선(찌르기) 축" 기하.
/// 좌표계 규약은 과 같다.
///
[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)" }; } }
}
///
/// 이펙트 프리팹의 호·직선 기하를 실측한다.
///
/// 사용 시점:
/// · 에디터 베이크 — 정점 기반 정밀 실측().
/// 결과를 SlashTrailSettings 에 구워 둔다.
/// · 런타임 — 구워진 프레임이 없을 때만 바운딩 박스 폴백
/// (). 정점을 읽지 않는다.
///
public static class SlashArcMeasure
{
// ─────────────────────────────────────────────────────────────────────
// 공통 — 인스턴스를 원점·항등·스케일1 로 잠깐 놓고 재기
// ─────────────────────────────────────────────────────────────────────
///
/// 인스턴스의 루트를 원점·항등 회전·스케일 1 로 옮긴 뒤 를 실행하고
/// 원래 자세로 되돌린다. 이렇게 하면 자식의 world 좌표가 곧 "루트 로컬 · 스케일 1" 좌표가 된다.
///
/// Unity 의 트랜스폼 계층은 즉시 갱신되므로(지연 없음) 비활성 오브젝트에서도 정확하다.
/// InGameInfo.Show_EffectEx 는 actSpawned 를 SetActive **직전에** 부르므로,
/// 이 함수를 그 콜백 안에서 호출해도 파티클이 한 프레임도 잘못된 자리에 뜨지 않는다.
///
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;
}
}
// ─────────────────────────────────────────────────────────────────────
// 호(크레센트) 실측 — 정점 기반 (에디터 베이크 전용)
// ─────────────────────────────────────────────────────────────────────
///
/// 프리팹 인스턴스에서 호 이미터를 골라 원 피팅으로 프레임을 실측한다.
///
/// 에 이름이 있으면 1순위로 쓴다(C45 — 어느 이미터가
/// 호 본체인지는 데이터다). 없으면 "카메라 정렬이 아닌 Mesh 렌더 이미터 중 가장 큰 것"을 고른다.
///
/// 🔴 메시 정점을 읽으므로 **에디터에서만** 부른다. isReadable 이 꺼진 FBX 는
/// 빌드된 플레이어에서 실패한다. 실패하면 Invalid 를 돌려준다(예외를 던지지 않는다).
///
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();
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;
}
///
/// 시간 순서대로 들어온 점들을 원호로 피팅한다 — 스윙 궤적(칼끝 샘플)용.
///
/// 정점 피팅()과 달리 점의 순서가 곧 호의 진행 방향이므로
/// UV·테이퍼 같은 간접 단서가 필요 없다. 법선의 부호는 pts[0]→pts[count-1] 이
/// +normal 축에서 볼 때 반시계가 되도록 잡는다(= 스윙 진행 방향을 법선이 담는다).
///
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
};
}
/// 점들의 무게중심을 평면(법선 normal · 기준점 onPlane)에 투영해 돌려준다.
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);
}
/// 중심에서 잰 반지름의 표준편차. 원 피팅이 실제로 원을 찾았는지 보는 척도.
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);
}
///
/// 피벗을 바꾼 뒤 시작 방향 · 볼록 방향 · 스윕을 다시 잰다.
/// 진행 방향이 CCW 가 되도록 의 부호도 함께 맞춘다.
///
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;
}
/// 중심에서 잰 평균 반지름 — 손잡이 반경처럼 "이 원 기준으로 얼마인가"를 잴 때.
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;
}
///
/// #816v — 칼끝 궤적 창에서 **감아올리기(한 바퀴 넘게 감기는 구간)** 를 잘라 낸다.
///
/// 속도 트림()은 "칼이 멈춘 꼬리"만 자른다.
/// 1타처럼 클립이 타격 뒤에도 빠르게 계속 감기면 속도 트림은 그 구간을 남기고, 그러면 칼끝이
/// 한 바퀴를 넘겨 원 피팅이 깨진다(반경 편차비 0.27) → 호출부가 손잡이 중심 폴백으로 떨어져
/// 피벗이 머리 높이·몸 옆으로 올라간다.
///
/// 여기서는 창 끝을 한 프레임씩 앞으로 당기며 **편차비가 기준 이하가 되는 가장 긴 창**의
/// 길이를 돌려준다. 기준을 만족하는 창이 없으면 원래 길이를 그대로 돌려준다(동작 불변).
///
/// 칼끝 궤적(창 시작부터 순서대로)
/// 현재 창 길이
/// 합격 기준 = 반경 편차 / 평균 반경
/// 이보다 짧게는 자르지 않는다
/// 새 창 길이(≤ count)
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;
}
/// 정점 집합을 원호로 피팅한다. 실패하면 Invalid.
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(xs);
var keepY = new List(ys);
for (int pass = 0; pass < 5 && keepX.Count >= 12; pass++)
{
var rr = new List(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(rr);
sortedR.Sort();
float cut = sortedR[sortedR.Count / 2]; // 중앙값 이상만 남긴다
var nx = new List();
var ny = new List();
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
};
}
///
/// 정점을 못 읽을 때의 폴백 — 바운딩 박스로 대략의 프레임을 만든다.
/// 최단축 = 법선 · 최장축 = 현(chord) 으로 보고, 현에 수직인 평면 내 축을 볼록 방향으로 잡는다.
/// 반경은 "반원(180°)" 을 가정해 현 길이의 절반으로 둔다 — 정확하지 않으므로
/// 어디까지나 베이크가 없을 때의 안전 경로다(C8).
///
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();
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;
}
// ─────────────────────────────────────────────────────────────────────
// 찌르기 축 실측
// ─────────────────────────────────────────────────────────────────────
///
/// 찌르기 이펙트의 진행 축·시작점·길이를 실측한다.
///
/// 정점이 필요 없다 — 활성 이미터의 **위치와 방향성 크기**만으로 충분하다.
/// 축 = 이미터 위치의 주성분(가장 퍼진 방향)
/// 부호 = 무게중심에서 가장 멀리 떨어진 이미터 쪽을 칼끝으로 본다
/// (찌르기 이펙트는 칼끝 쪽으로 길게 뻗고 꼬리는 촘촘하다)
/// 길이 = **프리팹 루트에서 앞쪽으로 뻗은 도달 거리**
///
/// 🔴 길이를 "전체 범위"로 잡으면 안 된다(실측 2026-09-07):
/// FX_Blue Stab 은 뒤로 흐르는 스피드 라인(lb·line_flash)이 커서 전체 범위가 12.78 m 인데,
/// 작가가 부착점으로 둔 루트에서 앞으로 뻗은 거리는 5.31 m 다. 전체 범위로 크기를 맞추면
/// 이펙트가 의도의 2.4 배로 작아진다. 그래서 **루트를 축에 투영한 지점 → 앞끝** 을 기준으로 쓴다.
///
/// 등방 반경이 아니라 **축에 투영한 OBB 지지 함수**로 각 이미터의 축 방향 반크기를 잰다.
/// (등방 max 로 재면 8.5 m 짜리 가로 스트리크가 축 방향 길이를 통째로 부풀린다)
///
/// 부호 판정이 틀리면 로 뒤집는다(SO 데이터 · 캡처로 확정).
/// 를 주면 그 이미터만 재고 나머지는 무시한다 —
/// 드로우 예산 때문에 이미터를 줄이면 시각 길이도 함께 변하므로 두 결정은 같은 데이터다.
///
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(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();
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;
}
// ─────────────────────────────────────────────────────────────────────
// 내부 유틸
// ─────────────────────────────────────────────────────────────────────
/// 호 본체 이미터를 고른다. 이름 지정 → 방향 정보 보유(비 View 정렬) → 큰 것 순.
private static bool PickCrescentEmitter(GameObject go, string[] preferred, out ParticleSystem best, out float bestStartSize)
{
best = null;
bestStartSize = 0f;
var systems = go.GetComponentsInChildren(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();
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;
}
///
/// 메시 정점을 읽는다. 실패하면 false — 예외를 던지지 않는다.
///
/// 🔴 isReadable 로 미리 거르지 않는다(실측 2026-09-07).
/// 임포트된 FBX 는 Read/Write 가 꺼져 있는 것이 기본이지만 **에디터에서는 정점을 읽을 수 있다**
/// (SlashMesh 는 isReadable=False 인데도 160 정점을 정상적으로 읽었다). 미리 거르면
/// 에디터 베이크까지 막혀 정밀 실측을 못 한다. 대신 빌드된 플레이어에서는 진짜로 실패하므로
/// 이 경로는 **에디터 베이크 전용**이고 런타임은 구워 둔 값만 읽는다.
///
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;
}
/// 삼각형 법선의 면적 가중 합. 가장 큰 삼각형을 기준으로 부호를 정렬해 양면 상쇄를 막는다.
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;
}
/// Kåsa 대수 원피팅. 정규방정식 3×3 을 크래머 공식으로 푼다.
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);
}
/// UV 의 u 가 각도 증가 방향과 반대로 흐르는가 (상관계수 부호).
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;
}
/// 멱승법으로 최대 분산 축을 구한다.
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;
}
/// 주축을 제거한 뒤 다시 멱승법 — 두 번째 축.
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;
}
}
}