// 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 effectTimes; public List 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); } /// #841 한 타만 굽는다(stage 1~4 · 0 = 전부). 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; /// #841 창을 60 Hz 샘플 번호로 직접 지정해 한 타만 굽는다(트림 없음) — 검 1타처럼 자동 창이 감아올리기 구간을 잡을 때. 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; } } /// #841 한 타의 60 Hz 칼끝 샘플(위치·속도)을 전부 출력 — 창 고르기용(dry). 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(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(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(pcPath); var weaponPrefab = AssetDatabase.LoadAssetAtPath("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(); 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(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(); if (fitter == null) fitter = go.AddComponent(); 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(true)) { var mf2 = r.GetComponent(); 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(); 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(); } /// [a, b] 구간에서 칼끝 속도 트림(런타임 MeasureSwingCalibration 과 같은 규칙). 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; } /// WL816t_Bake.Run 과 같은 수식 — 원 피팅 → 반경·편차 → 나쁘면 손잡이 중심 피벗 → 피벗 기준 재계산. 찌르기 축·직진도도 함께. 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)); } }