// ───────────────────────────────────────────────────────────────────────────── // 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; } } }