// WL804_Bake.cs — #804 Attack3 검기 캘리브레이션을 **에디트 모드 결정적 샘플링**으로 베이크 (PD 결정 2026-09-07 "3타 휘두르기 · 무기 방향에 정합")
// unity command run_script --file AgentScripts/WL804_Bake.cs --entry WL804_Bake.BakeAll --args "[false]" (dry · 수치만)
// unity command run_script --file AgentScripts/WL804_Bake.cs --entry WL804_Bake.BakeAll --args "[true]" (SlashTrailSettings 에 기록)
// unity command run_script --file AgentScripts/WL804_Bake.cs --entry WL804_Bake.Bake --args "[10101, 7.0, 12.0, 60.0, false]"
//
// 왜 에디트 모드 베이크인가 (실측 2026-09-07)
// · Attack3 클립(Knight@Attack3_S · 32f)의 실제 스윙은 f7~f12(nt 0.22~0.38)에서 칼끝이 약 3 m 움직이는 **가슴 높이 수평 스윙**
// (오른쪽 뒤 → 앞 → 왼쪽 앞)이다 — WL804_TipProbe(에디트 30fps) 와 WL792_Verify.Trace3(런타임 60fps) 두 실측이 일치.
// · 그런데 런타임 MeasureSwingCalibration 은 Attack3 에서만 path 0.20~0.25 m · 샘플 6~9 개를 돌려준다(Attack1/2 는 5 m · 25~29 개).
// 원인은 미규명(【미확인】 — 슬롯 선택/샘플 거리 필터 의심). 이벤트 시각을 f10→f8.5 로 앞당겨도 같았다.
// · 배치는 (클래스,클립) 캘리브레이션 행을 이벤트 시점에 캐릭터 로컬 → 월드로 펼치는 구조라, 행만 정확하면 런타임 실측과 무관하게 정합된다.
// 그래서 같은 모델·무기·클립을 프리뷰 씬에서 60 Hz 로 샘플해 MeasureSwingCalibration 과 **같은 수식**(SlashArcMeasure)으로 행을 만든다.
// · quality 는 런타임 재실측(BakeCalibrations 의 "더 좋을 때만 교체")이 이 행을 덮지 않도록 크게 둔다(source 에 editbake 표기).
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using WL.Combat;
public static class WL804_Bake
{
const string ModelPath = "Assets/Res_Addr/PC/Ai01.prefab";
const string SettingsPath = "Assets/WL/Settings/Resources/WL/SlashTrailSettings.asset";
const string ClassConfigJson = "Assets/ResWork/Table/Export/ClassConfig.json";
const string Fbx3 = "Assets/Res_Addr/Animations/Animation/OneHand/Knight@Attack3_S.FBX";
const float BakedQuality = 50f; // 런타임 q 는 2~6 → 이 행이 항상 우선
public static object BakeAll(bool apply)
{
var sb = new StringBuilder();
foreach (var cid in new[] { 10101, 10102, 10501, 10502 })
sb.Append(Bake(cid, 7f, 12f, 60f, apply)).AppendLine();
return sb.ToString();
}
/// frame0~frame1 (30fps 프레임 단위 · 원 클립 기준) 을 hz 로 샘플해 캘리브레이션을 만든다.
public static object Bake(int classId, float frame0, float frame1, float hz, bool apply)
{
// ── 클래스 → 무기 (ClassConfig.json · 런타임 table_classconfig 은 에디트 모드에 없다)
string weaponName = null; int socketIndex = -1;
try
{
var arr = Newtonsoft.Json.Linq.JArray.Parse(System.IO.File.ReadAllText(ClassConfigJson));
foreach (var row in arr)
{
if ((string)row["n_ClassID"] != classId.ToString()) continue;
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"]); }
break;
}
}
catch (System.Exception ex) { return "ClassConfig parse fail: " + ex.Message; }
if (string.IsNullOrEmpty(weaponName) || socketIndex < 0) return "class " + classId + " weapon not found";
var model = AssetDatabase.LoadAssetAtPath(ModelPath);
var clips = AssetDatabase.LoadAllAssetsAtPath(Fbx3).OfType().Where(c => !c.name.StartsWith("__preview")).ToArray();
var clip = clips.FirstOrDefault(c => c.name == "Attack3_S_" + classId) ?? clips.FirstOrDefault(c => c.name == "Attack3");
var weapon = AssetDatabase.LoadAssetAtPath("Assets/Res_Addr/Weapon/" + weaponName + ".prefab");
var settings = AssetDatabase.LoadAssetAtPath(SettingsPath);
if (model == null || clip == null || weapon == null || settings == null)
return "load fail model=" + (model != null) + " clip=" + (clip != null) + " weapon=" + (weapon != null) + "(" + weaponName + ") settings=" + (settings != null);
var scene = EditorSceneManager.NewPreviewScene();
var sb = new StringBuilder();
bool animMode = false;
try
{
var go = (GameObject)PrefabUtility.InstantiatePrefab(model, scene);
go.transform.position = Vector3.zero; go.transform.rotation = Quaternion.identity;
var pc = go.GetComponentInChildren(true);
if (pc == null || pc.tfs_weapon == null || socketIndex >= pc.tfs_weapon.Length) return "tfs_weapon missing";
var socket = pc.tfs_weapon[socketIndex];
var w = (GameObject)PrefabUtility.InstantiatePrefab(weapon, scene);
w.transform.SetParent(socket, false);
w.transform.localPosition = Vector3.zero; w.transform.localRotation = Quaternion.identity; w.transform.localScale = Vector3.one;
// MeasureSlot 규칙과 동일 — 가장 긴 렌더러 바운딩 장축 양끝 · 소켓에 가까운 쪽 = 자루
Renderer best = null; Mesh bestMesh = null; float bestExtent = 0f;
foreach (var r in socket.GetComponentsInChildren(true))
{
var mf = r.GetComponent(); var smr = r as SkinnedMeshRenderer;
var mesh = smr != null ? smr.sharedMesh : (mf != null ? mf.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 "no 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);
var anim = go.GetComponentInChildren();
var animGo = anim.gameObject; anim.applyRootMotion = false;
var root = go.transform; // 런타임 ToLocalPos/Dir 의 기준 = 액터 루트(위치+회전)
float t0 = frame0 / 30f, t1 = frame1 / 30f;
int n = Mathf.Max(3, Mathf.RoundToInt((t1 - t0) * hz) + 1);
var tips = new Vector3[n]; var hilts = new Vector3[n];
AnimationMode.StartAnimationMode(); animMode = true;
for (int i = 0; i < n; i++)
{
float t = t0 + (t1 - t0) * i / (n - 1);
AnimationMode.BeginSampling();
AnimationMode.SampleAnimationClip(animGo, clip, t);
AnimationMode.EndSampling();
tips[i] = root.InverseTransformPoint(socket.TransformPoint(tipLocal));
hilts[i] = root.InverseTransformPoint(socket.TransformPoint(hiltLocal));
}
AnimationMode.StopAnimationMode(); animMode = false;
// ── MeasureSwingCalibration 과 같은 수식 ─────────────────────────────
float fullPath = 0f, hiltPath = 0f, bladeAng = 0f;
Vector3 prevDir = (tips[0] - hilts[0]).normalized;
for (int i = 1; i < n; i++)
{
fullPath += Vector3.Distance(tips[i], tips[i - 1]);
hiltPath += Vector3.Distance(hilts[i], hilts[i - 1]);
var d = (tips[i] - hilts[i]).normalized; bladeAng += Vector3.Angle(prevDir, d); prevDir = d;
}
float tipPerHilt = fullPath / Mathf.Max(hiltPath, 1e-3f);
int kMax = 0; float dMax = 0f;
for (int i = 1; i < n; i++) { 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;
bool swingShape = bladeAng >= settings.swingMinSweepDegrees || tipPerHilt >= settings.swingMinTipHiltRatio;
bool stabShape = chord >= settings.stabMinDistance && straight >= settings.stabMinStraightness;
var kind = swingShape ? SwingKind.Swing : (stabShape ? SwingKind.Stab : SwingKind.Swing);
var fit = SlashArcMeasure.FitArcOrdered(tips, n);
sb.AppendLine(string.Format("== class {0} weapon {1}@{2} clip {3} f{4}~f{5} @{6}Hz n={7} · path={8:F2} hiltPath={9:F2} tip/hilt={10:F2} bladeAng={11:F0}° chord={12:F2} straight={13:F2} → kind={14} · fit.valid={15} sweep={16:F1}° r={17:F3}",
classId, weaponName, socket.name, clip.name, frame0, frame1, hz, n, fullPath, hiltPath, tipPerHilt, bladeAng, chord, straight, kind, fit.valid, fit.sweepDeg, fit.radiusOuter));
if (!fit.valid) return sb.Append("!! 원 피팅 실패").ToString();
Vector3 nrm = fit.normal; Vector3 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 = "칼끝원";
bool bad = tipR < 1e-3f || tipR <= hiltR * 1.15f || dev > tipR * 0.25f;
if (bad)
{
Vector3 tipCentroid = SlashArcMeasure.CentroidOnPlane(tips, n, nrm, tips[0]);
pivot = SlashArcMeasure.CentroidOnPlane(hilts, n, nrm, tipCentroid);
tipR = SlashArcMeasure.MeanRadius(tips, n, pivot, nrm);
hiltR = SlashArcMeasure.MeanRadius(hilts, n, pivot, nrm);
pivotSrc = "손잡이중심";
}
Vector3 sd, bis; float sweep;
if (!SlashArcMeasure.RecomputeAboutPivot(tips, n, pivot, ref nrm, out sd, out bis, out sweep))
return sb.Append("!! RecomputeAboutPivot 실패").ToString();
float fitQ = Mathf.Clamp01(1f - (dev / Mathf.Max(tipR, 1e-3f)) * 2f);
float q = fullPath * Mathf.Max(fitQ, 0.05f);
sb.AppendLine(string.Format(" pivot({0})={1} tipR={2:F3} hiltR={3:F3} dev={4:F3} fitQ={5:F2} q={6:F2} · normal={7} bisector={8} startDir={9} sweep={10:F1}°{11}",
pivotSrc, V(pivot), tipR, hiltR, dev, fitQ, q, V(nrm), V(bis), V(sd), sweep, sweep < settings.swingMinSweepDegrees ? " (⚠ 런타임이면 칼날평면 폴백 구간 — 베이크는 원 피팅 그대로 기록)" : ""));
sb.AppendLine(" tips: " + string.Join(" ", tips.Select(V)));
if (!apply) return sb.Append(" (dry)").ToString();
var cal = new SwingCalibration
{
classId = classId, clipName = "Effect_Slash_" + classId + "_3", kind = SwingKind.Swing,
planeNormal = nrm, bisector = bis, startDir = sd, pivot = pivot, tipRadius = tipR, hiltRadius = hiltR, sweepDeg = sweep,
stabAxis = (tips[kMax] - tips[0]).normalized, stabStart = tips[0], stabLength = Mathf.Max(chord, 1e-3f),
quality = Mathf.Max(BakedQuality, q),
source = string.Format("editbake {0:yyyy-MM-dd HH:mm} {1} f{2}~f{3}@{4}Hz n={5} path={6:F2} bladeAng={7:F0}° sweep={8:F1}° pivot={9} tipR={10:F3} hiltR={11:F3} dev={12:F3} fitQ={13:F2} q={14:F2} (WL804_Bake · 런타임 재실측이 덮지 않도록 quality 고정)",
System.DateTime.Now, clip.name, frame0, frame1, hz, n, fullPath, bladeAng, sweep, pivotSrc, tipR, hiltR, dev, fitQ, q)
};
int removed = settings.swingCalibrations.RemoveAll(c => c != null && c.classId == classId && c.clipName == cal.clipName);
settings.swingCalibrations.Add(cal);
EditorUtility.SetDirty(settings);
AssetDatabase.SaveAssets();
sb.AppendLine(" APPLIED → " + cal.clipName + " (기존 " + removed + "행 교체 · 총 " + settings.swingCalibrations.Count + "행)");
}
finally
{
if (animMode) AnimationMode.StopAnimationMode();
EditorSceneManager.ClosePreviewScene(scene);
}
return sb.ToString();
}
static string V(Vector3 v) { return string.Format("({0:F2},{1:F2},{2:F2})", 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)); }
}