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