Project_WL/AgentScripts/WL828_TrailBake.cs

395 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// WL828_TrailBake.cs — Warrior Pack 모션 교체 뒤 검기(호/찌르기) 캘리브레이션 재베이크 (PD 2026-10-10 「무기 별로 공격 궤적 이펙트를 맞춰야」)
// unity command run_script --file AgentScripts/WL828_TrailBake.cs --entry WL828_TrailBake.Probe --args "[10101, false]" (dry · 전체 클립 속도트림 창 = 09-15 방식)
// unity command run_script --file AgentScripts/WL828_TrailBake.cs --entry WL828_TrailBake.ProbeM --args "[10501, false, 1]" (dry · mode 1 = 런타임 창: ShowEffect → +swingWindowNormalized → 속도·감아올리기 트림)
// unity command run_script --file AgentScripts/WL828_TrailBake.cs --entry WL828_TrailBake.ProbeM --args "[10501, true, 1]" (SlashTrailSettings 에 기록)
//
// WL816t_Bake(2026-09-15 · 기존 정합 작업)와 **같은 캐릭터·스케일·무기 보정·로컬 규약(회전만)·수식(SlashArcMeasure)** 을 쓴다.
// 다른 점 — ① 클립을 FBX 경로 상수가 아니라 **클래스의 애니메이터 컨트롤러**(ClassConfig s_AnimationController → 상태 클립 Attack{N}_S_{CID})에서 가져온다.
// ② 무기는 ClassConfig 오른손 프리팹이되 WLWeaponClasses.json 수정 행(망치)·추가 행(창 10701 = ClassConfig 에 없음)을 우선한다.
// ③ mode 1 = 런타임이 실제로 재는 창(ShowEffect 이벤트 → +0.35)으로 굽는다 — 망치처럼 처음부터 빠른 들어올리기가 있는 클립은
// 전체 클립 트림(mode 0)이 감아올리기까지 삼켜 sweep 400° 가 되므로(#834 실측) 이쪽을 쓴다.
// ④ 찌르기/휘두르기를 수치로 가른다(칼날 누적 회전 < swingMinSweepDegrees 이고 직진도 ≥ 0.6 이고 chord ≥ stabMinDistance → Stab) — 창 1·2타.
// Stab 행은 stabAxis/stabStart/stabLength 를 실측으로 채운다(PlaceStab 이 쓴다).
// ⑤ ShowEffect 가 클립에 없는 창(런타임 EnsureClipEvents 가 붙임)은 json effectTimes/effectPrefabs 를 이벤트로 본다 — 행 키도 그 프리팹 이름.
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using WL.Combat;
public static class WL828_TrailBake
{
const string SettingsPath = "Assets/WL/Settings/Resources/WL/SlashTrailSettings.asset";
const string ClassConfigJson = "Assets/ResWork/Table/Export/ClassConfig.json";
const string WeaponClassesJson = "Assets/WL/Combat/Weapon/Resources/WL/WLWeaponClasses.json";
const float BakedQuality = 50f;
const float StabMinStraight = 0.6f; // 도구 판정용(런타임 stabMinStraightness 0.86 은 경로 전체 기준이라 창 찌르기 0.75 를 놓친다 · WL834_ThrustProbe 와 같은 기준)
class Row
{
public int stage; public string clipName, clip;
public Vector3 pivot, normal, bisector, startDir; public float tipR, hiltR, sweep, path, fitQ, q, dev, bladeAng;
public SwingKind kind; public Vector3 stabAxis, stabStart; public float stabLength, straight;
public int lo, hi, n0, n, nBefore; public string pivotSrc;
public float evNorm; public int evF; public string evStr;
public string src;
}
class JRow { public int classId; public string weaponRHPrefab; public int weaponRHIndex = -1; public string animController; public int templateClassId; public List<float> effectTimes; public List<string> effectPrefabs; }
public static object Probe(int classId, bool apply) { return Run(classId, apply, 0); }
public static object ProbeM(int classId, bool apply, int mode) { return Run(classId, apply, mode); }
/// <summary>#841 한 타만 굽는다(stage 1~4 · 0 = 전부).</summary>
public static object ProbeS(int classId, bool apply, int mode, int stage) { s_onlyStage = stage; try { return Run(classId, apply, mode); } finally { s_onlyStage = 0; } }
static int s_onlyStage;
/// <summary>#841 창을 60 Hz 샘플 번호로 직접 지정해 한 타만 굽는다(트림 없음) — 검 1타처럼 자동 창이 감아올리기 구간을 잡을 때.</summary>
public static object ProbeF(int classId, bool apply, int stage, int fLo, int fHi) { s_onlyStage = stage; s_fLo = fLo; s_fHi = fHi; try { return Run(classId, apply, 1); } finally { s_onlyStage = 0; s_fLo = s_fHi = -1; } }
/// <summary>#841 한 타의 60 Hz 칼끝 샘플(위치·속도)을 전부 출력 — 창 고르기용(dry).</summary>
public static object ProbeDump(int classId, int stage) { s_onlyStage = stage; s_dump = true; try { return Run(classId, false, 1); } finally { s_onlyStage = 0; s_dump = false; } }
static int s_fLo = -1, s_fHi = -1; static bool s_dump;
public static object ProbeAll(bool apply)
{
var sb = new StringBuilder();
foreach (var c in new[] { 10101, 10102 }) sb.AppendLine(Run(c, apply, 0));
return sb.ToString();
}
static JRow ReadJsonRow(int classId)
{
try
{
if (!System.IO.File.Exists(WeaponClassesJson)) return null;
var root = Newtonsoft.Json.Linq.JObject.Parse(System.IO.File.ReadAllText(WeaponClassesJson));
var jrows = root["classes"] as Newtonsoft.Json.Linq.JArray;
if (jrows == null) return null;
foreach (var r in jrows)
{
if ((int?)r["classId"] != classId) continue;
var jr = new JRow { classId = classId };
jr.weaponRHPrefab = (string)r["weaponRHPrefab"];
jr.weaponRHIndex = (int?)r["weaponRHIndex"] ?? -1;
jr.animController = (string)r["animController"];
jr.templateClassId = (int?)r["templateClassId"] ?? 0;
var et = r["effectTimes"] as Newtonsoft.Json.Linq.JArray; if (et != null) jr.effectTimes = et.Select(x => (float)x).ToList();
var ep = r["effectPrefabs"] as Newtonsoft.Json.Linq.JArray; if (ep != null) jr.effectPrefabs = ep.Select(x => (string)x).ToList();
return jr;
}
}
catch (System.Exception) { }
return null;
}
static string Run(int classId, bool apply, int mode)
{
var sb = new StringBuilder();
var settings = AssetDatabase.LoadAssetAtPath<SlashTrailSettings>(SettingsPath);
if (settings == null) return "설정 에셋 없음";
// ── ClassConfig + WLWeaponClasses.json (C45 · 상수 금지) ───────────
var jr = ReadJsonRow(classId);
string weaponName = null, ctrlName = null; int socketIndex = -1; float fScale = 1f; bool inClassConfig = false;
try
{
var arr = Newtonsoft.Json.Linq.JArray.Parse(System.IO.File.ReadAllText(ClassConfigJson));
int lookup = classId;
foreach (var row in arr)
{
if ((string)row["n_ClassID"] != lookup.ToString()) continue;
inClassConfig = true;
weaponName = (string)row["s_WeaponRHPrefab"]; socketIndex = int.Parse((string)row["n_WeaponRHIndex"]);
if (string.IsNullOrEmpty(weaponName)) { weaponName = (string)row["s_WeaponLHPrefab"]; socketIndex = int.Parse((string)row["n_WeaponLHIndex"]); }
float.TryParse((string)row["f_Scale"], out fScale);
ctrlName = (string)row["s_AnimationController"];
break;
}
if (!inClassConfig && jr != null && jr.templateClassId > 0)
foreach (var row in arr) if ((string)row["n_ClassID"] == jr.templateClassId.ToString()) { float.TryParse((string)row["f_Scale"], out fScale); break; }
}
catch (System.Exception ex) { return "ClassConfig parse fail: " + ex.Message; }
string weaponSrc = inClassConfig ? "ClassConfig" : "WLWeaponClasses.json(추가 직업)";
if (jr != null)
{
if (!string.IsNullOrEmpty(jr.weaponRHPrefab)) { weaponName = jr.weaponRHPrefab; if (jr.weaponRHIndex >= 0) socketIndex = jr.weaponRHIndex; weaponSrc = "WLWeaponClasses.json"; }
if (!string.IsNullOrEmpty(jr.animController)) ctrlName = jr.animController;
}
if (string.IsNullOrEmpty(weaponName) || socketIndex < 0) return "class " + classId + " weapon not found (ClassConfig·json 둘 다)";
// ── 컨트롤러 → 클립 ───────────────────────────────────────────────────
if (string.IsNullOrEmpty(ctrlName)) return "class " + classId + " 컨트롤러 비어 있음";
string ctrlStem = System.IO.Path.GetFileNameWithoutExtension(ctrlName);
string ctrlPath = null;
foreach (var g in AssetDatabase.FindAssets(ctrlStem + " t:AnimatorController"))
{
var p = AssetDatabase.GUIDToAssetPath(g);
if (System.IO.Path.GetFileNameWithoutExtension(p) == ctrlStem) { ctrlPath = p; break; }
}
if (ctrlPath == null) return "컨트롤러 못 찾음: " + ctrlName;
var ctrl = AssetDatabase.LoadAssetAtPath<RuntimeAnimatorController>(ctrlPath);
if (ctrl == null) return "컨트롤러 로드 실패: " + ctrlPath;
// ── 런타임과 같은 PC 프리팹·스케일 ──────────────────────────────────
string pcName = WL.Character.WLCharacterSwapSettings.Resolve("Ai01");
float heightComp = WL.Character.WLCharacterSwapSettings.HeightCompensation;
string pcPath = "Assets/Res_Addr/PC/" + pcName + ".prefab";
var model = AssetDatabase.LoadAssetAtPath<GameObject>(pcPath);
var weaponPrefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Res_Addr/Weapon/" + weaponName + ".prefab");
if (model == null || weaponPrefab == null) return "load fail pc=" + pcPath + " weapon=" + weaponName;
float rootScale = Mathf.Max(fScale, 0.01f) * Mathf.Max(heightComp, 0.01f);
sb.AppendLine(string.Format("== class {0} · mode {1}({2}) · PC {3} rootScale={4:F4} · weapon {5}@{6}({7}) · controller {8} ({9} clips)",
classId, mode, mode == 1 ? "런타임 창" : "전체 클립 트림", pcName, rootScale, weaponName, socketIndex, weaponSrc, ctrlPath, ctrl.animationClips.Length));
var scene = EditorSceneManager.NewPreviewScene();
bool animMode = false;
var rows = new List<Row>();
try
{
var go = (GameObject)PrefabUtility.InstantiatePrefab(model, scene);
go.transform.position = Vector3.zero; go.transform.rotation = Quaternion.identity;
go.transform.localScale = Vector3.one * rootScale;
var pc = go.GetComponentInChildren<PCActor>(true);
if (pc == null || pc.tfs_weapon == null || socketIndex >= pc.tfs_weapon.Length) return sb + "\ntfs_weapon missing";
var socket = pc.tfs_weapon[socketIndex];
var w = (GameObject)PrefabUtility.InstantiatePrefab(weaponPrefab, scene);
w.transform.SetParent(socket, false);
w.transform.localPosition = Vector3.zero; w.transform.localRotation = Quaternion.identity; w.transform.localScale = Vector3.one;
var fitter = go.GetComponent<WL.Character.WLWeaponFitter>();
if (fitter == null) fitter = go.AddComponent<WL.Character.WLWeaponFitter>();
float wk = fitter.Fit(w.transform, socket);
// #834 방향 보정(활 등)은 런타임 EnsureWeaponOrient 와 같은 json 규칙을 적용해야 하지만 근접 4계열엔 규칙이 없다(0회전).
sb.AppendLine(string.Format(" 무기 보정 ×{0:F4} (WLWeaponFitter.Fit)", wk));
// ── 칼자루/칼끝 로컬(WeaponTrailDriver.MeasureSlot 과 같은 규칙) ──
Renderer best = null; Mesh bestMesh = null; float bestExtent = 0f;
foreach (var r in socket.GetComponentsInChildren<Renderer>(true))
{
var mf2 = r.GetComponent<MeshFilter>(); var smr = r as SkinnedMeshRenderer;
var mesh = smr != null ? smr.sharedMesh : (mf2 != null ? mf2.sharedMesh : null);
if (mesh == null) continue;
var ws = Vector3.Scale(mesh.bounds.size, Abs(r.transform.lossyScale));
float longest = Mathf.Max(ws.x, Mathf.Max(ws.y, ws.z));
if (longest > bestExtent) { bestExtent = longest; best = r; bestMesh = mesh; }
}
if (best == null) return sb + "\nno renderer under socket";
var b = bestMesh.bounds; var size = Vector3.Scale(b.size, Abs(best.transform.lossyScale));
int axis = size.x >= size.y && size.x >= size.z ? 0 : (size.y >= size.z ? 1 : 2);
Vector3 axisDir = Vector3.zero; axisDir[axis] = 1f;
Vector3 eA = best.transform.TransformPoint(b.center - axisDir * b.extents[axis]);
Vector3 eB = best.transform.TransformPoint(b.center + axisDir * b.extents[axis]);
bool aIsHilt = (eA - socket.position).sqrMagnitude <= (eB - socket.position).sqrMagnitude;
Vector3 hiltLocal = socket.InverseTransformPoint(aIsHilt ? eA : eB);
Vector3 tipLocal = socket.InverseTransformPoint(aIsHilt ? eB : eA);
sb.AppendLine(string.Format(" 무기 월드 길이 {0:F3} m · 칼끝-자루 {1:F3} m · 검날 {2}", bestExtent,
Vector3.Distance(socket.TransformPoint(tipLocal), socket.TransformPoint(hiltLocal)), best.name));
var anim = go.GetComponentInChildren<Animator>();
var animGo = anim.gameObject; anim.applyRootMotion = false;
var root = go.transform;
AnimationMode.StartAnimationMode(); animMode = true;
for (int stage = 1; stage <= 4; stage++) // #839 4타(대검 Attack4 · 단검 DodgeAttack Attack4_S)까지 — 클립이 없으면 아래에서 건너뛴다
{
if (s_onlyStage > 0 && stage != s_onlyStage) continue;
AnimationClip clip = null;
string want = "Attack" + stage + "_S_" + classId;
foreach (var c in ctrl.animationClips) if (c != null && c.name == want) { clip = c; break; }
if (clip == null) foreach (var c in ctrl.animationClips) if (c != null && c.name.StartsWith("Attack" + stage)) { clip = c; break; }
if (clip == null && jr != null && !inClassConfig) // 창: 상태 클립 이름 Spear_Attack{N}
foreach (var c in ctrl.animationClips) if (c != null && c.name.EndsWith("Attack" + stage) && !c.name.StartsWith("__")) { clip = c; break; }
if (clip == null) { sb.AppendLine(" [" + stage + "타] 컨트롤러에 클립 없음 (" + want + ")"); continue; }
int n0 = Mathf.Max(8, Mathf.RoundToInt(clip.length * 60f) + 1);
var tAll = new Vector3[n0]; var hAll = new Vector3[n0];
for (int i = 0; i < n0; i++)
{
float t = clip.length * i / (n0 - 1);
AnimationMode.BeginSampling();
AnimationMode.SampleAnimationClip(animGo, clip, t);
AnimationMode.EndSampling();
tAll[i] = Quaternion.Inverse(root.rotation) * (socket.TransformPoint(tipLocal) - root.position); // 런타임 ToLocalPos = 회전만
hAll[i] = Quaternion.Inverse(root.rotation) * (socket.TransformPoint(hiltLocal) - root.position);
}
float ratio = settings.swingTrimSpeedRatio;
if (s_dump)
for (int i = 0; i < n0; i++)
sb.AppendLine(string.Format(" f{0:D2} t={1:F3} tip=({2:F2},{3:F2},{4:F2}) hilt=({5:F2},{6:F2},{7:F2}) step={8:F3}", i, clip.length * i / (n0 - 1),
tAll[i].x, tAll[i].y, tAll[i].z, hAll[i].x, hAll[i].y, hAll[i].z, i > 0 ? Vector3.Distance(tAll[i], tAll[i - 1]) : 0f));
// ── ShowEffect 이벤트(클립 → 없으면 json effectTimes) ──
float evT = -1f; string evStr = null;
foreach (var ev in AnimationUtility.GetAnimationEvents(clip))
if (ev.functionName == "ShowEffect") { evT = ev.time; evStr = ev.stringParameter; break; }
if (evT < 0f && jr != null && jr.effectTimes != null && stage - 1 < jr.effectTimes.Count)
{
evT = jr.effectTimes[stage - 1] * clip.length;
evStr = jr.effectPrefabs != null && stage - 1 < jr.effectPrefabs.Count ? jr.effectPrefabs[stage - 1] : null;
}
int evF = evT >= 0f ? Mathf.Clamp(Mathf.RoundToInt(evT / clip.length * (n0 - 1)), 0, n0 - 1) : -1;
// ── 창 A: 전체 클립 속도 트림(mode 0) ──
int lo, hi; Trim(tAll, 0, n0 - 1, ratio, out lo, out hi);
int nA = hi - lo + 1; if (nA < 3) { lo = 0; hi = n0 - 1; nA = n0; }
var tipsA = Slice(tAll, lo, nA); var hiltsA = Slice(hAll, lo, nA);
int nBeforeA = nA;
if (settings.arcTrimWindupWrap) nA = SlashArcMeasure.TrimWindupWrap(tipsA, nA, settings.arcFitMaxDeviation, Mathf.Max(settings.arcMinSamples, 3));
var fitA = FitRange(tipsA, hiltsA, nA, settings);
// ── 창 B: 런타임 창(ShowEffect → +swingWindowNormalized → 속도·감아올리기 트림)(mode 1) ──
Fit fitB = null; int loB = -1, hiB = -1, nBeforeB = 0, nB = 0;
if (s_fLo >= 0) // #841 지정 창 — 트림 없음
{
int tLo = Mathf.Clamp(s_fLo, 0, n0 - 3), tHi = Mathf.Clamp(s_fHi, tLo + 2, n0 - 1);
nB = tHi - tLo + 1; nBeforeB = nB;
fitB = FitRange(Slice(tAll, tLo, nB), Slice(hAll, tLo, nB), nB, settings);
loB = tLo; hiB = tHi;
}
else if (evF >= 0)
{
float winSec = settings.swingWindowNormalized * clip.length;
int rHi = Mathf.Clamp(Mathf.RoundToInt((evT + winSec) / clip.length * (n0 - 1)), evF + 1, n0 - 1);
int tLo, tHi;
if (mode == 2) { tLo = evF; tHi = rHi; } // #841 mode 2 = 런타임 창을 트림 없이 통째로(검 1타처럼 빠른 구간만 남으면 호가 칼 경로와 어긋나는 경우)
else Trim(tAll, evF, rHi, ratio, out tLo, out tHi);
nB = tHi - tLo + 1;
if (nB >= 3)
{
var rt = Slice(tAll, tLo, nB); var rh = Slice(hAll, tLo, nB);
nBeforeB = nB;
if (mode != 2 && mode != 3 && settings.arcTrimWindupWrap) nB = SlashArcMeasure.TrimWindupWrap(rt, nB, settings.arcFitMaxDeviation, Mathf.Max(settings.arcMinSamples, 3)); // #841 mode 3 = 속도 트림만(감아올리기 트림 생략)
fitB = FitRange(rt, rh, nB, settings);
loB = tLo; hiB = tLo + nB - 1;
}
}
bool useB = (mode >= 1 || s_fLo >= 0) && fitB != null;
Fit fit = useB ? fitB : fitA;
int uLo = useB ? loB : lo, uHi = useB ? hiB : hi, uN = useB ? nB : nA, uBefore = useB ? nBeforeB : nBeforeA;
if (fit == null) { sb.AppendLine(" [" + stage + "타] 원 피팅 실패"); continue; }
// ── 찌르기/휘두르기 판정 ──
var kind = (fit.bladeAng < settings.swingMinSweepDegrees && fit.straight >= StabMinStraight && fit.chord >= settings.stabMinDistance) ? SwingKind.Stab : SwingKind.Swing;
string clipName = !string.IsNullOrEmpty(evStr) ? evStr : "Effect_Slash_" + classId + "_" + stage;
var row = new Row
{
stage = stage, clipName = clipName, clip = clip.name, kind = kind,
pivot = fit.pivot, normal = fit.normal, bisector = fit.bisector, startDir = fit.startDir,
tipR = fit.tipR, hiltR = fit.hiltR, sweep = fit.sweep, path = fit.path, fitQ = fit.fitQ, dev = fit.dev, bladeAng = fit.bladeAng,
stabAxis = fit.stabAxis, stabStart = fit.stabStart, stabLength = fit.chord, straight = fit.straight,
q = fit.path * Mathf.Max(fit.fitQ, 0.05f), lo = uLo, hi = uHi, n0 = n0, n = uN, nBefore = uBefore, pivotSrc = fit.pivotSrc,
evNorm = evT >= 0f ? evT / clip.length : -1f, evF = evF, evStr = evStr
};
row.src = string.Format("wl828m{18} {0:yyyy-MM-dd HH:mm} {1} f{2}~f{3}/{4}@60Hz n={5}(트림 {6}→{5}) PC={7} rootScale={8:F4} weaponK={9:F4} path={10:F2} bladeAng={11:F0}° straight={19:F2} sweep={12:F1}° pivot={13} tipR={14:F3} hiltR={15:F3} dev={16:F3} fitQ={17:F2} kind={20} (런타임 로컬=회전만 · 컨트롤러 클립)",
System.DateTime.Now, clip.name, uLo, uHi, n0 - 1, uN, uBefore, pcName, rootScale, wk, fit.path, fit.bladeAng, fit.sweep, fit.pivotSrc, fit.tipR, fit.hiltR, fit.dev, fit.fitQ, s_fLo >= 0 ? mode + "F(지정창)" : mode.ToString(), fit.straight, kind);
rows.Add(row);
sb.AppendLine(string.Format(" [{0}타] {1} len={2:F3}s · 채택창({3}) f{4}~f{5}/{6} n={7}(트림 전 {8}) · pivot({9}){10} tipR={11:F3} hiltR={12:F3} sweep={13:F1}° bladeAng={14:F0}° straight={15:F2} chord={16:F2} path={17:F2} dev/R={18:F2} → {19}",
stage, clip.name, clip.length, useB ? (s_fLo >= 0 ? "지정" : mode == 2 ? "런타임·무트림" : "런타임") : "전체", uLo, uHi, n0 - 1, uN, uBefore, fit.pivotSrc, V(fit.pivot), fit.tipR, fit.hiltR, fit.sweep, fit.bladeAng, fit.straight, fit.chord, fit.path, fit.tipR > 1e-4f ? fit.dev / fit.tipR : 9f, kind));
sb.AppendLine(string.Format(" ShowEffect norm={0:F3} (f{1}/{2}) str='{3}' · 전체창 f{4}~f{5} 시작 대비 {6}",
row.evNorm, evF, n0 - 1, evStr, lo, hi, evF < 0 ? "-" : (evF <= lo ? (lo - evF) + "프레임 앞" : (evF - lo) + "프레임 뒤")));
if (fitA != null && fitB != null)
sb.AppendLine(string.Format(" 전체창 sweep={0:F1}° dev/R={1:F2} vs 런타임창 sweep={2:F1}° dev/R={3:F2} · Δpivot={4:F3} m · Δnormal={5:F1}°",
fitA.sweep, fitA.tipR > 1e-4f ? fitA.dev / fitA.tipR : 9f, fitB.sweep, fitB.tipR > 1e-4f ? fitB.dev / fitB.tipR : 9f, Vector3.Distance(fitA.pivot, fitB.pivot), Vector3.Angle(fitA.normal, fitB.normal)));
var old = settings.FindCalibration(classId, row.clipName);
sb.AppendLine(old == null ? " 기존 행 없음 → 신규" : string.Format(" 기존 행: kind={0} pivot{1} tipR={2:F3} sweep={3:F1}° · Δpivot={4:F3} m · Δnormal={5:F1}° · src={6}",
old.kind, V(old.pivot), old.tipRadius, old.sweepDeg, Vector3.Distance(old.pivot, row.pivot), Vector3.Angle(old.planeNormal, row.normal),
old.source != null && old.source.Length > 50 ? old.source.Substring(0, 50) + "…" : old.source));
}
AnimationMode.StopAnimationMode(); animMode = false;
if (apply)
{
int removed = 0;
foreach (var r in rows)
{
removed += settings.swingCalibrations.RemoveAll(c => c != null && c.classId == classId && c.clipName == r.clipName);
settings.swingCalibrations.Add(new SwingCalibration
{
classId = classId, clipName = r.clipName, kind = r.kind,
planeNormal = r.normal, bisector = r.bisector, startDir = r.startDir,
pivot = r.pivot, tipRadius = r.tipR, hiltRadius = r.hiltR, sweepDeg = r.sweep,
stabAxis = r.stabAxis, stabStart = r.stabStart, stabLength = Mathf.Max(r.stabLength, 1e-3f),
quality = Mathf.Max(BakedQuality, r.q), source = r.src
});
}
EditorUtility.SetDirty(settings);
AssetDatabase.SaveAssets();
sb.AppendLine(string.Format("APPLIED {0}행 (기존 {1}행 교체 · 총 {2}행)", rows.Count, removed, settings.swingCalibrations.Count));
}
else sb.AppendLine("(dry)");
}
finally
{
if (animMode) AnimationMode.StopAnimationMode();
EditorSceneManager.ClosePreviewScene(scene);
}
return sb.ToString();
}
/// <summary>[a, b] 구간에서 칼끝 속도 트림(런타임 MeasureSwingCalibration 과 같은 규칙).</summary>
static void Trim(Vector3[] tAll, int a, int b, float ratio, out int lo, out int hi)
{
lo = a; hi = b;
if (ratio <= 0f || b - a < 2) return;
float maxStep = 0f;
for (int i = a + 1; i <= b; i++) { float s = Vector3.Distance(tAll[i], tAll[i - 1]); if (s > maxStep) maxStep = s; }
float th = maxStep * ratio; int f = -1, l = -1;
for (int i = a + 1; i <= b; i++) if (Vector3.Distance(tAll[i], tAll[i - 1]) >= th) { if (f < 0) f = i - 1; l = i; }
if (f >= 0 && l > f) { lo = f; hi = l; }
}
static Vector3[] Slice(Vector3[] src, int from, int count) { var r = new Vector3[count]; for (int i = 0; i < count; i++) r[i] = src[from + i]; return r; }
class Fit { public Vector3 pivot, normal, bisector, startDir, stabAxis, stabStart; public float tipR, hiltR, sweep, path, bladeAng, dev, fitQ, chord, straight; public string pivotSrc; }
/// <summary>WL816t_Bake.Run 과 같은 수식 — 원 피팅 → 반경·편차 → 나쁘면 손잡이 중심 피벗 → 피벗 기준 재계산. 찌르기 축·직진도도 함께.</summary>
static Fit FitRange(Vector3[] tips, Vector3[] hilts, int n, SlashTrailSettings settings)
{
if (n < 3) return null;
float fullPath = 0f; Vector3 pd = (tips[0] - hilts[0]).normalized; float bladeAng = 0f;
int kMax = 0; float dMax = 0f;
for (int i = 1; i < n; i++)
{
fullPath += Vector3.Distance(tips[i], tips[i - 1]);
var d = (tips[i] - hilts[i]).normalized; bladeAng += Vector3.Angle(pd, d); pd = d;
float d2 = (tips[i] - tips[0]).sqrMagnitude; if (d2 > dMax) { dMax = d2; kMax = i; }
}
float chord = Mathf.Sqrt(dMax);
float pathToMax = 0f; for (int i = 1; i <= kMax; i++) pathToMax += Vector3.Distance(tips[i], tips[i - 1]);
float straight = pathToMax > 1e-5f ? chord / pathToMax : 0f;
var fit = SlashArcMeasure.FitArcOrdered(tips, n);
if (!fit.valid) return null;
Vector3 nrm = fit.normal, pivot = fit.center;
float tipR = SlashArcMeasure.MeanRadius(tips, n, pivot, nrm);
float hiltR = SlashArcMeasure.MeanRadius(hilts, n, pivot, nrm);
float dev = SlashArcMeasure.RadiusDeviation(tips, n, pivot, nrm, tipR);
string pivotSrc = "칼끝원";
if (tipR < 1e-3f || tipR <= hiltR * 1.15f || dev > tipR * Mathf.Max(settings.arcFitMaxDeviation, 0.01f))
{
Vector3 tc = SlashArcMeasure.CentroidOnPlane(tips, n, nrm, tips[0]);
pivot = SlashArcMeasure.CentroidOnPlane(hilts, n, nrm, tc);
tipR = SlashArcMeasure.MeanRadius(tips, n, pivot, nrm);
hiltR = SlashArcMeasure.MeanRadius(hilts, n, pivot, nrm);
pivotSrc = "손잡이중심";
}
Vector3 sd2, bis; float sweep;
if (!SlashArcMeasure.RecomputeAboutPivot(tips, n, pivot, ref nrm, out sd2, out bis, out sweep)) return null;
return new Fit
{
pivot = pivot, normal = nrm, bisector = bis, startDir = sd2, tipR = tipR, hiltR = hiltR, sweep = sweep,
path = fullPath, bladeAng = bladeAng, dev = dev, fitQ = Mathf.Clamp01(1f - (dev / Mathf.Max(tipR, 1e-3f)) * 2f), pivotSrc = pivotSrc,
chord = chord, straight = straight, stabAxis = kMax > 0 ? (tips[kMax] - tips[0]).normalized : Vector3.forward, stabStart = tips[0]
};
}
static string V(Vector3 v) { return string.Format("({0,6:F3},{1,6:F3},{2,6:F3})", v.x, v.y, v.z); }
static Vector3 Abs(Vector3 v) { return new Vector3(Mathf.Abs(v.x), Mathf.Abs(v.y), Mathf.Abs(v.z)); }
}