Editor/WeaponImporter/Core/Analysis/WeaponAnalyzer.cs
#nullable enable annotations
using System.Numerics;
using WeaponImporter.Core.Geometry;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Rig;
using WeaponImporter.Core.Weapon;
namespace WeaponImporter.Core.Analysis;
using Vector3 = System.Numerics.Vector3;
/// <summary>
/// Inspects a <see cref="WeaponAsset"/>: separates the weapon from any first-person arms,
/// measures part motion, finds the bore, muzzle and ejection port, classifies the weapon and
/// locates grip surfaces on the actual geometry. Every result carries a confidence so the
/// editor only asks about the uncertain ones.
/// </summary>
public static class WeaponAnalyzer
{
private static readonly AsyncLocal<Action<string>?> _trace = new();
/// <summary>Optional diagnostics sink (tests and the editor log); flows with the current async context.</summary>
public static Action<string>? Trace
{
get => _trace.Value;
set => _trace.Value = value;
}
private static readonly string[] ArmTokens =
{
"arm", "arms", "hand", "hands", "finger", "thumb", "index", "middle", "ring", "pinky", "little", "palm",
"wrist", "elbow", "forearm", "upperarm", "clavicle", "shoulder", "camera", "cam", "spine", "neck", "head",
"glove", "gloves", "sleeve", "watch", "body", "skin", "l", "r", "left", "right", "ik", "pole",
};
// Tokens that on their own identify an arm bone; "l"/"r"/"left" only count alongside these.
private static readonly string[] StrongArmTokens =
{
"arm", "arms", "hand", "hands", "finger", "thumb", "palm", "wrist", "elbow", "forearm", "upperarm", "clavicle",
"shoulder", "camera", "cam", "spine", "neck", "head", "glove", "gloves", "sleeve", "watch", "pinky",
};
private static readonly (PartKind Kind, string[] Aliases, string[]? Joined)[] PartNames =
{
(PartKind.Magazine, new[] { "mag", "magazine", "clip", "ammo", "drum", "cartridge", "mags" }, new[] { "magazine", "magwell" }),
(PartKind.ChargingHandle, new[] { "charging", "charginghandle", "ch", "cockinghandle", "cocking" }, new[] { "charginghandle", "chargehandle", "cockinghandle" }),
(PartKind.Slide, new[] { "slide", "slider" }, null),
(PartKind.Bolt, new[] { "bolt", "carrier", "breech", "boltcarrier" }, new[] { "boltcarrier" }),
(PartKind.Trigger, new[] { "trigger", "trig" }, null),
(PartKind.Hammer, new[] { "hammer", "striker" }, null),
(PartKind.Cylinder, new[] { "cylinder", "cyl", "revolver_drum" }, null),
(PartKind.Pump, new[] { "pump", "forend", "foreend", "slidehandle" }, null),
(PartKind.Foregrip, new[] { "foregrip", "vfg", "vertgrip", "frontgrip", "handstop", "angledgrip" }, new[] { "foregrip", "frontgrip", "verticalgrip" }),
(PartKind.Stock, new[] { "stock", "butt", "buttstock" }, null),
(PartKind.Scope, new[] { "scope", "optic", "sight", "reddot", "holo", "acog" }, null),
(PartKind.Barrel, new[] { "barrel" }, null),
};
public static WeaponAnalysis Analyze(WeaponAsset asset, CancellationToken cancel = default)
=> Analyze(asset, new AnalyzeOptions(), cancel);
public static WeaponAnalysis Analyze(WeaponAsset source, AnalyzeOptions options, CancellationToken cancel = default)
{
ArgumentNullException.ThrowIfNull(source);
options ??= new AnalyzeOptions();
// 0. Analyze the assembled weapon: the idle (or first) clip's first frame, not the bind pose.
var loaded = source;
var (reference, referenceFrame) = ReferencePose(source, options.ReferenceClip);
if (reference is not null)
{
// First-person clips move the whole weapon around the camera; put the weapon root
// back where it rests so the assembled weapon keeps its modelled orientation.
var bindArms = FindArmBones(loaded.Skeleton);
var bindTris = WeaponTriangles(loaded, bindArms, out _);
var bindRoot = bindTris.Length > 0 ? WeaponRoot(loaded, bindArms, bindTris) : 0;
var posed = source.WithReferencePose(reference, referenceFrame);
var back = XForm.Compose(loaded.Skeleton.RestWorld[bindRoot], posed.Skeleton.RestWorld[bindRoot].Inverse());
source = posed.Moved(back);
}
// 1. Find the weapon inside the file (drop arms, backgrounds, helper geometry).
var sourceArms = FindArmBones(source.Skeleton);
var sourceTris = WeaponTriangles(source, sourceArms, out var dropped);
if (sourceTris.Length == 0)
sourceTris = Enumerable.Range(0, source.Mesh.TriangleCount).ToArray();
sourceTris = DropSpareMagazines(source, sourceTris, dropped);
// 2. Bring it into canonical space: muzzle +X, up +Z, realistic size.
var sourceBounds = TriangleBounds(source.Mesh, sourceTris);
var orientation = GuessOrientation(source.Mesh, sourceTris, sourceBounds, OrientationHints(source, sourceTris));
var rotation = options.Rotation ?? (orientation.Confidence >= 0.45f ? orientation.ToCanonical : Quaternion.Identity);
var rotatedLength = RotatedLength(source.Mesh, sourceTris, rotation);
var (scale, scaleReason) = options.Scale is { } forced
? (forced, "set manually")
: GuessScale(rotatedLength, WeaponClassifier.FromNames(source));
var asset = MathF.Abs(scale - 1f) < 1e-6f && MathF.Abs(rotation.W) > 0.99999f ? source : source.Transformed(rotation, scale);
// 3. Centre the weapon on its own bounds: first-person rigs park weapons far from the
// origin (at camera height), which would give the world model a useless pivot.
var centre = TriangleBounds(asset.Mesh, sourceTris).Center;
if (centre.Length() > 1e-3f)
asset = asset.Moved(new XForm(-centre, Quaternion.Identity));
var skeleton = asset.Skeleton;
var mesh = asset.Mesh;
var armBones = FindArmBones(skeleton);
var weaponTris = sourceTris;
var bounds = TriangleBounds(mesh, weaponTris);
var root = WeaponRoot(asset, armBones, weaponTris);
var motion = MeasureMotion(asset, armBones, cancel);
var profile = ShapeProfile.Build(mesh, weaponTris);
var (boreStart, boreEnd, boreDiameter) = FindBore(profile, bounds);
var analysis = new WeaponAnalysis
{
Source = loaded,
ReferenceClip = reference?.Name ?? "",
ReferenceFrame = referenceFrame,
Asset = asset,
ModelToCanonical = rotation,
CanonicalOffset = -centre,
Scale = scale,
ScaleReason = scaleReason,
RootBone = root,
ArmBones = armBones,
WeaponTriangles = weaponTris,
WeaponBounds = bounds,
Profile = profile,
Orientation = orientation,
Motion = motion,
BoreStart = boreStart,
BoreEnd = boreEnd,
BoreDiameter = boreDiameter,
};
if (armBones.Count > 0)
analysis.Notes.Add($"First-person arms found ({armBones.Count} bones); they are excluded from the weapon.");
foreach (var d in dropped)
analysis.Notes.Add($"Ignored '{d}' (not part of the weapon).");
if (orientation.NeedsFix && options.Rotation is null && orientation.Confidence >= 0.45f)
analysis.Notes.Add($"Weapon was turned to face forward ({orientation.Reason}).");
if (MathF.Abs(scale - 1f) > 1e-3f)
analysis.Notes.Add($"Weapon scaled by {scale:0.####} ({scaleReason}).");
cancel.ThrowIfCancellationRequested();
DetectParts(analysis);
analysis.Type = WeaponClassifier.Classify(analysis);
analysis.Muzzle = DetectMuzzle(analysis);
analysis.Eject = DetectEject(analysis);
ClassifyAnimations(analysis);
cancel.ThrowIfCancellationRequested();
GripFinder.FindGrips(analysis);
return analysis;
}
/// <summary>The clip whose first frame shows the weapon assembled: idle, else the first clip.</summary>
public static Clip? ReferenceClip(WeaponAsset asset, string? preferred = null) => ReferencePose(asset, preferred).Clip;
/// <summary>
/// Picks the clip frame that shows the weapon assembled. Idle frame 0 is preferred, but some
/// exports leave magazines unkeyed in idle (they only look right after a reload), so the
/// frame where named magazines sit closest to the rest of the weapon wins.
/// </summary>
public static (Clip? Clip, int Frame) ReferencePose(WeaponAsset asset, string? preferred = null)
{
if (asset.Clips.Count == 0)
return (null, 0);
if (!string.IsNullOrEmpty(preferred) && asset.FindClip(preferred) is { } chosen)
return (chosen, 0);
var guesses = asset.Clips.Select(c => (Clip: c, Guess: AnimationClassifier.Classify(c.Name))).ToList();
var candidates = new List<(Clip Clip, int Frame)>();
void Add(AnimationRole role, bool last)
{
foreach (var g in guesses.Where(g => g.Guess.Role == role).OrderByDescending(g => g.Guess.Confidence))
candidates.Add((g.Clip, last ? g.Clip.FrameCount - 1 : 0));
}
Add(AnimationRole.Idle, false);
Add(AnimationRole.Reload, true);
Add(AnimationRole.EmptyReload, true);
Add(AnimationRole.TacticalReload, true);
Add(AnimationRole.Fire, false);
Add(AnimationRole.Draw, true);
candidates.Add((asset.Clips[0], 0));
var magBones = new HashSet<int>();
for (var b = 0; b < asset.Skeleton.Count; b++)
{
if (IsMagazineName(asset.Skeleton[b].Name))
magBones.Add(b);
}
if (magBones.Count == 0)
return candidates[0];
var arms = FindArmBones(asset.Skeleton);
var best = candidates[0];
var bestGap = float.MaxValue;
foreach (var candidate in candidates.Distinct())
{
var posed = asset.WithReferencePose(candidate.Clip, candidate.Frame);
var mesh = posed.Mesh;
var body = new List<int>();
var mags = new Dictionary<int, List<int>>();
for (var t = 0; t < mesh.TriangleCount; t++)
{
var bone = mesh.TriangleBone(t);
if (bone < 0 || arms.Contains(bone))
continue;
if (magBones.Contains(bone))
{
if (!mags.TryGetValue(bone, out var list))
mags[bone] = list = new List<int>();
list.Add(t);
}
else
{
body.Add(t);
}
}
if (mags.Count == 0 || body.Count == 0)
return candidates[0];
// An inserted magazine touches the receiver: the closest magazine vertex lies on (or
// inside) the body surface. Spares may stay parked, so the best magazine decides.
var bvh = new MeshBvh(mesh, body);
var gap = float.MaxValue;
foreach (var tris in mags.Values)
{
var step = Math.Max(1, tris.Count / 150);
for (var i = 0; i < tris.Count; i += step)
{
var (x, _, _) = mesh.Triangle(tris[i]);
var closest = bvh.Closest(x, gap);
if (closest is { } c)
gap = MathF.Min(gap, c.Inside ? 0f : c.Distance);
}
}
Trace?.Invoke($"reference candidate {candidate.Clip.Name}@{candidate.Frame}: magazine gap {gap:0.###}");
if (gap < bestGap - 1e-3f)
{
bestGap = gap;
best = candidate;
}
if (bestGap <= 0.01f)
break;
}
return best;
}
private static Bounds TriangleBounds(TriMesh mesh, IEnumerable<int> tris)
{
var b = Bounds.Empty;
foreach (var t in tris)
{
var (x, y, z) = mesh.Triangle(t);
b = b.Encapsulate(x).Encapsulate(y).Encapsulate(z);
}
return b;
}
private static float RotatedLength(TriMesh mesh, int[] tris, Quaternion rotation)
{
float min = float.MaxValue, max = float.MinValue;
foreach (var t in tris)
for (var k = 0; k < 3; k++)
{
var x = Vector3.Transform(mesh.Positions[mesh.Indices[t * 3 + k]], rotation).X;
min = MathF.Min(min, x);
max = MathF.Max(max, x);
}
return max > min ? max - min : 0f;
}
private static readonly float[] CleanScales = { 1f, 0.1f, 0.01f, 0.001f, 10f, 100f, 1f / 2.54f, 2.54f, 0.254f, 0.0254f, 39.3701f, 0.393701f, 0.0393701f, 0.00393701f };
/// <summary>
/// Picks a uniform scale that gives the weapon a believable length. Unit mix-ups (metres
/// vs centimetres vs inches) are preferred over arbitrary factors.
/// </summary>
public static (float Scale, string Reason) GuessScale(float length, WeaponType? nameType)
{
if (!(length > 1e-4f))
return (1f, "no measurable length");
var (min, max) = nameType is { } t ? WeaponTypes.TypicalLength(t) : (5f, 60f);
if (length >= min * 0.6f && length <= max * 1.5f)
return (1f, "size looks right");
var mid = MathF.Sqrt(min * max);
var best = CleanScales
.Where(s => length * s >= min * 0.75f && length * s <= max * 1.3f)
.OrderBy(s => MathF.Abs(MathF.Log(length * s / mid)))
.Cast<float?>()
.FirstOrDefault();
if (best is { } clean)
return (clean, $"{length:0.#} in long before, {length * clean:0.#} in after (unit mix-up)");
var factor = mid / length;
return (factor, $"{length:0.#} in long; rescaled to a typical {(nameType is { } nt ? WeaponTypes.Label(nt).ToLowerInvariant() : "weapon")} length");
}
/// <summary>
/// Reload rigs often carry a second magazine parked somewhere. Only magazines that touch the
/// rest of the weapon are part of it.
/// </summary>
private static int[] DropSpareMagazines(WeaponAsset asset, int[] tris, List<string> dropped)
{
var mesh = asset.Mesh;
var groups = new Dictionary<int, List<int>>();
var body = new List<int>();
foreach (var t in tris)
{
var bone = mesh.TriangleBone(t);
if (bone >= 0 && IsMagazineName(asset.Skeleton[bone].Name))
{
if (!groups.TryGetValue(bone, out var list))
groups[bone] = list = new List<int>();
list.Add(t);
}
else
{
body.Add(t);
}
}
if (groups.Count < 2 || body.Count == 0)
return tris;
var bvh = new MeshBvh(mesh, body);
var gaps = groups.ToDictionary(g => g.Key, g =>
{
var gap = float.MaxValue;
var step = Math.Max(1, g.Value.Count / 200);
for (var i = 0; i < g.Value.Count; i += step)
{
var (x, _, _) = mesh.Triangle(g.Value[i]);
if (bvh.Closest(x, gap) is { } c)
gap = MathF.Min(gap, c.Inside ? 0f : c.Distance);
}
return gap;
});
var bestGap = gaps.Values.Min();
var size = TriangleBounds(mesh, body).Size.Length();
var spares = gaps.Where(g => g.Value > bestGap + size * 0.01f).Select(g => g.Key).ToHashSet();
if (spares.Count == 0 || spares.Count == groups.Count)
return tris;
foreach (var s in spares)
dropped.Add($"{asset.Skeleton[s].Name} (spare magazine)");
return tris.Where(t => !spares.Contains(mesh.TriangleBone(t))).ToArray();
}
/// <summary>"mag", "Magazine_L", "pmag2", "UZI Mag", "clipMag" — but not "magnum" or "image".</summary>
public static bool IsMagazineName(string name)
{
var tokens = NameTokens.Split(name);
if (NameTokens.Has(tokens, "mag", "magazine", "mags", "drum"))
return true;
foreach (var t in tokens)
{
var i = t.IndexOf("mag", StringComparison.Ordinal);
if (i < 0 || t.Contains("magnum") || t.Contains("image") || t.Contains("magic"))
continue;
// Allow short prefixes/suffixes around "mag" (pmag, mag2, emag, magl).
if (t.Length - 3 <= 3)
return true;
}
return false;
}
// ---------------------------------------------------------------- arms
public static bool IsArmBoneName(string name)
{
var tokens = NameTokens.Split(name);
if (NameTokens.Has(tokens, StrongArmTokens))
return true;
var joined = NameTokens.Joined(tokens);
return joined.Contains("upperarm") || joined.Contains("forearm") || joined.Contains("lowerarm")
|| (tokens.Length >= 2 && NameTokens.Has(tokens, "index", "middle", "ring", "little") && NameTokens.Has(tokens, "l", "r", "left", "right", "0", "1", "2", "3", "01", "02", "03"));
}
private static HashSet<int> FindArmBones(Skeleton skeleton)
{
var arms = new HashSet<int>();
for (var i = 0; i < skeleton.Count; i++)
{
var parent = skeleton[i].ParentIndex;
if (IsArmBoneName(skeleton[i].Name) || (parent >= 0 && arms.Contains(parent) && !IsWeaponName(skeleton[i].Name)))
arms.Add(i);
}
// A skeleton that is "all arms" is really a mislabelled weapon; keep it.
return arms.Count >= skeleton.Count ? new HashSet<int>() : arms;
}
private static bool IsWeaponName(string name)
{
var tokens = NameTokens.Split(name);
return NameTokens.Has(tokens, "weapon", "gun", "wpn", "rifle", "pistol") || PartNames.Any(p => NameTokens.Has(tokens, p.Aliases));
}
private static readonly string[] NonWeaponParts =
{
"arm", "arms", "hand", "hands", "glove", "gloves", "sleeve", "watch", "body", "skin", "cloth", "cloths", "character",
"fpsarms", "face", "head", "eye", "eyes", "hair", "teeth", "tongue", "background", "backdrop", "floor", "ground",
"shadow", "camera", "light", "lamp", "sky", "skybox", "aim", "aimbottom", "crosshair", "collision", "collider", "ucx", "ubx",
};
private static int[] WeaponTriangles(WeaponAsset asset, HashSet<int> armBones, out List<string> dropped)
{
var mesh = asset.Mesh;
dropped = new List<string>();
var excludedParts = new HashSet<int>();
for (var p = 0; p < mesh.PartNames.Count; p++)
{
var tokens = NameTokens.Split(mesh.PartNames[p]);
if (NameTokens.Has(tokens, NonWeaponParts))
excludedParts.Add(p);
}
// Groups are (mesh part, bone) so a spare magazine inside one skinned mesh can be dropped.
var byPart = new Dictionary<(int Part, int Bone), List<int>>();
for (var t = 0; t < mesh.TriangleCount; t++)
{
var part = mesh.TrianglePart[t];
if (excludedParts.Contains(part))
continue;
var bone = mesh.TriangleBone(t);
if (bone >= 0 && armBones.Contains(bone))
continue;
if (!byPart.TryGetValue((part, bone), out var list))
byPart[(part, bone)] = list = new List<int>();
list.Add(t);
}
foreach (var p in excludedParts)
if (mesh.TrianglePart.Contains(p))
dropped.Add(mesh.PartNames[p]);
if (byPart.Count == 0)
return Array.Empty<int>();
// Grow a cluster from the biggest group; groups far away or giant flat cards are scenery.
var partBounds = byPart.ToDictionary(kv => kv.Key, kv => TriangleBounds(mesh, kv.Value));
var main = byPart.OrderByDescending(kv => kv.Value.Count).First().Key;
var mainCount = byPart[main].Count;
var cluster = new HashSet<(int Part, int Bone)> { main };
var clusterBounds = partBounds[main];
var changed = true;
while (changed)
{
changed = false;
var diag = clusterBounds.Size.Length();
foreach (var (part, b) in partBounds)
{
if (cluster.Contains(part))
continue;
var size = b.Size;
// Backdrops: a handful of triangles, flat, and much bigger than the weapon.
var thinnest = MathF.Min(size.X, MathF.Min(size.Y, size.Z));
var widest = MathF.Max(size.X, MathF.Max(size.Y, size.Z));
var flatCard = byPart[part].Count <= 6 && thinnest < widest * 0.02f && size.Length() > diag * 1.5f;
if (flatCard)
continue;
var gap = Vector3.Max(Vector3.Zero, Vector3.Max(clusterBounds.Min - b.Max, b.Min - clusterBounds.Max)).Length();
// Small detached pieces (a parked spare magazine) must touch the weapon.
var small = byPart[part].Count < mainCount * 0.25f;
if (gap <= diag * (small ? 0.06f : 0.35f) && size.Length() < diag * 3f)
{
cluster.Add(part);
clusterBounds = clusterBounds.Encapsulate(b);
changed = true;
}
}
}
foreach (var key in byPart.Keys.Where(p => !cluster.Contains(p)))
dropped.Add(key.Bone >= 0 ? $"{mesh.PartNames[key.Part]} / {asset.Skeleton[key.Bone].Name}" : mesh.PartNames[key.Part]);
return byPart.Where(kv => cluster.Contains(kv.Key)).SelectMany(kv => kv.Value).OrderBy(t => t).ToArray();
}
private static int WeaponRoot(WeaponAsset asset, HashSet<int> armBones, int[] weaponTris)
{
var skeleton = asset.Skeleton;
var counts = new Dictionary<int, int>();
foreach (var t in weaponTris)
{
var b = asset.Mesh.TriangleBone(t);
if (b >= 0)
counts[b] = counts.GetValueOrDefault(b) + 1;
}
if (counts.Count == 0)
return 0;
var dominant = counts.OrderByDescending(kv => kv.Value).First().Key;
// Climb while the parent is still a weapon bone, so the root is the weapon's own base
// (not a magazine that happens to be densely tessellated).
var bone = dominant;
while (skeleton[bone].ParentIndex >= 0 && !armBones.Contains(skeleton[bone].ParentIndex))
{
var parent = skeleton[bone].ParentIndex;
var name = NameTokens.Split(skeleton[parent].Name);
if (NameTokens.Has(name, "root", "scene", "armature", "rootnode") && skeleton[parent].ParentIndex < 0 && counts.GetValueOrDefault(parent) == 0)
break;
bone = parent;
}
return bone;
}
// ---------------------------------------------------------------- motion
private static List<BoneMotion> MeasureMotion(WeaponAsset asset, HashSet<int> armBones, CancellationToken cancel)
{
var skeleton = asset.Skeleton;
var result = new List<BoneMotion>();
for (var b = 0; b < skeleton.Count; b++)
{
if (armBones.Contains(b) || skeleton[b].ParentIndex < 0)
continue;
var rest = skeleton[b].RestLocal;
var parentWorld = skeleton.RestWorld[skeleton[b].ParentIndex];
float maxT = 0f, maxR = 0f;
var dir = Vector3.Zero;
string clipName = "";
var peakFrame = 0;
foreach (var clip in asset.Clips)
{
cancel.ThrowIfCancellationRequested();
for (var f = 0; f < clip.FrameCount; f++)
{
var local = clip.Frames[f][b];
var d = local.Pos - rest.Pos;
var t = d.Length();
var r = MathQ.AngleBetween(local.Rot, rest.Rot) * 180f / MathF.PI;
if (t > maxT)
{
maxT = t;
// Direction in model space (through the parent's rest rotation).
dir = t > 1e-5f ? Vector3.Normalize(parentWorld.TransformVector(d)) : Vector3.Zero;
clipName = clip.Name;
peakFrame = f;
}
if (r > maxR)
{
maxR = r;
if (maxT < 1e-3f)
{
clipName = clip.Name;
peakFrame = f;
}
}
}
}
result.Add(new BoneMotion(b, maxT, maxR, dir, clipName, peakFrame));
}
return result;
}
// ---------------------------------------------------------------- orientation
/// <summary>
/// Weapons are longest along their barrel, and grips/magazines hang below the bore, so the
/// longest axis is forward and the side with sparse coverage is down. The muzzle end is the
/// thinner end.
/// </summary>
/// <summary>
/// Named parts that tell up from down: triggers and grips hang below the bore, scopes and
/// sights sit on top. Weight says how reliable each cue is.
/// </summary>
public static List<(Vector3 Point, float Side, float Weight)> OrientationHints(WeaponAsset asset, int[] tris)
{
var hints = new List<(Vector3, float, float)>();
var mesh = asset.Mesh;
var byBone = new Dictionary<int, List<int>>();
var byPart = new Dictionary<int, List<int>>();
foreach (var t in tris)
{
var b = mesh.TriangleBone(t);
if (b >= 0)
{
if (!byBone.TryGetValue(b, out var l)) byBone[b] = l = new List<int>();
l.Add(t);
}
var part = mesh.TrianglePart[t];
if (!byPart.TryGetValue(part, out var pl)) byPart[part] = pl = new List<int>();
pl.Add(t);
}
void Consider(string name, List<int> group)
{
var tokens = NameTokens.Split(name);
if (NameTokens.Has(tokens, "trigger", "trig", "triggerguard"))
hints.Add((Centroid(mesh, group), -1f, 2f));
else if (NameTokens.Has(tokens, "grip", "handle", "pistolgrip") && !NameTokens.Has(tokens, "charging", "cocking"))
hints.Add((Centroid(mesh, group), -1f, 1f));
else if (NameTokens.Has(tokens, "scope", "optic", "sight", "reddot", "holo", "acog", "rail"))
hints.Add((Centroid(mesh, group), 1f, 1f));
}
foreach (var (bone, group) in byBone)
Consider(asset.Skeleton[bone].Name, group);
foreach (var (part, group) in byPart)
Consider(mesh.PartNames[part], group);
return hints;
}
public static OrientationGuess GuessOrientation(TriMesh mesh, int[] tris, Bounds bounds, IReadOnlyList<(Vector3 Point, float Side, float Weight)>? hints = null)
{
// Area-weighted samples.
var points = new List<Vector3>();
var weights = new List<float>();
foreach (var t in tris)
{
var (a, b, c) = mesh.Triangle(t);
var area = Vector3.Cross(b - a, c - a).Length() * 0.5f + 1e-6f;
points.Add(a); points.Add(b); points.Add(c); points.Add((a + b + c) / 3f);
weights.Add(area); weights.Add(area); weights.Add(area); weights.Add(area * 3f);
}
if (points.Count < 4)
return new OrientationGuess(Quaternion.Identity, 0.1f, "not enough geometry");
// Principal axes; snapped to the file axes when close (most weapons are modelled axis aligned).
var (axes, extents) = PrincipalAxes(points);
static Vector3 Snap(Vector3 v)
{
var abs = Vector3.Abs(v);
var axis = abs.X >= abs.Y && abs.X >= abs.Z ? new Vector3(MathF.Sign(v.X), 0, 0) : abs.Y >= abs.Z ? new Vector3(0, MathF.Sign(v.Y), 0) : new Vector3(0, 0, MathF.Sign(v.Z));
return Vector3.Dot(axis, v) > MathF.Cos(35f * MathF.PI / 180f) ? axis : v;
}
var forward = Snap(axes[0]);
var upAxis = Snap(Vector3.Normalize(axes[1] - forward * Vector3.Dot(axes[1], forward)));
upAxis = Vector3.Normalize(upAxis - forward * Vector3.Dot(upAxis, forward));
float Along(Vector3 p) => Vector3.Dot(p, forward);
float Up(Vector3 p) => Vector3.Dot(p, upAxis);
var min = points.Min(Along);
var len = MathF.Max(1e-4f, points.Max(Along) - min);
var side = Vector3.Cross(upAxis, forward);
var widthExtent = MathF.Max(1e-4f, points.Max(p => Up(p)) - points.Min(p => Up(p)));
if (len < widthExtent * 1.25f)
return CompactOrientation(points, hints);
// Thin end = muzzle: compare cross-section spread in the first and last 12%.
float Spread(Func<float, bool> inSlice)
{
var slice = points.Where(p => inSlice((Along(p) - min) / len)).ToList();
if (slice.Count == 0)
return 0f;
return (slice.Max(Up) - slice.Min(Up)) + (slice.Max(p => Vector3.Dot(p, side)) - slice.Min(p => Vector3.Dot(p, side)));
}
var lowEnd = Spread(u => u < 0.12f);
var highEnd = Spread(u => u > 0.88f);
var forwardSign = highEnd <= lowEnd ? 1f : -1f;
var endConfidence = MathF.Abs(highEnd - lowEnd) / MathF.Max(1e-3f, MathF.Max(highEnd, lowEnd));
// Up: the half with sparse lengthwise coverage (grip, magazine) is down.
var upMin = points.Min(Up);
var upLen = MathF.Max(1e-3f, points.Max(Up) - upMin);
float Coverage(bool top)
{
var bins = new HashSet<int>();
foreach (var p in points)
{
var u = (Up(p) - upMin) / upLen;
if (top ? u > 0.7f : u < 0.3f)
bins.Add((int)((Along(p) - min) / len * 40f));
}
return bins.Count / 41f;
}
var topCover = Coverage(true);
var bottomCover = Coverage(false);
var vote = (topCover - bottomCover) * 1.5f;
// Named parts compare against the bore line at the muzzle end.
if (hints is { Count: > 0 })
{
var front = points.Where(p => forwardSign > 0 ? (Along(p) - min) / len > 0.9f : (Along(p) - min) / len < 0.1f).Select(Up).OrderBy(v => v).ToList();
var boreUp = front.Count > 0 ? front[front.Count / 2] : (upMin + upLen * 0.5f);
foreach (var (point, sideHint, weight) in hints)
{
var d = Up(point) - boreUp;
if (MathF.Abs(d) < upLen * 0.03f)
continue;
vote += MathF.Sign(d) * sideHint * weight;
}
}
var upSign = vote >= 0f ? 1f : -1f;
var upConfidence = Math.Clamp(MathF.Abs(vote) / 2f, 0f, 1f);
var f = forward * forwardSign;
var u2 = upAxis * upSign;
var from = MathQ.FromAxes(f, Vector3.Cross(u2, f));
var toCanonical = MathQ.Normalize(Quaternion.Conjugate(from));
var confidence = Math.Clamp(0.35f + endConfidence * 0.4f + upConfidence * 0.6f, 0f, 1f);
var reason = $"forward {Describe(f)}, up {Describe(u2)}";
return new OrientationGuess(toCanonical, confidence, reason);
}
/// <summary>
/// Compact weapons (stubby pistols, cartoon props) aren't clearly longest along the barrel.
/// Up is trusted from the file (+Z after axis conversion); forward is the horizontal axis whose
/// rear carries the hanging grip and whose front is the thinner muzzle end.
/// </summary>
private static OrientationGuess CompactOrientation(List<Vector3> points, IReadOnlyList<(Vector3 Point, float Side, float Weight)>? hints)
{
var minZ = points.Min(p => p.Z);
var maxZ = points.Max(p => p.Z);
var height = MathF.Max(1e-4f, maxZ - minZ);
var low = points.Where(p => p.Z < minZ + height * 0.3f).ToList();
var best = Vector3.UnitX;
var bestScore = float.MinValue;
var scores = new List<float>();
foreach (var f in new[] { Vector3.UnitX, -Vector3.UnitX, Vector3.UnitY, -Vector3.UnitY })
{
float Along(Vector3 p) => Vector3.Dot(p, f);
var min = points.Min(Along);
var len = MathF.Max(1e-4f, points.Max(Along) - min);
// Grip hangs toward the rear: low points sit behind the middle.
var gripRear = low.Count > 0 ? 0.5f - (low.Average(Along) - min) / len : 0f;
// Muzzle end is thin compared with the rear.
var side = Vector3.Cross(Vector3.UnitZ, f);
float Spread(Func<float, bool> slice)
{
var sel = points.Where(p => slice((Along(p) - min) / len)).ToList();
return sel.Count == 0 ? 0f : (sel.Max(p => p.Z) - sel.Min(p => p.Z)) + (sel.Max(p => Vector3.Dot(p, side)) - sel.Min(p => Vector3.Dot(p, side)));
}
var rear = Spread(u => u < 0.2f);
var front = Spread(u => u > 0.8f);
var thinFront = (rear - front) / MathF.Max(1e-3f, MathF.Max(rear, front));
var score = gripRear * 2f + thinFront;
if (hints is not null)
foreach (var (point, sideHint, weight) in hints)
if (sideHint < 0f) // triggers/grips sit behind the centre
score += (0.5f - (Along(point) - min) / len) * weight;
scores.Add(score);
if (score > bestScore)
{
bestScore = score;
best = f;
}
}
var second = scores.OrderByDescending(x => x).Skip(1).First();
var from = MathQ.FromAxes(best, Vector3.Cross(Vector3.UnitZ, best));
var confidence = Math.Clamp(0.45f + (bestScore - second) * 0.8f, 0.3f, 0.9f);
return new OrientationGuess(MathQ.Normalize(Quaternion.Conjugate(from)), confidence, $"compact shape; forward {Describe(best)} from grip and muzzle, up +Z");
}
private static string Describe(Vector3 v)
{
var abs = Vector3.Abs(v);
if (abs.X > 0.999f) return v.X > 0 ? "+X" : "-X";
if (abs.Y > 0.999f) return v.Y > 0 ? "+Y" : "-Y";
if (abs.Z > 0.999f) return v.Z > 0 ? "+Z" : "-Z";
return $"({v.X:0.00}, {v.Y:0.00}, {v.Z:0.00})";
}
/// <summary>Eigenvectors of the point covariance, largest spread first (Jacobi iteration).</summary>
public static (Vector3[] Axes, float[] Spread) PrincipalAxes(IReadOnlyList<Vector3> points)
{
var mean = Vector3.Zero;
foreach (var p in points) mean += p;
mean /= points.Count;
var a = new double[3, 3];
foreach (var p in points)
{
var d = p - mean;
double[] v = { d.X, d.Y, d.Z };
for (var i = 0; i < 3; i++)
for (var j = 0; j < 3; j++)
a[i, j] += v[i] * v[j];
}
var e = new double[3, 3] { { 1, 0, 0 }, { 0, 1, 0 }, { 0, 0, 1 } };
for (var sweep = 0; sweep < 32; sweep++)
{
for (var pI = 0; pI < 2; pI++)
for (var q = pI + 1; q < 3; q++)
{
if (Math.Abs(a[pI, q]) < 1e-12)
continue;
var theta = (a[q, q] - a[pI, pI]) / (2 * a[pI, q]);
var t = Math.Sign(theta) / (Math.Abs(theta) + Math.Sqrt(theta * theta + 1));
if (theta == 0) t = 1;
var c = 1 / Math.Sqrt(t * t + 1);
var sn = t * c;
for (var k = 0; k < 3; k++)
{
var akp = a[k, pI]; var akq = a[k, q];
a[k, pI] = c * akp - sn * akq; a[k, q] = sn * akp + c * akq;
}
for (var k = 0; k < 3; k++)
{
var apk = a[pI, k]; var aqk = a[q, k];
a[pI, k] = c * apk - sn * aqk; a[q, k] = sn * apk + c * aqk;
}
for (var k = 0; k < 3; k++)
{
var ekp = e[k, pI]; var ekq = e[k, q];
e[k, pI] = c * ekp - sn * ekq; e[k, q] = sn * ekp + c * ekq;
}
}
}
var order = new[] { 0, 1, 2 }.OrderByDescending(i => a[i, i]).ToArray();
var axes = order.Select(i => Vector3.Normalize(new Vector3((float)e[0, i], (float)e[1, i], (float)e[2, i]))).ToArray();
var spread = order.Select(i => (float)Math.Sqrt(Math.Max(0, a[i, i]) / points.Count)).ToArray();
return (axes, spread);
}
// ---------------------------------------------------------------- bore
private static (Vector3 Start, Vector3 End, float Diameter) FindBore(ShapeProfile profile, Bounds bounds)
{
// Barrel = the front run of slices with a small, stable cross-section; the bore height is
// the centre of those slices.
var last = profile.Count - 1;
while (last > 0 && !profile.Has(last))
last--;
var n = Math.Max(1, (int)(profile.Count * 0.1f));
var zs = new List<float>();
var ys = new List<float>();
var ds = new List<float>();
for (var i = last; i >= Math.Max(0, last - n); i--)
{
if (!profile.Has(i))
continue;
zs.Add((profile.Top[i] + profile.Bottom[i]) * 0.5f);
ys.Add((profile.Left[i] + profile.Right[i]) * 0.5f);
ds.Add(MathF.Min(profile.Height(i), profile.Width(i)));
}
float Median(List<float> v) { if (v.Count == 0) return 0f; v.Sort(); return v[v.Count / 2]; }
var z = zs.Count > 0 ? Median(zs) : bounds.Center.Z;
var y = ys.Count > 0 ? Median(ys) : bounds.Center.Y;
var d = ds.Count > 0 ? Median(ds) : MathF.Min(bounds.Size.Y, bounds.Size.Z) * 0.3f;
return (new Vector3(bounds.Min.X, y, z), new Vector3(bounds.Max.X, y, z), d);
}
// ---------------------------------------------------------------- parts
private static void DetectParts(WeaponAnalysis a)
{
var skeleton = a.Asset.Skeleton;
var mesh = a.Asset.Mesh;
var bore = a.BoreStart.Z;
var candidates = new List<PartDetection>();
// Bones.
foreach (var m in a.Motion)
{
var name = skeleton[m.Bone].Name;
var tokens = NameTokens.Split(name);
var joined = NameTokens.Joined(tokens);
var center = PartCenter(a, m.Bone) ?? skeleton.RestWorld[m.Bone].Pos;
foreach (var (kind, aliases, joinedAliases) in PartNames)
{
var named = NameTokens.Has(tokens, aliases) || (joinedAliases?.Any(j => joined.Contains(j)) ?? false)
|| (kind == PartKind.Magazine && IsMagazineName(name));
if (!named)
continue;
// "clip" is also an animation word; only trust it on a bone that moves in a reload.
if (kind == PartKind.Magazine && tokens.Contains("clip") && m.MaxTranslation < 1f)
continue;
var conf = 0.75f + MotionAgrees(kind, m, center, bore) * 0.25f;
candidates.Add(new PartDetection { Kind = kind, Bone = name, Center = center, Confidence = conf, Reason = $"bone '{name}'" + (m.MaxTranslation > 0.1f || m.MaxRotationDeg > 3f ? $", moves in '{m.PeakClip}'" : "") });
}
}
// Unnamed moving bones: classify by how they move.
foreach (var m in a.Motion)
{
var name = skeleton[m.Bone].Name;
if (candidates.Any(c => c.Bone == name) || m.Bone == a.RootBone)
continue;
if (!a.WeaponTriangles.Any(t => mesh.TriangleBone(t) == m.Bone))
continue;
var center = PartCenter(a, m.Bone) ?? skeleton.RestWorld[m.Bone].Pos;
var guess = GuessFromMotion(m, center, bore, a);
if (guess is { } g)
candidates.Add(new PartDetection { Kind = g.Kind, Bone = name, Center = center, Confidence = g.Confidence, Reason = g.Reason });
}
// Static meshes named after parts (props without bones).
for (var p = 0; p < mesh.PartNames.Count; p++)
{
var tokens = NameTokens.Split(mesh.PartNames[p]);
var joined = NameTokens.Joined(tokens);
foreach (var (kind, aliases, joinedAliases) in PartNames)
{
if (!NameTokens.Has(tokens, aliases) && !(joinedAliases?.Any(j => joined.Contains(j)) ?? false))
continue;
var tris = a.WeaponTriangles.Where(t => mesh.TrianglePart[t] == p).ToArray();
if (tris.Length == 0)
continue;
var center = Centroid(mesh, tris);
candidates.Add(new PartDetection { Kind = kind, Meshes = new[] { mesh.PartNames[p] }, Center = center, Confidence = 0.65f, Reason = $"mesh '{mesh.PartNames[p]}'" });
}
}
// One detection per kind: highest confidence wins, bone-driven preferred over mesh-only.
foreach (var group in candidates.GroupBy(c => c.Kind))
{
var best = group.OrderByDescending(c => c.Confidence + (c.Bone != "" ? 0.05f : 0f)).First();
var meshes = group.SelectMany(c => c.Meshes).Distinct().ToArray();
a.Parts.Add(best with { Meshes = meshes.Length > 0 ? meshes : best.Meshes });
}
}
private static Vector3? PartCenter(WeaponAnalysis a, int bone)
{
var tris = a.WeaponTriangles.Where(t => a.Asset.Mesh.TriangleBone(t) == bone).ToArray();
return tris.Length == 0 ? null : Centroid(a.Asset.Mesh, tris);
}
public static Vector3 Centroid(TriMesh mesh, IReadOnlyCollection<int> tris)
{
var sum = Vector3.Zero;
var area = 0f;
foreach (var t in tris)
{
var (a, b, c) = mesh.Triangle(t);
var w = Vector3.Cross(b - a, c - a).Length() * 0.5f + 1e-6f;
sum += (a + b + c) / 3f * w;
area += w;
}
return area > 0f ? sum / area : Vector3.Zero;
}
/// <summary>0..1: how well a bone's motion fits what the part kind does.</summary>
private static float MotionAgrees(PartKind kind, BoneMotion m, Vector3 center, float bore)
{
var alongX = MathF.Abs(m.MainDirection.X);
return kind switch
{
PartKind.Magazine => m.MaxTranslation > 2f ? 1f : m.MaxTranslation > 0.5f ? 0.5f : 0f,
PartKind.Slide or PartKind.Bolt or PartKind.ChargingHandle or PartKind.Pump => m.MaxTranslation > 0.3f && alongX > 0.7f ? 1f : m.MaxTranslation > 0.1f ? 0.5f : 0f,
PartKind.Trigger => m.MaxRotationDeg > 3f || (m.MaxTranslation > 0.02f && m.MaxTranslation < 0.5f) ? 1f : 0f,
PartKind.Hammer => m.MaxRotationDeg > 10f ? 1f : 0f,
PartKind.Cylinder => m.MaxRotationDeg > 20f ? 1f : 0f,
_ => 0.5f,
};
}
private static (PartKind Kind, float Confidence, string Reason)? GuessFromMotion(BoneMotion m, Vector3 center, float bore, WeaponAnalysis a)
{
var alongX = MathF.Abs(m.MainDirection.X);
var below = center.Z < bore - a.BoreDiameter;
var clip = m.PeakClip.ToLowerInvariant();
if (m.MaxTranslation > 3f && below && clip.Contains("reload"))
return (PartKind.Magazine, 0.7f, $"leaves the weapon during '{m.PeakClip}'");
if (m.MaxTranslation > 3f && below)
return (PartKind.Magazine, 0.5f, "moves far below the bore");
if (m.MaxTranslation > 0.3f && m.MaxTranslation < 4f && alongX > 0.8f && !below)
{
var slideLike = a.Asset.Mesh.TriangleCount > 0 && a.Length < 13f;
return slideLike
? (PartKind.Slide, 0.55f, "slides back along the barrel")
: (PartKind.Bolt, 0.55f, "slides back along the barrel");
}
// A pump sits well ahead of the trigger on a long gun.
var triggerX = a.Parts.FirstOrDefault(p => p.Kind == PartKind.Trigger)?.Center.X ?? (a.WeaponBounds.Min.X + a.Length * 0.4f);
if (m.MaxTranslation > 0.5f && alongX > 0.8f && below && a.Length > 20f && center.X > triggerX + 3f)
return (PartKind.Pump, 0.45f, "slides along under the barrel");
// A cylinder is a sizeable part around the bore on a short gun.
if (m.MaxRotationDeg > 25f && a.Length < 16f && MathF.Abs(center.Y - a.BoreStart.Y) < a.BoreDiameter * 2f && m.MaxTranslation < 0.2f && center.Z > bore - a.BoreDiameter * 2f)
return (PartKind.Cylinder, 0.4f, "rotates around the bore axis");
if (m.MaxRotationDeg > 3f && m.MaxRotationDeg < 40f && below && m.MaxTranslation < 0.3f)
return (PartKind.Trigger, 0.4f, "pivots below the receiver");
return null;
}
// ---------------------------------------------------------------- muzzle / eject
private static readonly string[] MuzzleNames = { "muzzle", "flash", "muzzleflash", "barrelend", "tip", "fx", "shoot", "bullet", "spawn" };
private static readonly string[] EjectNames = { "eject", "ejection", "shell", "brass", "casing", "ejectionport", "shelleject" };
private static PointDetection? DetectMuzzle(WeaponAnalysis a)
{
var skeleton = a.Asset.Skeleton;
var rootName = skeleton[a.RootBone].Name;
foreach (var att in a.Asset.Attachments)
if (NameTokens.Has(NameTokens.Split(att.Name), MuzzleNames))
{
var bone = skeleton.IndexOf(att.Bone);
var world = bone >= 0 ? XForm.Compose(skeleton.RestWorld[bone], att.Local) : att.Local;
return new PointDetection { Bone = att.Bone, Local = att.Local, Model = world, Confidence = 0.95f, Reason = $"attachment '{att.Name}'" };
}
for (var b = 0; b < skeleton.Count; b++)
{
if (a.ArmBones.Contains(b))
continue;
var tokens = NameTokens.Split(skeleton[b].Name);
if (!NameTokens.Has(tokens, "muzzle", "flash", "muzzleflash", "barrelend") && !NameTokens.Joined(tokens).Contains("muzzle"))
continue;
var world = new XForm(skeleton.RestWorld[b].Pos, Quaternion.Identity);
var local = XForm.ToLocal(skeleton.RestWorld[b], world);
return new PointDetection { Bone = skeleton[b].Name, Local = local, Model = world, Confidence = 0.9f, Reason = $"bone '{skeleton[b].Name}'" };
}
if (a.WeaponBvh.IsEmpty)
return null;
// Geometry: centre of the bore at the front face, found by casting back from ahead of it.
var front = a.BoreEnd + Vector3.UnitX * 2f;
var hit = a.WeaponBvh.Raycast(front, -Vector3.UnitX, a.Length + 4f);
var point = hit is { } h && h.Point.X > a.WeaponBounds.Max.X - a.Length * 0.08f
? new Vector3(a.WeaponBounds.Max.X, a.BoreEnd.Y, a.BoreEnd.Z)
: a.BoreEnd;
var model = new XForm(point, Quaternion.Identity);
var rootWorld = skeleton.RestWorld[a.RootBone];
return new PointDetection
{
Bone = rootName,
Local = XForm.ToLocal(rootWorld, model),
Model = model,
Confidence = a.Type.Type == WeaponType.Melee ? 0.2f : 0.6f,
Reason = "front of the barrel",
};
}
private static PointDetection? DetectEject(WeaponAnalysis a)
{
var skeleton = a.Asset.Skeleton;
foreach (var att in a.Asset.Attachments)
if (NameTokens.Has(NameTokens.Split(att.Name), EjectNames))
{
var bone = skeleton.IndexOf(att.Bone);
var world = bone >= 0 ? XForm.Compose(skeleton.RestWorld[bone], att.Local) : att.Local;
return new PointDetection { Bone = att.Bone, Local = att.Local, Model = world, Confidence = 0.95f, Reason = $"attachment '{att.Name}'" };
}
for (var b = 0; b < skeleton.Count; b++)
{
if (a.ArmBones.Contains(b))
continue;
if (!NameTokens.Has(NameTokens.Split(skeleton[b].Name), EjectNames))
continue;
var world = new XForm(skeleton.RestWorld[b].Pos, EjectRotation);
return new PointDetection { Bone = skeleton[b].Name, Local = XForm.ToLocal(skeleton.RestWorld[b], world), Model = world, Confidence = 0.9f, Reason = $"bone '{skeleton[b].Name}'" };
}
if (a.WeaponBvh.IsEmpty)
return null;
// Geometry: the port sits on the right (-Y) of the receiver, over the magazine or just
// ahead of the grip, at bore height.
var mag = a.Part(PartKind.Magazine);
var trigger = a.Part(PartKind.Trigger);
var slideOrBolt = a.Part(PartKind.Slide) ?? a.Part(PartKind.Bolt);
var x = mag?.Center.X
?? (trigger is { } tr ? tr.Center.X + 1.2f : a.WeaponBounds.Min.X + a.Length * 0.45f);
if (slideOrBolt is { } sb && mag is null)
x = sb.Center.X;
var z = a.BoreStart.Z + a.BoreDiameter * 0.25f;
var from = new Vector3(x, a.WeaponBounds.Min.Y - 2f, z);
var hit = a.WeaponBvh.Raycast(from, Vector3.UnitY, a.WeaponBounds.Size.Y + 4f);
if (hit is null)
return null;
var model = new XForm(hit.Value.Point, EjectRotation);
var rootWorld = skeleton.RestWorld[a.RootBone];
return new PointDetection
{
Bone = skeleton[a.RootBone].Name,
Local = XForm.ToLocal(rootWorld, model),
Model = model,
Confidence = mag is not null || slideOrBolt is not null ? 0.55f : 0.35f,
Reason = "right side of the receiver",
};
}
/// <summary>Shells leave to the right, slightly up and back.</summary>
public static readonly Quaternion EjectRotation = MathQ.FromAxes(Vector3.Normalize(new Vector3(-0.25f, -1f, 0.45f)), Vector3.UnitZ);
// ---------------------------------------------------------------- animations
private static void ClassifyAnimations(WeaponAnalysis a)
{
var mag = a.Part(PartKind.Magazine);
var bolt = a.Part(PartKind.Slide) ?? a.Part(PartKind.Bolt) ?? a.Part(PartKind.Pump);
var skeleton = a.Asset.Skeleton;
var magBone = mag is null ? -1 : skeleton.IndexOf(mag.Bone);
var boltBone = bolt is null ? -1 : skeleton.IndexOf(bolt.Bone);
foreach (var clip in a.Asset.Clips)
{
var hint = new AnimationMotionHint(
clip.Duration,
magBone >= 0 ? Travel(clip, magBone, skeleton) : 0f,
boltBone >= 0 ? Travel(clip, boltBone, skeleton) : 0f,
a.RootBone >= 0 ? Travel(clip, a.RootBone, skeleton) : 0f,
a.Motion.Count == 0 ? 0f : a.Motion.Max(m => m.MaxTranslation));
a.Animations.Add(AnimationClassifier.Classify(clip.Name, hint));
}
foreach (var kv in AnimationClassifier.Assign(a.Animations, AnimationPerspective.FirstPerson))
a.AssignedAnimations[kv.Key] = kv.Value;
}
private static float Travel(Clip clip, int bone, Skeleton skeleton)
{
var rest = skeleton[bone].RestLocal.Pos;
var max = 0f;
foreach (var frame in clip.Frames)
max = MathF.Max(max, Vector3.Distance(frame[bone].Pos, rest));
return max;
}
}