Project_WL/AgentScripts/WL828_TrailBake.cs

395 lines
28 KiB
C#
Raw Permalink Normal View History

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