s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (136 total matches found)
notpointless.chomnr_weapon_importer / Code/WeaponImporter/WeaponHoldSystem.cs
Game library
namespace WeaponImporter;

/// <summary>
/// Reads every <see cref="WeaponHold"/>'s action triggers after components update and before
/// the animgraph evaluates (Stage.UpdateBones, order -1), so auto-reset graph triggers set by
/// game code in OnUpdate are never missed.
/// </summary>
public sealed class WeaponHoldSystem : GameObjectSystem
{
    public WeaponHoldSystem( Scene scene ) : base( scene )
    {
        Listen( Stage.UpdateBones, -1, ReadTriggers, "WeaponHold.ReadTriggers" );
    }

    private void ReadTriggers()
    {
        foreach ( var hold in Scene.GetAllComponents<WeaponHold>() )
        {
            if ( hold.Active )
                hold.ReadTriggers();
        }
    }
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Core/Grip/LibraryGripGenerators.cs
Editor library
#nullable enable annotations

using System.Numerics;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Geometry;
using WeaponImporter.Core.Grasp;
using WeaponImporter.Core.Hands;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Rig;
using WeaponImporter.Core.Weapon;

namespace WeaponImporter.Core.Grip;

using Vector3 = System.Numerics.Vector3;

/// <summary>
/// Grips from the library (captured from animations): each preset for the requested hand is
/// placed on the grip surface, refined against the geometry for this hand's size, and scored.
/// </summary>
public sealed class PresetGripGenerator : IGripGenerator
{
    private readonly IReadOnlyList<GripPreset> _presets;

    /// <summary>Per hand: only this preset is offered (the user picked it).</summary>
    public string? OnlyRight { get; init; }
    public string? OnlyLeft { get; init; }

    public PresetGripGenerator(IEnumerable<GripPreset> presets) => _presets = presets.ToList();

    public string Name => "Library";
    public bool IsAvailable => _presets.Count > 0;

    public IEnumerable<GraspCandidate> Generate(GraspRequest request, CancellationToken cancel = default)
    {
        var list = new List<GraspCandidate>();
        foreach (var preset in _presets)
        {
            cancel.ThrowIfCancellationRequested();
            if (preset.Side != request.Hand.Side || !Compatible(preset.Style, request.Style))
                continue;
            var only = request.Hand.Side == Side.Right ? OnlyRight : OnlyLeft;
            if (!string.IsNullOrEmpty(only) && preset.Name != only)
                continue;
            var pose = GripLibrary.Apply(preset, request.Hand, request.Surface);
            ContactRefiner.Refine(request, pose);
            var quality = GraspEvaluator.Evaluate(request, pose);
            list.Add(new GraspCandidate(pose, quality, preset.Name) { Bonus = preset.Priority });
        }
        return list.OrderByDescending(c => c.Rank);
    }

    /// <summary>A handguard grip can cradle a pump and vice versa; a pistol grip grip is a wrap.</summary>
    public static bool Compatible(GripStyle preset, GripStyle surface) => preset == surface
        || (preset, surface) is (GripStyle.Cradle, GripStyle.Pump) or (GripStyle.Pump, GripStyle.Cradle)
        || (preset, surface) is (GripStyle.Wrap, GripStyle.Handle) or (GripStyle.Handle, GripStyle.Wrap);
}

/// <summary>
/// Several generators competing: their candidates are pooled and ranked together (the solver
/// then re-ranks the best ones with wrist bend and reach). A learned generator such as GrabNet
/// plugs in here the same way; it runs inside the background grip solve, never on the UI thread.
/// </summary>
public sealed class CompositeGripGenerator : IGripGenerator
{
    private readonly IReadOnlyList<IGripGenerator> _parts;

    public CompositeGripGenerator(params IGripGenerator[] parts) => _parts = parts.Where(p => p.IsAvailable).ToList();

    public string Name => string.Join(" + ", _parts.Select(p => p.Name));
    public bool IsAvailable => _parts.Count > 0;

    public IEnumerable<GraspCandidate> Generate(GraspRequest request, CancellationToken cancel = default)
        => _parts.SelectMany(p => p.Generate(request, cancel)).OrderByDescending(c => c.Rank).ToList();
}

/// <summary>
/// The grip source per hand from the user's choice: "" = automatic (library grips and the
/// procedural fit compete), "Procedural" = only the procedural fit, a preset name = that grip.
/// </summary>
public sealed class ChoiceGripGenerator : IGripGenerator
{
    public const string Procedural = "Procedural";

    private readonly IReadOnlyList<GripPreset> _presets;
    private readonly string _right, _left;
    private readonly ProceduralGripGenerator _procedural = new();

    public ChoiceGripGenerator(IEnumerable<GripPreset> presets, string? right, string? left)
    {
        _presets = presets.ToList();
        _right = right ?? "";
        _left = left ?? "";
    }

    public string Name => "Grips";
    public bool IsAvailable => true;

    public IEnumerable<GraspCandidate> Generate(GraspRequest request, CancellationToken cancel = default)
    {
        var choice = request.Hand.Side == Side.Right ? _right : _left;
        if (choice == Procedural)
            return _procedural.Generate(request, cancel);
        if (choice.Length > 0)
        {
            var chosen = new PresetGripGenerator(_presets.Where(p => p.Name == choice)).Generate(request, cancel).ToList();
            if (chosen.Count > 0)
                return chosen;
        }
        return new CompositeGripGenerator(new PresetGripGenerator(_presets), _procedural).Generate(request, cancel);
    }

    /// <summary>Only the library grips this hand may use (none when the procedural fit is chosen).</summary>
    public IEnumerable<GraspCandidate> GenerateLibrary(GraspRequest request, CancellationToken cancel = default)
    {
        var choice = request.Hand.Side == Side.Right ? _right : _left;
        if (choice == Procedural)
            return Array.Empty<GraspCandidate>();
        var presets = choice.Length > 0 ? _presets.Where(p => p.Name == choice) : _presets;
        return new PresetGripGenerator(presets).Generate(request, cancel);
    }
}

/// <summary>Captures the grips of a file's own arms (first-person rigs, animation packs).</summary>
public static class GripExtractor
{
    /// <summary>A hand counts as holding when its palm is this close to the weapon surface (inches).</summary>
    public const float HoldingDistance = 2.5f;

    /// <summary>Ranking bonus of grips taken from the weapon's own animation (see <see cref="GripPreset.Priority"/>).</summary>
    public const float OwnPriority = 2.5f;

    /// <summary>The weapon's own grips, preferred when grips compete.</summary>
    public static List<GripPreset> ExtractOwn(WeaponAnalysis a, string source)
    {
        var list = Extract(a, source);
        foreach (var p in list)
            p.Priority = OwnPriority;
        return list;
    }

    /// <summary>
    /// The hands in the analysed file's reference pose (canonical weapon space), captured on the
    /// grip surface each one holds. Hands that aren't on the weapon are skipped.
    /// </summary>
    public static List<GripPreset> Extract(WeaponAnalysis a, string source)
    {
        var list = new List<GripPreset>();
        var skeleton = a.Asset.Skeleton;
        if (skeleton.Count == 0 || a.WeaponBvh.IsEmpty)
            return list;
        var world = ReferenceWorld(a);
        foreach (var side in new[] { Side.Right, Side.Left })
        {
            var rig = HandRig.Build(skeleton, side);
            if (rig is null || rig.Fingers.Count < 3)
                continue;
            var palm = world[rig.Hand].TransformPoint(rig.PalmCenter);
            var candidate = side == Side.Right ? a.Primary : a.Support;
            GripSurface? surface = null;
            var style = side == Side.Right ? (a.Type.Type == WeaponType.Melee ? GripStyle.Handle : GripStyle.Wrap) : GripStyle.Cradle;
            if (candidate is not null && Vector3.Distance(candidate.Surface.Contact, palm) < rig.HandLength)
            {
                surface = candidate.Surface;
                style = candidate.Style;
            }
            else
            {
                var hint = side == Side.Right ? Vector3.UnitZ : Vector3.UnitX;
                surface = SurfaceProbe.FromPoint(a.WeaponBvh, palm, hint);
            }
            if (surface is null || Vector3.Distance(surface.Contact, palm) > HoldingDistance + rig.PalmThickness)
                continue;
            var name = $"{source} · {(side == Side.Right ? "right" : "left")}";
            var preset = GripLibrary.Capture(rig, world, surface, style, name, source);
            // A palm inside the surface means the "weapon" is the arms themselves (an arms-only
            // file) or the hand isn't really holding it.
            if (preset.Palm[2] < -0.12f)
                continue;
            preset.WeaponType = a.Type.Type.ToString();
            list.Add(preset);
        }
        return list;
    }

    /// <summary>World transforms of the analysis reference frame (bind pose without a clip).</summary>
    public static XForm[] ReferenceWorld(WeaponAnalysis a)
    {
        var skeleton = a.Asset.Skeleton;
        var clip = a.Asset.FindClip(a.ReferenceClip);
        if (clip is { FrameCount: > 0 })
        {
            var frame = Math.Clamp(a.ReferenceFrame, 0, clip.FrameCount - 1);
            return new Pose(clip.Frames[frame]).ToWorld(skeleton);
        }
        return skeleton.RestWorld.ToArray();
    }
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Core/Setup/ActionContacts.cs
Editor library
#nullable enable annotations

using System.Numerics;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Grip;
using WeaponImporter.Core.Hands;
using WeaponImporter.Core.Ik;
using WeaponImporter.Core.Maths;

namespace WeaponImporter.Core.Setup;

using Vector3 = System.Numerics.Vector3;

/// <summary>A character action sampled over its normalized time (see CharacterPoser.SampleTrack).</summary>
public sealed record ActionTrack(AnimationRole Role, float Seconds, IReadOnlyList<(float Time, CharacterPose Pose)> Samples);

/// <summary>Result of planning the support hand over the actions.</summary>
public sealed class ActionContactPlan
{
    /// <summary>Support hand on the magazine (weapon space), when the weapon has one.</summary>
    public HandPose? Magazine { get; set; }
    public GripRegion? MagazineRegion { get; set; }
    public Dictionary<AnimationRole, ContactPlanner.Plan> Plans { get; } = new();
}

/// <summary>
/// Plans the support hand over the character's actions once the grip is solved: where the
/// animated hand goes relative to the weapon (it follows the hold bone), where it should touch
/// this weapon instead (the magazine during reloads), and the contact keys that get it there with
/// the smallest change to the animation. Pure maths; the samples come from the editor.
/// </summary>
public static class ActionContacts
{
    public static ActionContactPlan Plan(WeaponAnalysis weapon, WeaponSetup setup, CharacterRig character, GripSolution grip, IReadOnlyList<ActionTrack> tracks, GripOptions? options = null, CancellationToken cancel = default)
    {
        var plan = new ActionContactPlan();
        if (grip.Left is null || character.Left is not { } leftRig || !setup.UseSupportHand)
            return plan;
        var region = GripRegions.Magazine(weapon);
        plan.MagazineRegion = region;

        // Animated support wrist in weapon space, per sample.
        List<ContactPlanner.Sample> Samples(ActionTrack track) => track.Samples
            .Select(s => new ContactPlanner.Sample(s.Time, XForm.ToLocal(GripSolver.WeaponWorld(s.Pose, character, grip.WeaponInHold), s.Pose.World[leftRig.Hand])))
            .ToList();

        var reloads = tracks.Where(t => ActionTiming.ReloadRoles.Contains(t.Role)).ToList();
        if (!setup.ReloadTouchesMagazine)
        {
            // Default: the character reloads as animated; the hand only lets go and comes back.
            foreach (var track in reloads)
                plan.Plans[track.Role] = ContactPlanner.PlanRelease(Samples(track), track.Seconds);
            return plan;
        }
        if (region?.Surface is not null && reloads.Count > 0)
        {
            // Fit the magazine grab closest to where the animation brings the hand.
            var first = Samples(reloads[0]);
            var nearest = first.OrderBy(s => Vector3.Distance(s.Wrist.Pos, region.Surface.Contact)).First();
            plan.Magazine = GripSolver.SolveOnRegion(weapon, region, character, grip, nearest.Wrist, options, cancel);
        }

        foreach (var track in reloads)
        {
            cancel.ThrowIfCancellationRequested();
            if (plan.Magazine is null)
                break;
            plan.Plans[track.Role] = ContactPlanner.PlanReload(Samples(track), grip.Left.Wrist, plan.Magazine.Wrist, track.Seconds, weapon.WeaponBounds);
        }
        return plan;
    }

    /// <summary>Writes the planned tracks into the setup, leaving tracks the user edited alone.</summary>
    public static void Apply(WeaponSetup setup, ActionContactPlan plan)
    {
        foreach (var (role, p) in plan.Plans)
        {
            if (setup.Contacts.TryGetValue(role, out var existing) && existing.Keys.Count > 0 && !existing.Generated)
                continue;
            setup.Contacts[role] = new ContactTrack { Keys = p.Keys, Generated = true, Note = p.Note };
        }
    }
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Tool/UI/Steps/CheckStep.cs
Editor library
using Editor;
using Sandbox;
using WeaponImporter.Core.Setup;

namespace WeaponImporter.Tool.UI;

/// <summary>Step 4: what still needs attention, then Bake and the generated files.</summary>
public sealed class CheckStep : StepPanel
{
	public CheckStep( Widget parent, ImporterController controller ) : base( parent, controller )
	{
		controller.BusyChanged += MarkDirty;
	}

	public override string Title => "Export";

	protected override string StructureKey()
	{
		var checks = string.Join( ";", C.Session.Checks.Select( c => $"{c.Name}:{c.Severity}:{c.Message}" ) );
		return $"{checks}|{C.Busy}|{C.LastBake?.GetHashCode()}";
	}

	protected override void Build()
	{
		BuildChecks();
		BuildBake();
	}

	private void BuildChecks()
	{
		var checks = C.Session.Checks;
		var problems = Validation.Problems( checks ).ToList();
		var card = AddCard( "fact_check", "Validation", out var header, "Everything the importer verified" );
		header.AddStretchCell();
		if ( problems.Count == 0 )
			header.Add( new Pill( card, "ALL GOOD", Theme.Green ) );
		else
			header.Add( new Pill( card, $"{problems.Count} TO LOOK AT", problems.Any( p => p.Severity == CheckSeverity.Error ) ? Theme.Red : Theme.Yellow ) );

		foreach ( var check in problems )
			card.Layout.Add( new CheckLine( card, check, expanded: true, () => Fix( check ) ) );
		if ( problems.Count > 0 )
			card.Layout.AddSpacingCell( 4 );
		foreach ( var check in checks.Where( c => !c.NeedsAttention ) )
			card.Layout.Add( new CheckLine( card, check, expanded: false, check.AutoFix is null ? null : () => Fix( check ) ) );
		if ( checks.Count == 0 )
			card.Layout.Add( UiStyle.Muted( new Label( "Nothing checked yet.", card ) ) );
	}

	private void Fix( CheckResult check )
	{
		Safe( () =>
		{
			check.AutoFix?.Invoke();
			C.Session.Revalidate();
			C.MarkChanged();
			C.ScheduleResolve( 0.01f );
			C.SetStatus( $"{check.Name}: {check.AutoFixLabel.ToLowerInvariant()} applied.", Theme.Green );
		} );
	}

	private void BuildBake()
	{
		var session = C.Session;
		var card = AddCard( "inventory_2", "Bake", out _, "Write the model, animations, attachments and a ready prefab" );
		card.Layout.Add( UiStyle.Muted( new Label( $"Output: {session.OutputFolder}/", card ) { WordWrap = true, ToolTip = "Files are written here inside the project's assets" }, small: true ) );
		var s = session.Setup;
		if ( session.Analysis?.ArmBones.Count > 0 )
		{
			var fp = card.Layout.Add( new Checkbox( "Bake the first-person viewmodel", card ) { Value = s.ExportFirstPerson } );
			fp.ToolTip = "Also write <name>_fp.vmdl: this file's own arms, weapon and camera with every animation as authored. The prefab shows it to the player holding the weapon, in sync with the third-person hold.";
			fp.StateChanged = _ => s.ExportFirstPerson = fp.Value;
		}
		var sizeRow = UiStyle.FieldRow( card, card.Layout, "Textures", "Largest texture size in the baked weapon; bigger images are scaled down (smaller files, same look at normal distances)" );
		var size = sizeRow.Add( UiStyle.Framed( new ComboBox( card ) ), 1 );
		foreach ( var (label, value) in new[] { ("Original size", 0), ("4096", 4096), ("2048", 2048), ("1024", 1024) } )
		{
			var v = value;
			size.AddItem( label, "photo_size_select_large", () => s.MaxTextureSize = v, selected: s.MaxTextureSize == value );
		}
		var corrected = card.Layout.Add( new Checkbox( "Bake corrected third-person animations", card ) { Value = s.BakeCorrectedAnimations } );
		corrected.ToolTip = "Also record the character's actions with the hands on this weapon as new clips (<name>_corrected.vmdl). The character's own animations are never changed.";
		corrected.StateChanged = _ => s.BakeCorrectedAnimations = corrected.Value;
		var use = card.Layout.Add( new Checkbox( "Play the corrected clips in the prefab", card ) { Value = s.UseCorrectedAnimations } );
		use.ToolTip = "Off: the prefab corrects the character's live animation every frame (works with any animgraph blend). On: it plays the baked corrected clips over the upper body.";
		use.StateChanged = _ => s.UseCorrectedAnimations = use.Value;
		var bake = card.Layout.Add( new Button.Primary( "Bake", card ) { Icon = "inventory_2", Tint = Theme.Green, FixedHeight = 36, Enabled = !C.Busy, ToolTip = "Write and compile the weapon model and its prefab" } );
		bake.Clicked = () => _ = C.BakeAsync();

		var result = C.LastBake;
		if ( result is null )
			return;

		card.Layout.Add( new SectionHeader( card, result.Success ? "Baked" : "Bake finished with errors" ) );
		var links = card.Layout.AddRow();
		links.Spacing = 6;
		links.Add( new UiButton( card, "Open model", "view_in_ar", () => Open( result.ModelPath ), result.ModelPath ) { Enabled = FindAsset( result.ModelPath ) is not null } );
		links.Add( new UiButton( card, "Open prefab", "inventory_2", () => Open( result.PrefabPath ), result.PrefabPath ) { Enabled = FindAsset( result.PrefabPath ) is not null } );
		links.Add( new UiButton( card, "Show in asset browser", "folder_open", () => Reveal( result.ModelPath ), "Reveal the generated model in the asset browser" ) );
		links.AddStretchCell();

		foreach ( var file in result.Files )
		{
			var errors = result.Errors.TryGetValue( file, out var list ) ? list : null;
			var row = card.Layout.AddRow();
			row.Spacing = 6;
			row.Add( new IconLabel( card, errors is null ? "check_circle" : "error", errors is null ? Theme.Green : Theme.Red ) );
			row.Add( new Label( file, card ) { ToolTip = AssetCompiler.Absolute( file ) }, 1 );
			if ( errors is not null )
			{
				foreach ( var e in errors.Take( 6 ) )
				{
					var er = card.Layout.AddRow();
					er.AddSpacingCell( 26 );
					er.Add( UiStyle.Colored( new Label( e, card ) { WordWrap = true }, Theme.Red ), 1 );
				}
			}
		}
		foreach ( var (file, errors) in result.Errors.Where( kv => !result.Files.Contains( kv.Key ) ) )
			card.Layout.Add( UiStyle.Colored( new Label( $"{file}: {errors.FirstOrDefault()}", card ) { WordWrap = true }, Theme.Red ) );
		foreach ( var note in result.Notes )
			card.Layout.Add( UiStyle.Muted( new Label( note, card ) { WordWrap = true }, small: true ) );
	}

	private static Asset FindAsset( string relative ) => string.IsNullOrEmpty( relative ) ? null : AssetSystem.FindByPath( relative );

	private void Open( string relative )
	{
		Safe( () =>
		{
			var asset = FindAsset( relative );
			if ( asset is null )
				C.SetStatus( $"{relative} is not in the asset system yet.", Theme.Yellow );
			else
				asset.OpenInEditor();
		} );
	}

	private void Reveal( string relative )
	{
		Safe( () =>
		{
			var asset = FindAsset( relative );
			if ( asset is not null )
				AssetBrowser.OpenTo( asset );
			else
				EditorUtility.OpenFolder( System.IO.Path.GetDirectoryName( AssetCompiler.Absolute( relative ) ) );
		} );
	}

	public override void OnDestroyed()
	{
		C.BusyChanged -= MarkDirty;
		base.OnDestroyed();
	}
}

/// <summary>"✓ Skeleton" / "! Left wrist penetration detected" / "× Muzzle missing".</summary>
public sealed class CheckLine : Widget
{
	private readonly CheckResult _check;
	private readonly bool _expanded;

	public CheckLine( Widget parent, CheckResult check, bool expanded, Action fix ) : base( parent )
	{
		_check = check;
		_expanded = expanded;
		Layout = Layout.Row();
		Layout.Spacing = 8;
		Layout.Margin = expanded ? new Sandbox.UI.Margin( 10, 7, 7, 7 ) : new Sandbox.UI.Margin( 10, 1, 4, 1 );
		var color = ColorOf( check.Severity );
		Layout.Add( new Glyph( this, GlyphOf( check.Severity ), color ) );
		var text = Layout.AddColumn( 1 );
		text.Spacing = 2;
		if ( expanded )
		{
			text.Add( UiStyle.Colored( new Label( check.Name, this ), color, bold: true ) );
			if ( !string.IsNullOrEmpty( check.Message ) )
				text.Add( new Label( check.Message, this ) { WordWrap = true } );
		}
		else
		{
			var line = text.AddRow();
			line.Spacing = 6;
			line.Add( new Label( check.Name, this ) );
			if ( !string.IsNullOrEmpty( check.Message ) )
				line.Add( UiStyle.Muted( new Label( check.Message, this ) { ToolTip = check.Message, WordWrap = true, MinimumWidth = 20 }, small: true ), 1 );
			else
				line.AddStretchCell();
		}
		if ( fix is not null && check.AutoFix is not null )
		{
			var button = Layout.Add( new UiButton( this, check.AutoFixLabel, "auto_fix_high", null, $"Apply the safe fix for: {check.Name}" ) );
			button.Clicked = fix;
		}
		ToolTip = string.IsNullOrEmpty( check.Message ) ? check.Name : $"{check.Name}: {check.Message}";
	}

	public static Color ColorOf( CheckSeverity s ) => s switch
	{
		CheckSeverity.Error => Theme.Red,
		CheckSeverity.Warning => Theme.Yellow,
		CheckSeverity.Info => Theme.Blue,
		_ => Theme.Green,
	};

	public static string GlyphOf( CheckSeverity s ) => s switch
	{
		CheckSeverity.Error => "×",
		CheckSeverity.Warning => "!",
		CheckSeverity.Info => "i",
		_ => "✓",
	};

	protected override void OnPaint()
	{
		if ( !_expanded )
			return;
		Paint.Antialiasing = true;
		var color = ColorOf( _check.Severity );
		Paint.ClearPen();
		Paint.SetBrush( color.WithAlpha( .07f ) );
		Paint.DrawRect( LocalRect, 4 );
		Paint.SetBrush( color );
		Paint.DrawRect( new Rect( 0, 0, 3, Height ), 1.5f );
	}

	private sealed class Glyph : Widget
	{
		private readonly string _glyph;
		private readonly Color _color;

		public Glyph( Widget parent, string glyph, Color color ) : base( parent )
		{
			_glyph = glyph;
			_color = color;
			FixedSize = 16;
		}

		protected override void OnPaint()
		{
			Paint.SetPen( _color );
			Paint.SetDefaultFont( 9, 700 );
			Paint.DrawText( LocalRect, _glyph, TextFlag.Center );
		}
	}
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Tool/UI/Timeline/TimelineCard.cs
Editor library
using Editor;
using Sandbox;
using WeaponImporter.Core.Analysis;

namespace WeaponImporter.Tool.UI;

/// <summary>Transport for the selected action (play, step, speed, loop, scrub) above its contact and event lanes.</summary>
public sealed class TimelineCard : Card
{
	private readonly ImporterController _c;
	private readonly IconButton _play;
	private readonly IconButton _loop;
	private readonly FloatSlider _scrub;
	private readonly Label _clock;
	private readonly Label _role;
	private readonly TimelineLanes _lanes;
	private bool _scrubbing;

	public TimelineCard( Widget parent, ImporterController controller ) : base( parent )
	{
		_c = controller;
		Layout.Margin = new Sandbox.UI.Margin( 10, 6, 10, 8 );
		Layout.Spacing = 4;

		var transport = Layout.AddRow();
		transport.Spacing = 6;
		_role = transport.Add( UiStyle.Bold( new Label( "Idle", this ) { FixedWidth = TimelineLanes.LabelWidth - 6, FixedHeight = UiStyle.ControlHeight, ToolTip = "The action being previewed; pick another in the preview header or in Animations" } ) );
		_play = transport.Add( UiStyle.Icon( this, "pause", controller.TogglePlay, "Play / pause (Space)" ) );
		transport.Add( UiStyle.Icon( this, "chevron_left", () => controller.StepFrame( -1 ), "Previous frame (Left arrow)" ) );
		transport.Add( UiStyle.Icon( this, "chevron_right", () => controller.StepFrame( 1 ), "Next frame (Right arrow)" ) );
		_scrub = transport.Add( new FloatSlider( this ) { Minimum = 0, Maximum = 1, Value = 0, ToolTip = "Scrub through the action", FixedHeight = UiStyle.ControlHeight }, 1 );
		_scrub.OnValueEdited = () =>
		{
			_scrubbing = true;
			controller.Seek( _scrub.Value );
			_scrubbing = false;
		};
		_clock = transport.Add( UiStyle.Muted( new Label( "0.00 s", this ) { FixedWidth = 92, FixedHeight = UiStyle.ControlHeight, Alignment = TextFlag.RightCenter, ToolTip = "Time in the action / its length" } ) );
		var speed = transport.Add( UiStyle.Framed( new ComboBox( this ) { FixedWidth = 70, ToolTip = "Playback speed" } ) );
		foreach ( var s in new[] { 0.25f, 0.5f, 1f, 2f } )
		{
			var v = s;
			speed.AddItem( $"{s:0.##}×", null, () => controller.Speed = v, selected: s == 1f );
		}
		_loop = transport.Add( UiStyle.Toggle( this, "repeat", true, null, "Loop the action" ) );
		_loop.OnToggled = on => controller.Loop = on;

		_lanes = Layout.Add( new TimelineLanes( this, controller ) );

		controller.RoleChanged += Refresh;
		controller.Changed += Refresh;
	}

	private void Refresh()
	{
		_role.Text = AnimationRoles.Label( _c.Role );
		_lanes.Update();
	}

	/// <summary>Per frame: playhead, clock and play icon.</summary>
	public void Tick()
	{
		var seconds = _c.RoleSeconds( _c.Role );
		if ( !_scrubbing )
			_scrub.Value = _c.Time;
		_clock.Text = $"{_c.Time * seconds:0.00} / {seconds:0.00} s";
		var icon = _c.Playing ? "pause" : "play_arrow";
		if ( _play.Icon != icon )
		{
			_play.Icon = icon;
			_play.Update();
		}
		if ( _role.Text != AnimationRoles.Label( _c.Role ) )
			_role.Text = AnimationRoles.Label( _c.Role );
		_lanes.SetPlayhead( _c.Time );
	}

	public override void OnDestroyed()
	{
		_c.RoleChanged -= Refresh;
		_c.Changed -= Refresh;
		base.OnDestroyed();
	}
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Tool/UI/Widgets/Card.cs
Editor library
using Editor;
using Sandbox;

namespace WeaponImporter.Tool.UI;

/// <summary>
/// A rounded dark-gray panel on the gray window. Its column layout holds an optional header
/// row (icon, title, then controls) above the content.
/// </summary>
public class Card : Widget
{
	public Card( Widget parent, bool row = false ) : base( parent )
	{
		Layout = row ? Layout.Row() : Layout.Column();
		Layout.Margin = 10;
		Layout.Spacing = 8;
	}

	/// <summary>Adds the header row: an icon and a title, then whatever the caller adds to the returned row.</summary>
	public Layout Header( string icon, string title, string tooltip = null )
	{
		var row = Layout.AddRow();
		row.Spacing = 6;
		row.Add( new CardIcon( this, icon ) );
		var label = row.Add( new Label( title, this ) { ToolTip = tooltip } );
		label.SetStyles( "font-weight: 600;" );
		return row;
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.SetPen( Theme.ControlBackground.Lighten( .25f ), 1 );
		Paint.SetBrush( Theme.ControlBackground );
		Paint.DrawRect( LocalRect.Shrink( 1 ), 6 );
	}

	private sealed class CardIcon : Widget
	{
		private readonly string _icon;

		public CardIcon( Widget parent, string icon ) : base( parent )
		{
			_icon = icon;
			FixedSize = 18;
		}

		protected override void OnPaint()
		{
			Paint.SetPen( Theme.Green );
			Paint.DrawIcon( LocalRect, _icon, 16 );
		}
	}
}

/// <summary>A small round light before the status text: amber while working, green when ready, red on errors.</summary>
public sealed class StatusDot : Widget
{
	private Color _color = Theme.TextLight;

	public StatusDot( Widget parent ) : base( parent )
	{
		FixedSize = 10;
	}

	public Color Color
	{
		get => _color;
		set
		{
			if ( _color == value )
				return;
			_color = value;
			Update();
		}
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.ClearPen();
		Paint.SetBrush( _color );
		Paint.DrawRect( LocalRect.Shrink( 1 ), 4 );
	}
}

/// <summary>Small uppercase caption with a hairline, separating groups inside a card.</summary>
public sealed class SectionHeader : Widget
{
	private readonly string _text;

	public SectionHeader( Widget parent, string text ) : base( parent )
	{
		_text = text.ToUpperInvariant();
		FixedHeight = 18;
	}

	protected override void OnPaint()
	{
		Paint.SetDefaultFont( 7, 600 );
		Paint.SetPen( Theme.TextLight );
		var size = Paint.MeasureText( _text );
		Paint.DrawText( new Rect( 0, 0, size.x + 2, Height ), _text, TextFlag.LeftCenter );
		Paint.SetPen( Theme.ControlBackground.Lighten( .45f ), 1 );
		Paint.DrawLine( new Vector2( size.x + 10, Height * .5f ), new Vector2( Width, Height * .5f ) );
	}
}

/// <summary>Small muted uppercase column caption (list headers).</summary>
public sealed class SectionCaption : Widget
{
	private readonly string _text;
	private readonly TextFlag _align;

	public SectionCaption( Widget parent, string text, float width, TextFlag align = TextFlag.LeftCenter ) : base( parent )
	{
		_text = text.ToUpperInvariant();
		_align = align;
		FixedHeight = 16;
		if ( width > 0 )
			FixedWidth = width;
	}

	protected override void OnPaint()
	{
		Paint.SetDefaultFont( 7, 600 );
		Paint.SetPen( Theme.TextLight );
		Paint.DrawText( LocalRect, _text, _align );
	}
}
notpointless.chomnr_weapon_importer / Code/WeaponImporter/WeaponGripProfile.cs
Game library
namespace WeaponImporter;

/// <summary>
/// A reusable grip: how a character holds one weapon, made by the Weapon Importer. Transforms are
/// "px,py,pz,qx,qy,qz,qw" in the weapon model's space, finger poses "bone=qx,qy,qz,qw;..." (the
/// same formats as <see cref="WeaponHold"/>). Assign it to a Weapon Hold to use it; nothing here
/// is computed at runtime beyond the small IK correction.
/// </summary>
[AssetType( Name = "Weapon Grip Profile", Extension = "wgrip", Category = "Weapons" )]
public sealed class WeaponGripProfile : GameResource
{
    [Header( "Hands" )]
    /// <summary>The hand that holds the weapon's main grip ("R" or "L").</summary>
    public string PrimaryHand { get; set; } = "R";

    /// <summary>Primary hand bone in weapon space.</summary>
    public string PrimaryGripTransform { get; set; } = "";

    /// <summary>Primary hand finger rotations.</summary>
    public string PrimaryFingerPose { get; set; } = "";

    /// <summary>Support hand bone in weapon space ("" = one-handed).</summary>
    public string SecondaryGripTransform { get; set; } = "";

    /// <summary>Support hand finger rotations.</summary>
    public string SecondaryFingerPose { get; set; } = "";

    /// <summary>Where the index finger rests, weapon space (zero = no trigger).</summary>
    public Vector3 TriggerTarget { get; set; }

    /// <summary>Where the elbows point, weapon space; the arm IK leans toward them (keeps elbows steady).</summary>
    public Vector3 PrimaryElbowHint { get; set; }
    public Vector3 SecondaryElbowHint { get; set; }

    public bool TwoHanded { get; set; } = true;

    [Header( "Weapon" )]
    /// <summary>Weapon model relative to the hold bone.</summary>
    public string WeaponOffset { get; set; } = "";

    /// <summary>Character bone the weapon is held by.</summary>
    public string HoldBone { get; set; } = "hold_R";

    /// <summary>Dual weapons: the left-hand copy's root bone ("" = a single weapon).</summary>
    public string SecondWeaponBone { get; set; } = "";

    /// <summary>Dual weapons: the character bone the left-hand copy is held by.</summary>
    public string SecondHoldBone { get; set; } = "";

    /// <summary>Dual weapons: the left-hand copy's bone relative to <see cref="SecondHoldBone"/>.</summary>
    public string SecondWeaponOffset { get; set; } = "";

    [Header( "Contact" )]
    /// <summary>Per-action hand contact tracks (JSON, see <see cref="WeaponHold.Contacts"/>).</summary>
    public string Contacts { get; set; } = "";

    /// <summary>Per-action timing, weapon sequence and trigger parameter (JSON).</summary>
    public string Actions { get; set; } = "";

    [Range( 0, 1 )] public float Smoothing { get; set; } = 0.35f;
    public float ReachSlack { get; set; } = 1.5f;
    [Range( 0, 1 )] public float TransitionSeconds { get; set; } = 0.15f;

    [Header( "Third person" )]
    /// <summary>Citizen holdtype the weapon was fitted to (-1 = leave to the game).</summary>
    public int HoldType { get; set; } = -1;

    /// <summary>Character model the grip was fitted to.</summary>
    public string Character { get; set; } = "";

    /// <summary>Replacement character animations per action (JSON).</summary>
    public string CharacterActions { get; set; } = "";

    [Header( "First person" )]
    /// <summary>Weapon sequence for each first-person action (JSON: role → sequence).</summary>
    public string FirstPersonSequences { get; set; } = "";

    /// <summary>Suggested first-person field of view.</summary>
    public float FirstPersonFieldOfView { get; set; } = 70f;
}
notpointless.chomnr_weapon_importer / .obj/__compiler_extra.cs
Game library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Weapon Importer" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "chomnr_weapon_importer" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "notpointless" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "notpointless.chomnr_weapon_importer" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "29" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]

[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-09-26T07:52:10.4512751Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.120.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.120.0")]
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Core/Import/GltfWeaponReader.cs
Editor library
#nullable enable annotations

using System.Numerics;
using System.Text.Json;
using WeaponImporter.Core.Formats.Gltf;
using WeaponImporter.Core.Geometry;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Rig;
using WeaponImporter.Core.Weapon;

namespace WeaponImporter.Core.Import;

using Vector2 = System.Numerics.Vector2;
using Vector3 = System.Numerics.Vector3;

/// <summary>Reads a glTF/GLB weapon: skeleton, clips and bind-pose triangles in model space.</summary>
public static class GltfWeaponReader
{
    public static WeaponAsset Read(byte[] data, string name, string sourcePath = "", Func<string, byte[]>? externalBuffers = null, float sampleFps = 30f)
    {
        var imported = GltfImporter.Import(data, new GltfImportOptions { SampleFps = sampleFps, ExternalBufferResolver = externalBuffers }, out var document, out var boneByNode);
        var conversion = SpaceConversion.For(imported);
        var skeleton = conversion.Skeleton(imported.Skeleton);
        var clips = conversion.Clips(imported.Clips, imported.Skeleton, skeleton);

        var root = document.Root;
        var nodes = document.Nodes;
        var accessors = root.TryGetProperty("accessors", out var acc) ? acc : default;
        var views = root.TryGetProperty("bufferViews", out var bv) ? bv : default;
        if (!root.TryGetProperty("meshes", out var meshes) || meshes.ValueKind != JsonValueKind.Array)
            throw new FormatException("The glTF contains no meshes.");
        root.TryGetProperty("skins", out var skins);
        root.TryGetProperty("nodes", out var nodeArray);

        // World matrices (meters) of every node.
        var world = new Matrix4x4[nodes.Count];
        var done = new bool[nodes.Count];
        Matrix4x4 World(int i)
        {
            if (done[i])
                return world[i];
            var n = nodes[i];
            var local = Matrix4x4.CreateScale(n.Scale) * Matrix4x4.CreateFromQuaternion(n.Rotation) * Matrix4x4.CreateTranslation(n.Translation);
            done[i] = true; // guards malformed cycles
            world[i] = n.Parent >= 0 ? local * World(n.Parent) : local;
            return world[i];
        }

        int BoneOf(int node)
        {
            for (var n = node; n >= 0; n = nodes[n].Parent)
                if (boneByNode.TryGetValue(n, out var boneName))
                    return skeleton.IndexOf(boneName);
            return 0;
        }

        var positions = new List<Vector3>();
        var vertexBone = new List<int>();
        var indices = new List<int>();
        var triPart = new List<int>();
        var partNames = new List<string>();
        var vertexNormals = new List<Vector3>();
        var vertexUVs = new List<Vector2>();
        var triMaterial = new List<int>();
        var anyNormals = false;
        var anyUVs = false;
        var materialReader = new GltfMaterialReader(document, sourcePath, externalBuffers);

        for (var nodeIndex = 0; nodeIndex < nodes.Count; nodeIndex++)
        {
            var nodeJson = nodeArray[nodeIndex];
            if (!nodeJson.TryGetProperty("mesh", out var meshIndexProp))
                continue;
            var meshIndex = meshIndexProp.GetInt32();
            if (meshIndex < 0 || meshIndex >= meshes.GetArrayLength())
                throw new FormatException($"glTF node {nodeIndex} references missing mesh {meshIndex}.");
            var meshJson = meshes[meshIndex];
            int[]? skinJoints = null;
            Matrix4x4[]? inverseBind = null;
            if (nodeJson.TryGetProperty("skin", out var skinProp) && skins.ValueKind == JsonValueKind.Array)
            {
                var skin = skins[skinProp.GetInt32()];
                skinJoints = skin.GetProperty("joints").EnumerateArray().Select(j => j.GetInt32()).ToArray();
                if (skin.TryGetProperty("inverseBindMatrices", out var ibm))
                {
                    var floats = ReadFloats(accessors, views, document.Buffers, ibm.GetInt32(), 16);
                    inverseBind = new Matrix4x4[skinJoints.Length];
                    for (var j = 0; j < skinJoints.Length && (j + 1) * 16 <= floats.Length; j++)
                    {
                        var f = floats.AsSpan(j * 16, 16);
                        // glTF matrices are column-major column-vector = row-major row-vector.
                        inverseBind[j] = new Matrix4x4(f[0], f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8], f[9], f[10], f[11], f[12], f[13], f[14], f[15]);
                    }
                }
            }

            var nodeBone = BoneOf(nodeIndex);
            var partName = nodes[nodeIndex].Name ?? (meshJson.TryGetProperty("name", out var mn) ? mn.GetString() : null) ?? $"mesh_{meshIndex}";
            var part = partNames.Count;
            partNames.Add(partName);

            foreach (var prim in meshJson.GetProperty("primitives").EnumerateArray())
            {
                var mode = prim.TryGetProperty("mode", out var modeProp) ? modeProp.GetInt32() : 4;
                if (mode != 4)
                    continue; // points/lines/strips are not solid geometry
                var attributes = prim.GetProperty("attributes");
                if (!attributes.TryGetProperty("POSITION", out var posProp))
                    continue;
                var pos = ReadFloats(accessors, views, document.Buffers, posProp.GetInt32(), 3);
                var count = pos.Length / 3;
                float[]? joints = null, weights = null;
                if (skinJoints is not null && attributes.TryGetProperty("JOINTS_0", out var jp) && attributes.TryGetProperty("WEIGHTS_0", out var wp))
                {
                    joints = ReadFloats(accessors, views, document.Buffers, jp.GetInt32(), 4, raw: true);
                    weights = ReadFloats(accessors, views, document.Buffers, wp.GetInt32(), 4);
                }

                float[]? nrm = null, tex = null;
                if (attributes.TryGetProperty("NORMAL", out var nrmProp))
                {
                    nrm = ReadFloats(accessors, views, document.Buffers, nrmProp.GetInt32(), 3);
                    if (nrm.Length / 3 < count)
                        nrm = null;
                }
                if (attributes.TryGetProperty("TEXCOORD_0", out var texProp))
                {
                    tex = ReadFloats(accessors, views, document.Buffers, texProp.GetInt32(), 2);
                    if (tex.Length / 2 < count)
                        tex = null;
                }
                anyNormals |= nrm is not null;
                anyUVs |= tex is not null;
                var material = materialReader.MaterialIndex(prim.TryGetProperty("material", out var matProp) ? matProp.GetInt32() : -1);

                var baseVertex = positions.Count;
                var nodeWorld = World(nodeIndex);
                var anchorOfNode = Anchor(nodeIndex);
                var invAnchor = Matrix4x4.Identity;
                if (anchorOfNode >= 0 && Matrix4x4.Invert(World(anchorOfNode), out var inv))
                {
                    // Rigid rest transforms carry no scale; keep the anchor's.
                    Matrix4x4.Decompose(World(anchorOfNode), out var anchorScale, out _, out _);
                    invAnchor = inv * Matrix4x4.CreateScale(anchorScale);
                }
                var nodeNormal = NormalMatrix(nodeWorld);
                var anchoredNormal = NormalMatrix(nodeWorld * invAnchor);
                for (var v = 0; v < count; v++)
                {
                    var p = new Vector3(pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]);
                    var n = nrm is not null ? new Vector3(nrm[v * 3], nrm[v * 3 + 1], nrm[v * 3 + 2]) : Vector3.Zero;
                    int bone;
                    Vector3 model;
                    Vector3 normal;
                    if (joints is not null && weights is not null)
                    {
                        var best = 0;
                        for (var k = 1; k < 4; k++)
                            if (weights[v * 4 + k] > weights[v * 4 + best])
                                best = k;
                        var jointSlot = (int)joints[v * 4 + best];
                        var jointNode = jointSlot >= 0 && jointSlot < skinJoints!.Length ? skinJoints[jointSlot] : nodeIndex;
                        bone = BoneOf(jointNode);
                        // Mesh space -> joint bind space -> joint rest world. The full node matrix
                        // is used (rigid rest transforms drop inherited scale, which would pull
                        // parts on differently scaled joints apart).
                        var ibm = inverseBind is not null && jointSlot < inverseBind.Length ? inverseBind[jointSlot] : Matrix4x4.Identity;
                        var bindWorld = Vector3.Transform(Vector3.Transform(p, ibm), World(jointNode)) * 100f;
                        model = conversion.Point(bindWorld);
                        normal = conversion.Direction(Vector3.TransformNormal(n, NormalMatrix(ibm * World(jointNode))));
                    }
                    else
                    {
                        bone = nodeBone;
                        var anchorNode = anchorOfNode;
                        var rel = Vector3.Transform(Vector3.Transform(p, nodeWorld), invAnchor) * 100f;
                        var anchored = anchorNode >= 0 && boneByNode.ContainsKey(anchorNode);
                        model = anchored
                            ? skeleton.RestWorld[bone].TransformPoint(conversion.Direction(rel) * conversion.Scale)
                            : conversion.Point(Vector3.Transform(p, nodeWorld) * 100f);
                        normal = anchored
                            ? skeleton.RestWorld[bone].TransformVector(conversion.Direction(Vector3.TransformNormal(n, anchoredNormal)))
                            : conversion.Direction(Vector3.TransformNormal(n, nodeNormal));
                    }
                    positions.Add(model);
                    vertexBone.Add(bone);
                    var len = normal.Length();
                    vertexNormals.Add(nrm is not null && len > 1e-12f && float.IsFinite(len) ? normal / len : new Vector3(float.NaN));
                    // glTF UVs already have the top-left origin TriMesh uses.
                    vertexUVs.Add(tex is not null ? new Vector2(tex[v * 2], tex[v * 2 + 1]) : Vector2.Zero);
                }

                if (prim.TryGetProperty("indices", out var idxProp))
                {
                    var idx = ReadFloats(accessors, views, document.Buffers, idxProp.GetInt32(), 1, raw: true);
                    for (var i = 0; i + 2 < idx.Length; i += 3)
                    {
                        var a = (int)idx[i]; var b = (int)idx[i + 1]; var c = (int)idx[i + 2];
                        if ((uint)a >= (uint)count || (uint)b >= (uint)count || (uint)c >= (uint)count)
                            throw new FormatException($"glTF mesh '{partName}' has out-of-range indices.");
                        indices.Add(baseVertex + a); indices.Add(baseVertex + b); indices.Add(baseVertex + c);
                        triPart.Add(part);
                        triMaterial.Add(material);
                    }
                }
                else
                {
                    for (var i = 0; i + 2 < count; i += 3)
                    {
                        indices.Add(baseVertex + i); indices.Add(baseVertex + i + 1); indices.Add(baseVertex + i + 2);
                        triPart.Add(part);
                        triMaterial.Add(material);
                    }
                }
                if (indices.Count / 3 > FbxWeaponReader.MaxTriangles)
                    throw new FormatException($"The model has more than {FbxWeaponReader.MaxTriangles:N0} triangles; decimate it before importing.");
            }
        }

        int Anchor(int node)
        {
            for (var n = node; n >= 0; n = nodes[n].Parent)
                if (boneByNode.ContainsKey(n))
                    return n;
            return -1;
        }

        if (indices.Count == 0)
            throw new FormatException("The glTF contains no triangle geometry.");

        var indexArray = indices.ToArray();
        Vector3[]? cornerNormals = null;
        if (anyNormals)
        {
            cornerNormals = indexArray.Select(i => vertexNormals[i]).ToArray();
            if (cornerNormals.Any(n => float.IsNaN(n.X)))
            {
                var smooth = new TriMesh(positions.ToArray(), indexArray).EnsureNormals();
                for (var c = 0; c < cornerNormals.Length; c++)
                    if (float.IsNaN(cornerNormals[c].X))
                        cornerNormals[c] = smooth[c];
            }
        }
        var cornerUVs = anyUVs ? indexArray.Select(i => vertexUVs[i]).ToArray() : null;

        var asset = new WeaponAsset
        {
            Name = name,
            Kind = SourceKind.Gltf,
            SourcePath = sourcePath,
            Skeleton = skeleton,
            Mesh = new TriMesh(positions.ToArray(), indexArray, vertexBone.ToArray(), triPart.ToArray(), partNames,
                cornerNormals, cornerUVs, triMaterial.ToArray(), materialReader.Materials),
            Clips = clips,
        };
        asset.Notes.AddRange(imported.Notes);
        asset.Notes.AddRange(materialReader.Notes.Distinct().Take(20));
        return asset;
    }

    /// <summary>Row-vector normal matrix: inverse transpose of the linear part.</summary>
    private static Matrix4x4 NormalMatrix(Matrix4x4 m)
    {
        m.M41 = m.M42 = m.M43 = 0f;
        return Matrix4x4.Invert(m, out var inv) ? Matrix4x4.Transpose(inv) : m;
    }

    /// <summary>Accessor reader for any component type; <paramref name="raw"/> skips normalisation (indices, joints).</summary>
    private static float[] ReadFloats(JsonElement accessors, JsonElement views, List<byte[]> buffers, int index, int comps, bool raw = false)
    {
        if (accessors.ValueKind != JsonValueKind.Array || index < 0 || index >= accessors.GetArrayLength())
            throw new FormatException($"glTF accessor {index} does not exist.");
        var accessor = accessors[index];
        if (accessor.TryGetProperty("sparse", out _))
            throw new FormatException("glTF sparse accessors are not supported.");
        var type = accessor.GetProperty("type").GetString();
        var actual = type switch { "SCALAR" => 1, "VEC2" => 2, "VEC3" => 3, "VEC4" => 4, "MAT4" => 16, _ => throw new FormatException($"glTF accessor type '{type}' is not supported.") };
        if (actual != comps)
            throw new FormatException($"glTF accessor {index} is {type}, expected {comps} components.");
        var count = accessor.GetProperty("count").GetInt32();
        var componentType = accessor.GetProperty("componentType").GetInt32();
        var normalized = accessor.TryGetProperty("normalized", out var n) && n.GetBoolean();
        var size = componentType switch { 5126 or 5125 => 4, 5122 or 5123 => 2, 5120 or 5121 => 1, _ => throw new FormatException($"glTF component type {componentType} is not supported.") };
        if (count < 0 || count > 50_000_000)
            throw new FormatException($"glTF accessor {index} has an invalid count.");
        var result = new float[count * comps];
        if (!accessor.TryGetProperty("bufferView", out var viewProp))
            return result;
        var view = views[viewProp.GetInt32()];
        var buffer = buffers[view.GetProperty("buffer").GetInt32()];
        var start = (long)(view.TryGetProperty("byteOffset", out var vo) ? vo.GetInt32() : 0) + (accessor.TryGetProperty("byteOffset", out var ao) ? ao.GetInt32() : 0);
        var stride = view.TryGetProperty("byteStride", out var st) ? st.GetInt32() : size * comps;
        if (count > 0 && start + (long)(count - 1) * stride + size * comps > buffer.Length)
            throw new FormatException($"glTF accessor {index} reads past the end of its buffer (truncated file?).");
        for (var e = 0; e < count; e++)
        {
            var offset = (int)(start + (long)e * stride);
            for (var c = 0; c < comps; c++)
            {
                var at = offset + c * size;
                float value = componentType switch
                {
                    5126 => BitConverter.ToSingle(buffer, at),
                    5125 => BitConverter.ToUInt32(buffer, at),
                    5123 => BitConverter.ToUInt16(buffer, at),
                    5122 => BitConverter.ToInt16(buffer, at),
                    5121 => buffer[at],
                    _ => (sbyte)buffer[at],
                };
                if (!raw && normalized)
                    value = componentType switch { 5121 => value / 255f, 5123 => value / 65535f, 5120 => MathF.Max(value / 127f, -1f), 5122 => MathF.Max(value / 32767f, -1f), _ => value };
                result[e * comps + c] = value;
            }
        }
        return result;
    }
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Core/Import/SpaceConversion.cs
Editor library
#nullable enable annotations

using System.Numerics;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Rig;

namespace WeaponImporter.Core.Import;

using Vector3 = System.Numerics.Vector3;

/// <summary>
/// Converts a source scene (centimetres, file axes) into s&amp;box model space (inches, +Z up).
/// The file's up axis becomes +Z, its coordinate (right) axis becomes +X and its front axis
/// becomes -Y, which matches how ModelDoc imports Y-up FBX files.
/// </summary>
public sealed class SpaceConversion
{
    public const float CmToInch = 1f / 2.54f;

    /// <summary>Row-vector basis change: v_sbox = v_file * Basis.</summary>
    public Matrix4x4 Basis { get; }

    public float Scale { get; }

    public SpaceConversion(int upAxis, int upSign, int frontAxis, int frontSign, int coordAxis, int coordSign, float scale = CmToInch)
    {
        var m = new Matrix4x4();
        void Set(int row, int col, float v)
        {
            switch (row * 4 + col)
            {
                case 0: m.M11 = v; break; case 1: m.M12 = v; break; case 2: m.M13 = v; break;
                case 4: m.M21 = v; break; case 5: m.M22 = v; break; case 6: m.M23 = v; break;
                case 8: m.M31 = v; break; case 9: m.M32 = v; break; case 10: m.M33 = v; break;
            }
        }
        if (upAxis == frontAxis || upAxis == coordAxis || frontAxis == coordAxis)
        {
            // Malformed axis record: assume the FBX default (Y up, Z front, X right).
            (upAxis, frontAxis, coordAxis) = (1, 2, 0);
        }
        Set(coordAxis, 0, coordSign >= 0 ? 1f : -1f);
        Set(frontAxis, 1, frontSign >= 0 ? -1f : 1f);
        Set(upAxis, 2, upSign >= 0 ? 1f : -1f);
        m.M44 = 1f;
        Basis = m;
        Scale = scale;
    }

    public static SpaceConversion For(SourceScene scene)
        => new(scene.UpAxis, scene.UpAxisSign, scene.FrontAxis, scene.FrontAxisSign, scene.CoordAxis, scene.CoordAxisSign);

    public static SpaceConversion Identity { get; } = new(2, 1, 1, -1, 0, 1, 1f);

    public Vector3 Point(Vector3 cm) => Vector3.Transform(cm, Basis) * Scale;

    public Vector3 Direction(Vector3 v) => Vector3.Transform(v, Basis);

    public Quaternion Rotation(Quaternion q)
    {
        // Similarity transform keeps a proper rotation even when the file axes are mirrored.
        var r = Matrix4x4.CreateFromQuaternion(q);
        var t = Matrix4x4.Transpose(Basis);
        var s = t * r * Basis;
        s.M14 = s.M24 = s.M34 = s.M41 = s.M42 = s.M43 = 0f;
        s.M44 = 1f;
        return MathQ.Normalize(Quaternion.CreateFromRotationMatrix(s));
    }

    public XForm Transform(XForm x) => new(Point(x.Pos), Rotation(x.Rot));

    public Skeleton Skeleton(Skeleton source)
    {
        var defs = new List<BoneDefinition>(source.Count);
        for (var i = 0; i < source.Count; i++)
        {
            var b = source[i];
            defs.Add(new BoneDefinition(b.Name, b.ParentIndex < 0 ? null : source[b.ParentIndex].Name, Transform(b.RestLocal)));
        }
        return Rig.Skeleton.Create(defs);
    }

    /// <summary>Clips re-expressed on the converted skeleton (same bone order by name).</summary>
    public List<Clip> Clips(IReadOnlyList<Clip> clips, Skeleton source, Skeleton converted)
    {
        var map = new int[source.Count];
        for (var i = 0; i < source.Count; i++)
            map[i] = converted.IndexOf(source[i].Name);
        var result = new List<Clip>(clips.Count);
        foreach (var clip in clips)
        {
            var frames = new List<XForm[]>(clip.FrameCount);
            foreach (var frame in clip.Frames)
            {
                var locals = new XForm[converted.Count];
                for (var i = 0; i < source.Count && i < frame.Length; i++)
                    if (map[i] >= 0)
                        locals[map[i]] = Transform(frame[i]);
                frames.Add(locals);
            }
            result.Add(new Clip(clip.Name, clip.Fps, clip.Looping, frames, clip.NativeFps));
        }
        return result;
    }
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Core/Rig/Clip.cs
Editor library
#nullable enable annotations

using System;
using System.Collections.Generic;
using WeaponImporter.Core.Maths;

namespace WeaponImporter.Core.Rig;

/// <summary>
/// A sampled animation clip: a fixed-rate sequence of frames, each holding one
/// parent-relative local transform per bone (skeleton bone order). Clips are always
/// resampled at ingest — no key data is preserved.
/// </summary>
public sealed class Clip
{
    /// <summary>Clip (sequence) name.</summary>
    public string Name { get; }

    /// <summary>Sample rate in frames per second.</summary>
    public float Fps { get; }

    /// <summary>
    /// Native frame rate of the take in the SOURCE file (FBX GlobalSettings
    /// TimeMode/CustomFrameRate, BVH 1/FrameTime). External frame ranges — Unity
    /// <c>.fbx.meta</c> <c>clipAnimations</c> definitions — are expressed in THIS rate, so
    /// they must be rescaled by <c>Fps / NativeFps</c> to index the resampled
    /// <see cref="Frames"/>. Equals <see cref="Fps"/> when the importer records no native rate.
    /// </summary>
    public float NativeFps { get; }

    /// <summary>Whether the clip is authored to loop.</summary>
    public bool Looping { get; }

    /// <summary>Frames in playback order; each entry is one local transform per bone.</summary>
    public List<XForm[]> Frames { get; }

    /// <summary>Number of frames currently in the clip.</summary>
    public int FrameCount => Frames.Count;

    /// <summary>
    /// Clip duration in seconds at <see cref="Fps"/>: the time span between the first and the
    /// last sample, <c>(FrameCount - 1) / Fps</c> (frames are fence posts, intervals are the
    /// spans between them — matching the DMX timeFrame this clip serializes to). Zero for
    /// empty and single-frame clips.
    /// </summary>
    public float Duration => FrameCount <= 1 ? 0f : (FrameCount - 1) / Fps;

    /// <summary>Creates an empty clip.</summary>
    /// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="fps"/> is not positive.</exception>
    public Clip(string name, float fps, bool looping)
        : this(name, fps, looping, new List<XForm[]>())
    {
    }

    /// <summary>Creates a clip wrapping an existing frame list (not copied).</summary>
    /// <param name="nativeFps">Source-file native frame rate (<see cref="NativeFps"/>);
    /// null = same as <paramref name="fps"/>.</param>
    /// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="fps"/> (or a
    /// provided <paramref name="nativeFps"/>) is not positive.</exception>
    public Clip(string name, float fps, bool looping, List<XForm[]> frames, float? nativeFps = null)
    {
        ArgumentNullException.ThrowIfNull(name);
        ArgumentNullException.ThrowIfNull(frames);
        if (!(fps > 0f) || !float.IsFinite(fps))
            throw new ArgumentOutOfRangeException(nameof(fps), fps, "Fps must be a positive finite number.");
        if (nativeFps is { } native && (!(native > 0f) || !float.IsFinite(native)))
            throw new ArgumentOutOfRangeException(nameof(nativeFps), native, "NativeFps must be a positive finite number.");

        Name = name;
        Fps = fps;
        NativeFps = nativeFps ?? fps;
        Looping = looping;
        Frames = frames;
    }
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Tool/UI/Preview/WeaponViewport.Picking.cs
Editor library
using Editor;
using Sandbox;
using WeaponImporter.Core.Hands;

namespace WeaponImporter.Tool.UI;

/// <summary>Clicks in the preview: attachment markers, arms (per-action release) and the weapon surface.</summary>
public sealed partial class WeaponViewport
{
	private const float MarkerRadiusPx = 14f;
	private const float ArmRadiusPx = 12f;

	private readonly Dictionary<int, Side?> _boneSide = new();

	/// <summary>Right/left from the bone name suffix (hand_R, arm_lower_L, finger_index_0_R...).</summary>
	private Side? SideOf( int bone )
	{
		if ( _boneSide.TryGetValue( bone, out var side ) )
			return side;
		var name = _body.IsValid() && _body.Model is { } m ? m.GetBoneName( bone ).ToLowerInvariant() : "";
		side = name.EndsWith( "_r" ) ? Side.Right : name.EndsWith( "_l" ) ? Side.Left : null;
		_boneSide[bone] = side;
		return side;
	}

	private bool ToScreen( Vector3 world, out Vector2 screen )
	{
		screen = Camera.PointToScreenPixels( world, out var behind );
		return !behind;
	}

	/// <summary>The muzzle or eject marker under a viewport pixel (None when neither).</summary>
	public PickTarget HitMarker( Vector2 local )
	{
		var setup = _c.Setup;
		if ( setup is null || !Camera.IsValid() || !_weaponObject.IsValid() || FirstPerson )
			return PickTarget.None;
		var weapon = WeaponWorld;
		var best = PickTarget.None;
		var bestDistance = MarkerRadiusPx;
		foreach ( var (target, point) in new[] { (PickTarget.Muzzle, setup.Muzzle), (PickTarget.Eject, setup.Eject) } )
		{
			if ( point is null || !ToScreen( weapon.PointToWorld( Core.Setup.V.Of( point.Canonical ).ToEngine() ), out var screen ) )
				continue;
			var d = (screen - local).Length;
			if ( d < bestDistance )
			{
				bestDistance = d;
				best = target;
			}
		}
		return best;
	}

	/// <summary>Which character arm (upper arm, forearm, hand or fingers) is under a viewport pixel.</summary>
	public Side? HitArm( Vector2 local )
	{
		if ( !Camera.IsValid() || !_body.IsValid() || !_body.SceneModel.IsValid() || FirstPerson )
			return null;
		Side? best = null;
		var bestDistance = ArmRadiusPx;
		foreach ( var (bone, parent) in _armBones )
		{
			if ( SideOf( bone ) is not { } side )
				continue;
			if ( !ToScreen( _body.SceneModel.GetBoneWorldTransform( parent ).Position, out var a ) || !ToScreen( _body.SceneModel.GetBoneWorldTransform( bone ).Position, out var b ) )
				continue;
			var d = DistanceToSegment( local, a, b );
			if ( d < bestDistance )
			{
				bestDistance = d;
				best = side;
			}
		}
		return best;
	}

	private static float DistanceToSegment( Vector2 p, Vector2 a, Vector2 b )
	{
		var ab = b - a;
		var lengthSq = ab.x * ab.x + ab.y * ab.y;
		var t = lengthSq < 1e-4f ? 0f : Math.Clamp( ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / lengthSq, 0f, 1f );
		return (p - (a + ab * t)).Length;
	}

	/// <summary>Where a marker is on screen (gate and tests), or null.</summary>
	public Vector2? MarkerScreenPosition( PickTarget target )
	{
		var point = target == PickTarget.Muzzle ? _c.Setup?.Muzzle : target == PickTarget.Eject ? _c.Setup?.Eject : null;
		if ( point is null || !Camera.IsValid() || !_weaponObject.IsValid() )
			return null;
		return ToScreen( WeaponWorld.PointToWorld( Core.Setup.V.Of( point.Canonical ).ToEngine() ), out var s ) ? s : null;
	}

	/// <summary>Middle of a forearm on screen (gate and tests), or null.</summary>
	public Vector2? ArmScreenPosition( Side side )
	{
		if ( !Camera.IsValid() || !_body.IsValid() || !_body.SceneModel.IsValid() )
			return null;
		foreach ( var (bone, parent) in _armBones )
		{
			if ( SideOf( bone ) != side || !_body.Model.GetBoneName( bone ).StartsWith( "hand", StringComparison.OrdinalIgnoreCase ) )
				continue;
			var mid = (_body.SceneModel.GetBoneWorldTransform( parent ).Position + _body.SceneModel.GetBoneWorldTransform( bone ).Position) * 0.5f;
			return ToScreen( mid, out var s ) ? s : null;
		}
		return null;
	}

	public int FirstPersonPieceCount => _fpPieces.Count;

	/// <summary>The hand whose grip contact marker is under a viewport pixel (drag to move the grip).</summary>
	public Side? HitGripContact( Vector2 local )
	{
		var setup = _c.Setup;
		if ( setup is null || !_c.ShowContacts || !Camera.IsValid() || !_weaponObject.IsValid() || OwnArmsView )
			return null;
		var weapon = WeaponWorld;
		Side? best = null;
		var bestDistance = MarkerRadiusPx;
		foreach ( var (side, grip) in new[] { (Side.Right, setup.Primary), (Side.Left, setup.UseSupportHand ? setup.Support : null) } )
		{
			if ( grip is null || !ToScreen( weapon.PointToWorld( Core.Setup.V.Of( grip.Contact ).ToEngine() ), out var screen ) )
				continue;
			var d = (screen - local).Length;
			if ( d < bestDistance )
			{
				bestDistance = d;
				best = side;
			}
		}
		return best;
	}

	/// <summary>Hand grip being dragged across the weapon, or null.</summary>
	private Side? _dragGrip;

	/// <summary>Starts dragging a grip contact when the press lands on one.</summary>
	private bool BeginGripDrag( Vector2 local )
	{
		if ( _c.Pick != PickTarget.None || HitGripContact( local ) is not { } side )
			return false;
		_dragGrip = side;
		Cursor = CursorShape.ClosedHand;
		_c.SetStatus( $"Moving the {(side == Side.Right ? "right" : "left")} grip: drag across the weapon.", Theme.Blue );
		return true;
	}

	private void DragGrip( Vector2 local )
	{
		if ( _dragGrip is not { } side )
			return;
		if ( RaycastWeapon( local ) is { } hit )
			_c.DragGrip( side, hit.Point, hit.Normal );
	}

	private void EndGripDrag()
	{
		if ( _dragGrip is null )
			return;
		_dragGrip = null;
		Cursor = CursorShape.Arrow;
		_c.EndDragGrip();
	}

	/// <summary>A plain left click (no drag) in the preview.</summary>
	private void Click( Vector2 local )
	{
		var pick = _c.Pick;
		if ( pick is PickTarget.Muzzle or PickTarget.Eject )
		{
			// Another marker selects that one; the weapon surface moves the selected one; anything else deselects.
			var marker = HitMarker( local );
			if ( marker != PickTarget.None && marker != pick )
			{
				_c.BeginPick( marker );
				return;
			}
			if ( RaycastWeapon( local ) is { } hit )
				_c.CompletePick( hit.Point, hit.Normal );
			else
			{
				_c.BeginPick( PickTarget.None );
				_c.SetStatus( "Marker deselected.", Theme.TextLight );
			}
			return;
		}
		if ( pick != PickTarget.None )
		{
			if ( RaycastWeapon( local ) is { } hit )
				_c.CompletePick( hit.Point, hit.Normal );
			else
				_c.SetStatus( "That missed the weapon. " + ImporterController.PickHint( pick ), Theme.Yellow );
			return;
		}

		var clicked = HitMarker( local );
		if ( clicked != PickTarget.None )
		{
			_c.BeginPick( clicked );
			return;
		}
		if ( HitArm( local ) is { } side )
			_c.ToggleHand( _c.Role, side );
	}
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Tool/UI/Steps/StepPanel.cs
Editor library
using Editor;
using Sandbox;

namespace WeaponImporter.Tool.UI;

/// <summary>
/// One step of the workflow in the left column. Steps build their cards from the session and
/// rebuild only when their structure changes (a new weapon, a different part list...); value
/// changes just run the registered refreshers, so a slider being dragged is never destroyed.
/// Rebuilds are deferred to the next frame so a widget is never deleted inside its own callback.
/// </summary>
public abstract class StepPanel : Widget
{
	protected readonly ImporterController C;
	private readonly List<Action> _refreshers = new();
	private string _builtKey;
	private bool _dirty = true;

	protected StepPanel( Widget parent, ImporterController controller ) : base( parent )
	{
		C = controller;
		Layout = Layout.Column();
		Layout.Spacing = 10;
		controller.Changed += MarkDirty;
		controller.SessionReplaced += ForceRebuild;
	}

	public abstract string Title { get; }

	/// <summary>Anything that, when different, needs new widgets rather than new values.</summary>
	protected abstract string StructureKey();

	protected abstract void Build();

	public void MarkDirty() => _dirty = true;

	public void ForceRebuild()
	{
		_builtKey = null;
		_dirty = true;
	}

	/// <summary>Called every frame by the window while this step is visible.</summary>
	public void Flush()
	{
		if ( !_dirty )
			return;
		_dirty = false;
		string key;
		try
		{
			key = C.Session?.Setup is null ? "" : StructureKey();
		}
		catch ( Exception )
		{
			key = Guid.NewGuid().ToString();
		}
		if ( key != _builtKey )
		{
			_builtKey = key;
			Rebuild();
			return;
		}
		foreach ( var refresh in _refreshers.ToArray() )
		{
			try
			{
				refresh();
			}
			catch ( Exception e )
			{
				Log.Warning( $"[weapon importer] refresh failed: {e.Message}" );
			}
		}
	}

	private void Rebuild()
	{
		Layout.Clear( true );
		_refreshers.Clear();
		if ( C.Session?.Setup is null )
			return;
		try
		{
			Build();
		}
		catch ( Exception e )
		{
			Log.Warning( $"[weapon importer] {Title} panel failed: {e}" );
			Layout.Add( UiStyle.Colored( new Label( $"This panel could not be shown: {e.Message}", this ) { WordWrap = true }, Theme.Red ) );
		}
		Layout.AddStretchCell();
	}

	/// <summary>Registers a refresher and runs it once now.</summary>
	protected void Bind( Action refresh )
	{
		_refreshers.Add( refresh );
		refresh();
	}

	protected Card AddCard( string icon, string title, out Layout header, string tooltip = null )
	{
		var card = Layout.Add( new Card( this ) );
		header = card.Header( icon, title, tooltip );
		return card;
	}

	/// <summary>A labelled slider with a live value readout.</summary>
	protected FloatSlider Slider( Widget owner, Layout parent, string caption, string tooltip, float min, float max, float value, Func<float, string> format, Action<float> edited, float step = 0f )
	{
		var row = UiStyle.FieldRow( owner, parent, caption, tooltip );
		var slider = row.Add( new FloatSlider( owner ) { Minimum = min, Maximum = max, Value = value, ToolTip = tooltip, FixedHeight = UiStyle.ControlHeight }, 1 );
		if ( step > 0 )
			slider.Step = step;
		var readout = row.Add( UiStyle.Muted( new Label( format( value ), owner ) { FixedWidth = UiStyle.PillColumn, FixedHeight = UiStyle.ControlHeight, Alignment = TextFlag.RightCenter } ) );
		slider.OnValueEdited = () =>
		{
			readout.Text = format( slider.Value );
			try
			{
				edited( slider.Value );
			}
			catch ( Exception e )
			{
				C.SetStatus( e.Message, Theme.Red );
			}
		};
		return slider;
	}

	/// <summary>A framed dropdown (framed like mocap inputs).</summary>
	protected ComboBox Combo( Widget owner, Layout row, string tooltip, float width = 0 )
	{
		var combo = UiStyle.Framed( new ComboBox( owner ) { ToolTip = tooltip } );
		if ( width > 0 )
			combo.FixedWidth = width;
		else
			combo.MinimumWidth = 60; // long bone/clip names must not widen the column
		combo.MaxVisibleItems = 24;
		row.Add( combo, width > 0 ? 0 : 1 );
		return combo;
	}

	/// <summary>Runs a UI handler, turning exceptions into a red status line.</summary>
	protected void Safe( Action action )
	{
		try
		{
			action();
		}
		catch ( Exception e )
		{
			Log.Warning( $"[weapon importer] {e}" );
			C.SetStatus( e.Message, Theme.Red );
		}
	}

	public override void OnDestroyed()
	{
		C.Changed -= MarkDirty;
		C.SessionReplaced -= ForceRebuild;
		base.OnDestroyed();
	}
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Tool/UI/Widgets/ProcessingIndicator.cs
Editor library
using Editor;
using Sandbox;

namespace WeaponImporter.Tool.UI;

/// <summary>
/// Stands in the preview's place while the importer works: a green spinner arc with the step
/// title and the latest progress message below it. Styled like the first-load drop box.
/// </summary>
public sealed class ProcessingIndicator : Widget
{
	private string _title = "Processing…";
	private string _message = "";

	/// <summary>False once work stopped: the last message stays, without the spinner.</summary>
	public bool Busy { get; set; } = true;

	public ProcessingIndicator( Widget parent ) : base( parent )
	{
		MinimumSize = new Vector2( 120, 120 );
	}

	public void SetTitle( string title )
	{
		_title = string.IsNullOrEmpty( title ) ? "Processing…" : title;
		Update();
	}

	public void SetMessage( string message )
	{
		_message = message ?? "";
		Update();
	}

	/// <summary>Called every editor frame to turn the spinner.</summary>
	public void Tick()
	{
		if ( Busy && Visible )
			Update();
	}

	protected override void OnPaint() => DrawInto( LocalRect );

	public void DrawInto( Rect area )
	{
		Paint.Antialiasing = true;
		Paint.SetPen( Theme.ControlBackground.Lighten( .2f ), 1 );
		Paint.SetBrush( Theme.ControlBackground );
		Paint.DrawRect( area.Shrink( 1 ), 6 );
		var center = area.Center;
		var width = MathF.Min( 360f, area.Width - 40f );
		if ( Busy )
		{
			var angle = (float)(RealTime.Now * 300 % 360);
			var c = new Vector2( center.x, center.y - 30 );
			Paint.SetPen( Color.White.WithAlpha( .08f ), 4 );
			Paint.DrawArc( c, new Vector2( 18, 18 ), 0, 360 );
			Paint.SetPen( Theme.Green, 4 );
			Paint.DrawArc( c, new Vector2( 18, 18 ), angle, 100 );
			Paint.SetDefaultFont( 10, 600 );
			Paint.SetPen( Theme.Text );
			Paint.DrawText( new Rect( center.x - width / 2, center.y + 2, width, 22 ), _title, TextFlag.Center );
		}
		Paint.SetDefaultFont();
		Paint.SetPen( Theme.TextLight );
		Paint.DrawText( new Rect( center.x - width / 2, center.y + (Busy ? 26 : -20), width, 40 ), _message, TextFlag.Center | TextFlag.WordWrap );
	}
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Tool/UI/Widgets/SearchPicker.cs
Editor library
using Editor;
using Sandbox;

namespace WeaponImporter.Tool.UI;

/// <summary>One choice in a <see cref="SearchPicker"/>.</summary>
public sealed record PickerItem( string Value, string Label, string Detail = "", bool Suggested = false, bool Current = false );

/// <summary>
/// A popup list with a search box: type to filter (every word must match, anywhere in the
/// name), Enter picks the first match. Suggested items come first. Used for choosing clips.
/// </summary>
public sealed class SearchPicker : PopupWidget
{
	private readonly IReadOnlyList<PickerItem> _items;
	private readonly Action<string> _picked;
	private readonly LineEdit _search;
	private readonly Widget _list;
	private List<PickerItem> _shown = new();

	public SearchPicker( Widget parent, string title, IReadOnlyList<PickerItem> items, Action<string> picked ) : base( parent )
	{
		_items = items;
		_picked = picked;
		FixedWidth = 380;
		Layout = Layout.Column();
		Layout.Margin = 8;
		Layout.Spacing = 6;
		Layout.Add( UiStyle.Bold( new Label( title, this ) ) );
		_search = Layout.Add( new LineEdit( this ) { PlaceholderText = "Type to search…", FixedHeight = UiStyle.ControlHeight } );
		_search.TextEdited += _ => Fill();
		_search.ReturnPressed += () =>
		{
			if ( _shown.Count > 0 )
				Pick( _shown[0].Value );
		};
		var scroll = Layout.Add( new ScrollArea( this ), 1 );
		scroll.HorizontalScrollbarMode = ScrollbarMode.Off;
		scroll.MaximumHeight = 420;
		scroll.MinimumHeight = Math.Min( 420, 30 * (items.Count + 1) );
		_list = new Widget( scroll ) { Layout = Layout.Column() };
		_list.Layout.Spacing = 1;
		scroll.Canvas = _list;
		Fill();
	}

	/// <summary>Opens at the cursor with the search box focused.</summary>
	public void Open()
	{
		OpenAtCursor();
		_search.Focus();
	}

	private void Fill()
	{
		_list.Layout.Clear( true );
		_shown = Filter( _items, _search.Text );
		foreach ( var item in _shown )
		{
			var value = item.Value;
			var row = _list.Layout.Add( new ClickRow( _list, () => Pick( value ), item.Detail ) );
			row.Selected = item.Current;
			row.Layout.Add( new IconButton( item.Current ? "check" : item.Suggested ? "auto_awesome" : "movie" ) { Background = Color.Transparent, FixedSize = 22, TransparentForMouseEvents = true } );
			row.Layout.Add( new Label( UiStyle.Breakable( item.Label ), row ) { WordWrap = true, MinimumWidth = 20 }, 1 );
			if ( item.Detail.Length > 0 )
				row.Layout.Add( UiStyle.Muted( new Label( item.Detail, row ), small: true ) );
		}
		if ( _shown.Count == 0 )
			_list.Layout.Add( UiStyle.Muted( new Label( "Nothing matches.", _list ), small: true ) );
		_list.Layout.AddStretchCell();
	}

	/// <summary>Items matching a query (every word, anywhere in the name or detail), suggested first.</summary>
	public static List<PickerItem> Filter( IEnumerable<PickerItem> items, string query )
	{
		var words = (query ?? "").ToLowerInvariant().Split( new[] { ' ', '_', '-', '|' }, StringSplitOptions.RemoveEmptyEntries );
		return items
			.Where( i => words.All( w => i.Label.Contains( w, StringComparison.OrdinalIgnoreCase ) || i.Detail.Contains( w, StringComparison.OrdinalIgnoreCase ) ) )
			.OrderByDescending( i => i.Suggested )
			.ToList();
	}

	private void Pick( string value )
	{
		_picked( value );
		Close();
	}
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Tool/UI/Widgets/NavList.cs
Editor library
using Editor;
using Sandbox;

namespace WeaponImporter.Tool.UI;

/// <summary>
/// The editor's page list: one clickable row per page (icon, name, a status dot when something
/// needs attention). Rows are the controls; there are no separate buttons.
/// </summary>
public sealed class NavList : Widget
{
	public const float RowHeight = 34;

	private readonly List<(string Icon, string Title, string Tip)> _pages = new();
	private readonly Dictionary<int, Color?> _status = new();
	private int _hover = -1;

	public int Selected { get; private set; }
	public Action<int> OnSelected { get; set; }

	public NavList( Widget parent ) : base( parent )
	{
		MouseTracking = true;
		Cursor = CursorShape.Finger;
		FixedWidth = 176;
	}

	public void Add( string icon, string title, string tooltip )
	{
		_pages.Add( (icon, title, tooltip) );
		MinimumHeight = _pages.Count * RowHeight + 8;
		Update();
	}

	/// <summary>Dot beside a page: null hides it (all good), otherwise its colour (yellow, red).</summary>
	public void SetStatus( int page, Color? color )
	{
		if ( _status.TryGetValue( page, out var old ) && old == color )
			return;
		_status[page] = color;
		Update();
	}

	/// <summary>The dot colour of a page (null = none), for tests.</summary>
	public Color? StatusOf( int page ) => _status.TryGetValue( page, out var c ) ? c : null;

	public void Select( int page, bool notify = true )
	{
		page = Math.Clamp( page, 0, Math.Max( 0, _pages.Count - 1 ) );
		Selected = page;
		Update();
		if ( notify )
			OnSelected?.Invoke( page );
	}

	private int RowAt( float y ) => y < 4 ? -1 : (int)((y - 4) / RowHeight) is var i && i < _pages.Count ? i : -1;

	protected override void OnMouseMove( MouseEvent e )
	{
		var row = RowAt( e.LocalPosition.y );
		if ( row != _hover )
		{
			_hover = row;
			ToolTip = row >= 0 ? _pages[row].Tip : null;
			Update();
		}
	}

	protected override void OnMouseLeave()
	{
		_hover = -1;
		Update();
	}

	protected override void OnMousePress( MouseEvent e )
	{
		if ( !e.LeftMouseButton )
			return;
		var row = RowAt( e.LocalPosition.y );
		if ( row >= 0 )
			Select( row );
		e.Accepted = true;
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		for ( var i = 0; i < _pages.Count; i++ )
		{
			var (icon, title, _) = _pages[i];
			var rect = new Rect( 0, 4 + i * RowHeight, Width, RowHeight - 2 );
			var selected = i == Selected;
			if ( selected || i == _hover )
			{
				Paint.ClearPen();
				Paint.SetBrush( selected ? Theme.ControlBackground.Lighten( .35f ) : Theme.ControlBackground.Lighten( .15f ) );
				Paint.DrawRect( rect, 5 );
			}
			if ( selected )
			{
				Paint.ClearPen();
				Paint.SetBrush( Theme.Green );
				Paint.DrawRect( new Rect( rect.Left, rect.Top + 7, 3, rect.Height - 14 ), 1.5f );
			}
			Paint.SetPen( selected ? Theme.Green : Theme.TextLight );
			Paint.DrawIcon( new Rect( rect.Left + 12, rect.Top, 20, rect.Height ), icon, 16 );
			Paint.SetPen( selected ? Theme.Text : Theme.Text.WithAlpha( .8f ) );
			Paint.SetDefaultFont( 9, selected ? 600 : 400 );
			Paint.DrawText( new Rect( rect.Left + 40, rect.Top, rect.Width - 60, rect.Height ), title, TextFlag.LeftCenter );
			if ( _status.TryGetValue( i, out var status ) && status is { } dot )
			{
				Paint.ClearPen();
				Paint.SetBrush( dot );
				Paint.DrawRect( new Rect( rect.Right - 18, rect.Center.y - 4, 8, 8 ), 4 );
			}
		}
	}
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Core/Analysis/AnimationClassifier.cs
Editor library
#nullable enable annotations

namespace WeaponImporter.Core.Analysis;

/// <summary>A guess at an animation's role, with how sure the classifier is.</summary>
public sealed record AnimationGuess(string Animation, AnimationRole Role, float Confidence, string Reason)
{
    /// <summary>First-person (view model) or third-person (world / body) clip, when the name says so.</summary>
    public AnimationPerspective Perspective { get; init; }
}

public enum AnimationPerspective { Any, FirstPerson, ThirdPerson }

/// <summary>
/// Maps animation names onto <see cref="AnimationRole"/>s using aliases common across game
/// rigs and marketplace packs (<c>shoot</c>, <c>reload_empty</c>, <c>deploy</c>, <c>lookat</c>,
/// <c>ads_fire</c>, <c>rechamber</c>...). Scoring is token based so prefixes, numbering and
/// casing do not matter.
/// </summary>
public static class AnimationClassifier
{
    private sealed record Rule(AnimationRole Role, string[] Any, string[]? Joined = null, string[]? Requires = null, string[]? Excludes = null, float Weight = 1f);

    private static readonly string[] AimTokens = { "ads", "aim", "aiming", "iron", "ironsight", "ironsights", "sight", "sights", "zoom", "scope" };
    private static readonly string[] AimLoopTokens = { "idle", "loop", "hold", "pose", "static", "aiming", "breathing", "breath" };
    private static readonly string[] AimOutTokens = { "out", "exit", "end", "lower", "leave", "stop", "release" };

    // Order matters only for ties; more specific rules carry higher weights.
    private static readonly Rule[] Rules =
    {
        new(AnimationRole.AdsFire, new[] { "fire", "shoot", "shot", "attack", "firing" }, new[] { "adsfire", "aimfire", "ironfire", "firead", "fireaim", "fireiron", "sightfire", "scopefire" }, Requires: new[] { "ads", "aim", "iron", "ironsight", "ironsights", "sight", "scope", "zoom" }, Weight: 1.35f),
        new(AnimationRole.FireEmpty, new[] { "fire", "shoot", "shot", "attack", "dry", "dryfire" }, new[] { "fireempty", "firelast", "shootempty", "shootlast", "dryfire", "lastshot", "emptyfire" }, Requires: new[] { "empty", "last", "dry", "final" }, Weight: 1.3f),
        new(AnimationRole.Fire, new[] { "fire", "shoot", "shot", "attack", "firing", "recoil", "primary", "burst", "auto", "semi" }, Excludes: new[] { "reload", "select", "mode", "switch" }),

        new(AnimationRole.EmptyReload, new[] { "reload", "rld", "reloading" }, new[] { "reloadempty", "emptyreload", "reloadlong", "reloaddry", "reloadfull" }, Requires: new[] { "empty", "dry", "long", "full", "outofammo" }, Weight: 1.3f),
        new(AnimationRole.TacticalReload, new[] { "reload", "rld", "reloading" }, new[] { "reloadtac", "tacreload", "reloadtactical", "tacticalreload", "reloadshort", "reloadpartial" }, Requires: new[] { "tac", "tactical", "short", "partial", "fast", "speed" }, Weight: 1.3f),
        new(AnimationRole.Reload, new[] { "reload", "rld", "reloading", "magswap", "magchange" }, Excludes: new[] { "start", "end", "loop", "insert", "begin", "enter", "finish", "exit", "shell", "single" }),
        // Shell-by-shell reloads (shotguns, tube-fed rifles): start / one shell / end.
        new(AnimationRole.ReloadStart, new[] { "start", "begin", "enter", "open", "in", "intro" }, new[] { "reloadstart", "startreload", "reloadbegin", "reloadenter", "reloadopen", "reloadintro", "idletoreload", "toreload" }, Requires: new[] { "reload", "rld", "reloading", "load", "loading" }, Excludes: new[] { "fire", "shoot" }, Weight: 1.3f),
        new(AnimationRole.ReloadInsert, new[] { "insert", "loop", "shell", "single", "load", "inserting", "step" }, new[] { "reloadinsert", "reloadstep", "insertshell", "shellinsert", "reloadloop", "loadshell", "shellload", "reloadshell", "reloadsingle", "insertround", "loadround" }, Requires: new[] { "reload", "rld", "reloading", "insert", "shell", "round", "load" }, Excludes: new[] { "start", "begin", "end", "finish", "exit", "fire", "shoot" }, Weight: 1.3f),
        new(AnimationRole.ReloadEnd, new[] { "end", "finish", "exit", "close", "out", "outro", "stop" }, new[] { "reloadend", "endreload", "reloadfinish", "reloadexit", "reloadclose", "reloadoutro", "reloadtoidle", "reloadto" }, Requires: new[] { "reload", "rld", "reloading", "load", "loading" }, Excludes: new[] { "fire", "shoot" }, Weight: 1.3f),

        new(AnimationRole.Unjam, new[] { "unjam", "clear", "clearjam", "fixjam", "malfunctionclear", "tapack", "remedy" }, new[] { "unjam", "clearjam", "fixjam", "jamclear", "jamfix" }, Weight: 1.3f),
        new(AnimationRole.Jam, new[] { "jam", "jammed", "malfunction", "misfire", "stovepipe" }, Excludes: new[] { "clear", "fix", "un" }),
        new(AnimationRole.Bolt, new[] { "bolt", "rechamber", "chamber", "pump", "charge", "charging", "cock", "cocking", "rack", "cycle", "lever", "slide" }, Excludes: new[] { "reload", "fire", "shoot", "idle" }),

        new(AnimationRole.Draw, new[] { "draw", "deploy", "equip", "pullout", "raise", "unholster", "takeout", "ready", "select", "pickup" }, new[] { "firstdraw", "drawfirst", "pullout", "takeout" }, Excludes: new[] { "reload" }),
        new(AnimationRole.Holster, new[] { "holster", "putaway", "unequip", "lower", "stow", "putdown", "deselect", "hide" }, new[] { "putaway", "putdown" }, Excludes: new[] { "unholster" }),
        new(AnimationRole.Inspect, new[] { "inspect", "lookat", "examine", "check", "fidget", "admire", "showoff", "look" }, new[] { "lookat", "checkmag", "magcheck", "showoff" }),

        new(AnimationRole.Sprint, new[] { "sprint", "run", "running", "dash" }),
        new(AnimationRole.Walk, new[] { "walk", "walking", "move", "moving", "jog", "strafe", "bob" }),
        // Aiming, any naming style: raising the sights ("Aim_In", "ADS", "IronIn", "Zoom_Enter"),
        // the held loop ("Aim_Idle", "ADS_Loop"), lowering them ("Aim_Out", "ADS_Exit", "Unaim").
        new(AnimationRole.Ads, AimTokens.Concat(new[] { "adsin", "aimin" }).ToArray(), new[] { "aimin", "adsin", "ironin", "zoomin", "sightin", "scopein", "aimenter", "adsenter", "aimstart", "adsstart" }, Excludes: AimOutTokens.Concat(AimLoopTokens).Concat(new[] { "fire", "shoot", "unaim", "unads" }).ToArray()),
        new(AnimationRole.AdsIdle, AimLoopTokens, new[] { "aimidle", "adsidle", "aimloop", "adsloop", "ironidle", "zoomidle", "aimhold", "adshold", "aimingidle" }, Requires: AimTokens, Excludes: AimOutTokens.Concat(new[] { "fire", "shoot" }).ToArray(), Weight: 1.3f),
        new(AnimationRole.AdsOut, AimOutTokens.Concat(new[] { "unaim", "unads", "adsout", "aimout" }).ToArray(), new[] { "aimout", "adsout", "ironout", "zoomout", "sightout", "scopeout", "unaim", "unads", "aimexit", "adsexit", "aimend", "adsend" }, Requires: AimTokens.Concat(new[] { "unaim", "unads", "adsout", "aimout" }).ToArray(), Excludes: new[] { "fire", "shoot" }, Weight: 1.3f),
        new(AnimationRole.Melee, new[] { "melee", "bash", "stab", "slash", "swing", "hit", "knife", "punch", "butt", "strike", "attackmelee", "jab", "hook", "uppercut", "chop", "thrust" }, new[] { "meleeattack" }, Excludes: new[] { "heavy", "strong", "power", "charged", "secondary", "alt", "get", "react", "reaction", "damage", "hurt", "gethit", "hitreact" }, Weight: 1.1f),
        new(AnimationRole.Attack2, new[] { "heavy", "strong", "power", "charged", "secondary", "alt", "attack2", "special" }, new[] { "heavyattack", "attackheavy", "secondaryattack", "attacksecondary", "attack2", "altattack", "attackalt", "powerattack", "chargedattack", "strongattack", "heavyslash", "heavyswing" }, Requires: new[] { "attack", "slash", "swing", "stab", "hit", "strike", "punch", "melee", "chop", "attack2" }, Weight: 1.3f),
        // Guarding: raise / hold / lower.
        new(AnimationRole.BlockStart, new[] { "start", "begin", "enter", "in", "raise", "up" }, new[] { "blockstart", "startblock", "blockin", "blockbegin", "parrystart", "guardstart", "guardup", "blockenter" }, Requires: new[] { "block", "parry", "guard", "defend", "defense" }, Weight: 1.3f),
        new(AnimationRole.Block, new[] { "block", "parry", "guard", "defend", "defense", "blocking" }, new[] { "blockloop", "blockidle", "blockhold", "parryloop", "guardloop", "guardidle" }, Excludes: new[] { "start", "begin", "enter", "end", "stop", "exit", "out" }),
        new(AnimationRole.BlockEnd, new[] { "end", "stop", "exit", "out", "lower", "down", "release" }, new[] { "blockend", "blockstop", "endblock", "blockout", "blockexit", "parryend", "guardend", "guarddown" }, Requires: new[] { "block", "parry", "guard", "defend", "defense" }, Weight: 1.3f),
        // Items: use once, or start / hold / finish a use.
        new(AnimationRole.Use, new[] { "use", "drink", "eat", "inject", "heal", "consume", "apply", "bandage", "smoke", "injection", "healing", "sip", "gulp", "swallow", "spray", "activate", "press", "toggle" }, new[] { "useitem", "itemuse" }, Excludes: new[] { "start", "begin", "enter", "loop", "hold", "end", "stop", "exit", "finish", "takeout", "out" }),
        new(AnimationRole.UseStart, new[] { "start", "begin", "enter", "in", "raise", "up", "open" }, new[] { "usestart", "startuse", "drinkstart", "eatstart", "takestart", "healstart", "injectstart" }, Requires: new[] { "use", "drink", "eat", "inject", "heal", "consume", "take", "apply", "bandage", "smoke" }, Weight: 1.3f),
        new(AnimationRole.UseLoop, new[] { "loop", "hold", "idle", "holding", "continue" }, new[] { "useloop", "drinkloop", "eatloop", "takeloop", "healloop", "usehold" }, Requires: new[] { "use", "drink", "eat", "inject", "heal", "consume", "take", "apply", "bandage", "smoke" }, Weight: 1.3f),
        new(AnimationRole.UseEnd, new[] { "end", "stop", "exit", "finish", "out", "lower", "down", "close" }, new[] { "useend", "usestop", "drinkend", "eatend", "takestop", "takeend", "healend", "injectend" }, Requires: new[] { "use", "drink", "eat", "inject", "heal", "consume", "take", "apply", "bandage", "smoke" }, Weight: 1.3f),
        new(AnimationRole.Throw, new[] { "throw", "toss", "lob", "pitch", "hurl" }, new[] { "throwgrenade", "grenadethrow" }, Weight: 1.2f),
        new(AnimationRole.Idle, new[] { "idle", "rest", "hold", "static", "pose", "bind", "stand", "base", "breathing", "breath", "breathe" }, new[] { "breathing", "idleloop" }, Excludes: new[] { "to", "fire", "reload" }, Weight: 0.9f),
    };

    private static readonly string[] FirstPersonTokens = { "fp", "1p", "vm", "v", "view", "viewmodel", "arms", "firstperson", "fps" };
    private static readonly string[] ThirdPersonTokens = { "tp", "3p", "wm", "w", "world", "worldmodel", "thirdperson", "tps", "body", "player", "citizen" };

    /// <summary>Classifies one animation name.</summary>
    public static AnimationGuess Classify(string name, AnimationMotionHint? motion = null)
    {
        var tokens = NameTokens.Split(name);
        var joined = NameTokens.Joined(tokens);
        var perspective = NameTokens.Has(tokens, ThirdPersonTokens) ? AnimationPerspective.ThirdPerson
            : NameTokens.Has(tokens, FirstPersonTokens) ? AnimationPerspective.FirstPerson
            : AnimationPerspective.Any;

        AnimationRole best = AnimationRole.Unknown;
        float bestScore = 0f;
        string reason = "no matching name";

        foreach (var rule in Rules)
        {
            var score = 0f;
            string why = "";
            if (rule.Joined is { } joinedAliases && joinedAliases.Any(j => joined.Contains(j, StringComparison.Ordinal)))
            {
                score = 1f;
                why = $"name contains '{joinedAliases.First(j => joined.Contains(j, StringComparison.Ordinal))}'";
            }
            else if (NameTokens.Has(tokens, rule.Any))
            {
                if (rule.Requires is { } req && !NameTokens.Has(tokens, req))
                    continue;
                score = rule.Requires is null ? 0.8f : 0.95f;
                why = $"'{tokens.First(t => rule.Any.Any(a => t == a || (a.Length >= 4 && t.StartsWith(a, StringComparison.Ordinal))))}' in name";
            }
            else
            {
                continue;
            }

            if (rule.Excludes is { } ex && NameTokens.Has(tokens, ex))
                score *= 0.45f;
            // The role's own word ("Idle") beats a synonym ("Hold") on a tie.
            if (tokens.Contains(rule.Any[0]))
                score += 0.02f;
            score *= rule.Weight;

            if (score > bestScore)
            {
                bestScore = score;
                best = rule.Role;
                reason = why;
            }
        }

        // Motion evidence breaks ties and rescues meaningless names ("anim_03").
        if (motion is { } m)
        {
            var (role, conf, why) = FromMotion(m);
            if (best == AnimationRole.Unknown && role != AnimationRole.Unknown)
            {
                best = role;
                bestScore = conf;
                reason = why;
            }
            else if (role == best && role != AnimationRole.Unknown)
            {
                bestScore = MathF.Min(1f, bestScore + 0.1f);
            }
        }

        return new AnimationGuess(name, best, MathF.Min(1f, bestScore), reason) { Perspective = perspective };
    }

    /// <summary>
    /// Classifies a set of animations and resolves duplicates: each role goes to the clip with
    /// the highest confidence, preferring shorter base names ("reload" over "reload_02").
    /// </summary>
    public static IReadOnlyDictionary<AnimationRole, AnimationGuess> Assign(IEnumerable<AnimationGuess> guesses, AnimationPerspective prefer = AnimationPerspective.Any)
    {
        var result = new Dictionary<AnimationRole, AnimationGuess>();
        foreach (var group in guesses.Where(g => g.Role != AnimationRole.Unknown).GroupBy(g => g.Role))
        {
            var pick = group
                .OrderByDescending(g => g.Confidence + (prefer != AnimationPerspective.Any && g.Perspective == prefer ? 0.15f : 0f) - (prefer != AnimationPerspective.Any && g.Perspective != AnimationPerspective.Any && g.Perspective != prefer ? 0.3f : 0f))
                .ThenBy(g => g.Animation.Count(char.IsDigit))
                .ThenBy(g => g.Animation.Length)
                .First();
            result[group.Key] = pick;
        }

        // A lone "reload" that is actually the empty one leaves Reload itself free: promote.
        if (!result.ContainsKey(AnimationRole.Reload))
        {
            if (result.TryGetValue(AnimationRole.TacticalReload, out var tac))
                result[AnimationRole.Reload] = tac with { Role = AnimationRole.Reload, Confidence = tac.Confidence * 0.9f, Reason = "tactical reload used as reload" };
            else if (result.TryGetValue(AnimationRole.EmptyReload, out var empty))
                result[AnimationRole.Reload] = empty with { Role = AnimationRole.Reload, Confidence = empty.Confidence * 0.85f, Reason = "empty reload used as reload" };
        }
        // "IdleToReload" / "Reload" / "ReloadToIdle": between a start and an end, the plain
        // reload is the one-shell part.
        if (result.ContainsKey(AnimationRole.ReloadStart) && result.ContainsKey(AnimationRole.ReloadEnd)
            && !result.ContainsKey(AnimationRole.ReloadInsert) && result.TryGetValue(AnimationRole.Reload, out var middle))
        {
            result[AnimationRole.ReloadInsert] = middle with { Role = AnimationRole.ReloadInsert, Reason = "reload between a reload start and end" };
            result.Remove(AnimationRole.Reload);
        }
        if (!result.ContainsKey(AnimationRole.Fire) && result.TryGetValue(AnimationRole.AdsFire, out var adsFire))
            result[AnimationRole.Fire] = adsFire with { Role = AnimationRole.Fire, Confidence = adsFire.Confidence * 0.8f, Reason = "ADS fire used as fire" };
        return result;
    }

    private static (AnimationRole, float, string) FromMotion(AnimationMotionHint m)
    {
        if (m.MagazineTravel > 2f && m.Duration > 1f)
            return (AnimationRole.Reload, 0.6f, "magazine leaves the weapon");
        if (m.BoltTravel > 0.3f && m.Duration < 0.6f)
            return (AnimationRole.Fire, 0.55f, "short clip with slide/bolt cycling");
        if (m.BoltTravel > 0.3f && m.Duration >= 0.6f)
            return (AnimationRole.Bolt, 0.5f, "bolt cycles without magazine change");
        if (m.RootTravel < 0.05f && m.Duration > 0.5f && m.MaxBoneMotion < 0.1f)
            return (AnimationRole.Idle, 0.5f, "almost no motion");
        return (AnimationRole.Unknown, 0f, "motion inconclusive");
    }
}

/// <summary>Motion measurements used when a name alone is not enough.</summary>
public readonly record struct AnimationMotionHint(float Duration, float MagazineTravel, float BoltTravel, float RootTravel, float MaxBoneMotion);
notpointless.chomnr_weapon_importer / Code/WeaponImporter/CharacterOverlay.cs
Game library
using System.Text.Json;

namespace WeaponImporter;

/// <summary>A replacement character animation for one action.</summary>
public sealed class OverlayInfo
{
    public string Role;
    /// <summary>Model that owns the sequence ("" = the character's own model). Must share the character's skeleton.</summary>
    public string Model;
    public string Sequence;
    /// <summary>Fade in/out time in seconds.</summary>
    public float Blend = 0.2f;
    /// <summary>More sequences of the same model played in turn with <see cref="Sequence"/> (attack combos).</summary>
    public List<string> Variants = new();
}

/// <summary>
/// Plays a replacement character sequence for an action on a hidden model with the same
/// skeleton and blends it over the upper body (spine and everything above), so the weapon and
/// the hands follow it while the animgraph keeps driving the legs.
/// </summary>
internal sealed class CharacterOverlay
{
    private SceneModel _model;
    private Model _source;
    private string _sequence;
    private readonly Dictionary<string, int> _boneIndex = new( StringComparer.Ordinal );

    public bool Active { get; private set; }
    public float Weight { get; private set; }
    public float Duration { get; private set; }

    /// <summary>
    /// Bone the overlay is attached at: its upper body is moved so this bone stays where the
    /// character's own animation has it (a lunge or a crouch in the sequence bends the upper
    /// body without pulling it off the hips the animgraph keeps).
    /// </summary>
    public const string AnchorBone = "spine_0";

    private Vector3 _offset;

    /// <summary>Starts the overlay; returns false when the model or sequence is unusable.</summary>
    public bool Begin( SkinnedModelRenderer body, OverlayInfo info, out string error ) => Begin( body, info, info?.Sequence, out error );

    /// <summary>Starts the overlay with one of the info's sequences (its own or a variant).</summary>
    public bool Begin( SkinnedModelRenderer body, OverlayInfo info, string sequence, out string error )
    {
        error = null;
        Active = false;
        Weight = 0f;
        if ( info == null || string.IsNullOrEmpty( sequence ) || body?.SceneModel == null )
            return false;

        var model = string.IsNullOrEmpty( info.Model ) ? body.Model : Sandbox.Model.Load( info.Model );
        if ( model == null || model.IsError )
        {
            error = $"model '{info.Model}' could not be loaded";
            return false;
        }
        if ( !model.AnimationNames.Contains( sequence ) )
        {
            error = $"model '{model.Name}' has no sequence '{sequence}'";
            return false;
        }

        if ( _model == null || !_model.IsValid() || _source != model )
        {
            Dispose();
            _model = new SceneModel( body.SceneModel.World, model, body.SceneModel.Transform )
            {
                UseAnimGraph = false,
                RenderingEnabled = false,
            };
            _source = model;
            _boneIndex.Clear();
            for ( int i = 0; i < model.BoneCount; i++ )
                _boneIndex[model.GetBoneName( i )] = i;
        }

        _sequence = sequence;
        _model.CurrentSequence.Name = _sequence;
        _model.Update( 0f );
        Duration = _model.CurrentSequence.Duration;
        Active = true;
        return true;
    }

    /// <summary>Poses the overlay at <paramref name="seconds"/> into the action with the given blend weight.</summary>
    public void Update( SkinnedModelRenderer body, float seconds, float weight )
    {
        if ( !Active || _model == null || !_model.IsValid() )
            return;
        _model.Transform = body.SceneModel.Transform;
        _model.CurrentSequence.Name = _sequence;
        _model.CurrentSequence.Time = Math.Clamp( seconds, 0f, MathF.Max( 0f, Duration ) );
        _model.Update( 0f );
        Weight = Math.Clamp( weight, 0f, 1f );
        _offset = Vector3.Zero;
        var anchor = body.Model?.Bones.GetBone( AnchorBone );
        if ( anchor != null && _boneIndex.TryGetValue( AnchorBone, out var index ) && body.TryGetBoneTransformAnimation( anchor, out var own ) )
            _offset = own.Position - _model.GetBoneWorldTransform( index ).Position;
    }

    /// <summary>World transform of a bone in the overlay pose.</summary>
    public bool TryGetWorld( string bone, out Transform world )
    {
        world = global::Transform.Zero;
        if ( !Active || _model == null || !_boneIndex.TryGetValue( bone, out var index ) )
            return false;
        world = _model.GetBoneWorldTransform( index );
        world.Position += _offset;
        return true;
    }

    public void End()
    {
        Active = false;
        Weight = 0f;
    }

    public void Dispose()
    {
        if ( _model != null && _model.IsValid() )
            _model.Delete();
        _model = null;
        _source = null;
        Active = false;
    }

    /// <summary>Parses <c>{"reload":{"model":"","sequence":"reload_rifle","blend":0.2,"variants":["reload_2"]}}</c>.</summary>
    public static bool TryParse( string json, Dictionary<string, OverlayInfo> into, out string error )
    {
        into.Clear();
        error = null;
        if ( string.IsNullOrWhiteSpace( json ) )
            return true;
        try
        {
            using var doc = JsonDocument.Parse( json );
            if ( doc.RootElement.ValueKind != JsonValueKind.Object )
            {
                error = "expected an object";
                return false;
            }
            foreach ( var entry in doc.RootElement.EnumerateObject() )
            {
                if ( entry.Value.ValueKind != JsonValueKind.Object )
                    continue;
                var info = new OverlayInfo { Role = entry.Name.ToLowerInvariant() };
                if ( entry.Value.TryGetProperty( "model", out var m ) && m.ValueKind == JsonValueKind.String )
                    info.Model = m.GetString();
                if ( entry.Value.TryGetProperty( "sequence", out var s ) && s.ValueKind == JsonValueKind.String )
                    info.Sequence = s.GetString();
                if ( entry.Value.TryGetProperty( "blend", out var b ) && b.ValueKind == JsonValueKind.Number )
                    info.Blend = MathF.Max( 0f, b.GetSingle() );
                if ( entry.Value.TryGetProperty( "variants", out var v ) && v.ValueKind == JsonValueKind.Array )
                    foreach ( var item in v.EnumerateArray() )
                        if ( item.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty( item.GetString() ) )
                            info.Variants.Add( item.GetString() );
                if ( !string.IsNullOrEmpty( info.Sequence ) )
                    into[info.Role] = info;
            }
            return true;
        }
        catch ( JsonException e )
        {
            error = e.Message;
            return false;
        }
    }
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Core/Generation/ExportRig.cs
Editor library
#nullable enable annotations

using System.Numerics;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Rig;
using WeaponImporter.Core.Weapon;

namespace WeaponImporter.Core.Generation;

using Vector3 = System.Numerics.Vector3;

/// <summary>
/// The rig a world model ships with: the weapon body becomes the fixed root, only bones the
/// weapon uses are kept (arms, cameras and scene helpers are dropped) and parts parented
/// elsewhere (a magazine hanging off a hand or the scene root) are re-parented under the body
/// with their motion relative to it preserved. Root motion of the body is removed, so a
/// first-person sway never moves the weapon in third person.
/// </summary>
public sealed class ExportRig
{
    public required WeaponAsset Asset { get; init; }
    public required int[] Triangles { get; init; }
    public required string RootName { get; init; }

    /// <summary>Old bone index -> new bone index (-1 when dropped).</summary>
    public required int[] Map { get; init; }

    public static ExportRig Build(WeaponAnalysis a, IEnumerable<string>? keepNames = null)
    {
        var source = a.Asset;
        var skeleton = source.Skeleton;
        var mesh = source.Mesh;

        // Body: the bone most weapon triangles are skinned to.
        var counts = new Dictionary<int, int>();
        foreach (var t in a.WeaponTriangles)
        {
            var b = mesh.TriangleBone(t);
            if (b >= 0)
                counts[b] = counts.GetValueOrDefault(b) + 1;
        }
        var body = counts.Count > 0 ? counts.OrderByDescending(kv => kv.Value).First().Key : a.RootBone;

        // Bones to keep: skinned weapon bones, named/moving parts, explicitly requested ones.
        var keep = new HashSet<int> { body };
        foreach (var b in counts.Keys)
            keep.Add(b);
        foreach (var p in a.Parts)
            if (p.Bone.Length > 0 && skeleton.IndexOf(p.Bone) is var pi && pi >= 0)
                keep.Add(pi);
        // The left-hand copy of a dual weapon: its bones keep their own hierarchy under its root.
        var second = a.SecondWeaponBone is { } secondName ? skeleton.IndexOf(secondName) : -1;
        if (second >= 0)
        {
            keep.Add(second);
            foreach (var t in a.SecondWeaponTriangles)
                if (mesh.TriangleBone(t) is var sb && sb >= 0)
                    keep.Add(sb);
        }
        bool Under(int bone) => bone == body || IsDescendant(skeleton, bone, body) || (second >= 0 && (bone == second || IsDescendant(skeleton, bone, second)));
        if (keepNames is not null)
            foreach (var n in keepNames)
                if (!string.IsNullOrEmpty(n) && skeleton.IndexOf(n) is var ki && ki >= 0 && !a.ArmBones.Contains(ki))
                    keep.Add(ki);
        // Close over ancestors that sit between the body and a kept bone (keeps hierarchies intact).
        foreach (var b in keep.ToList())
            for (var p = skeleton[b].ParentIndex; p >= 0 && p != body && p != second; p = skeleton[p].ParentIndex)
            {
                if (!Under(p))
                    break;
                keep.Add(p);
            }

        // New parent of each kept bone: nearest kept ancestor under the body, else the body.
        int NewParent(int b)
        {
            if (b == body)
                return -1;
            if (b == second)
                return body;
            for (var p = skeleton[b].ParentIndex; p >= 0; p = skeleton[p].ParentIndex)
                if (keep.Contains(p) && Under(p))
                    return p;
            return body;
        }

        // Order: body first, then topological by new parent (stable, source order).
        var order = new List<int> { body };
        var placed = new HashSet<int> { body };
        var remaining = skeleton.Bones.Select(x => x.Index).Where(i => keep.Contains(i) && i != body).ToList();
        while (remaining.Count > 0)
        {
            var progress = false;
            foreach (var b in remaining.ToList())
                if (placed.Contains(NewParent(b)))
                {
                    order.Add(b);
                    placed.Add(b);
                    remaining.Remove(b);
                    progress = true;
                }
            if (!progress)
                break;
        }

        var rest = skeleton.RestWorld;
        var names = new HashSet<string>(StringComparer.Ordinal);
        var defs = new List<BoneDefinition>();
        foreach (var b in order)
        {
            var parent = NewParent(b);
            var local = parent < 0 ? rest[b] : XForm.ToLocal(rest[parent], rest[b]);
            defs.Add(new BoneDefinition(skeleton[b].Name, parent < 0 ? null : skeleton[parent].Name, local));
            names.Add(skeleton[b].Name);
        }
        var exportSkeleton = Skeleton.Create(defs);
        var map = new int[skeleton.Count];
        for (var i = 0; i < map.Length; i++)
            map[i] = keep.Contains(i) ? exportSkeleton.IndexOf(skeleton[i].Name) : -1;

        // Vertices skinned to dropped bones fall back to their nearest kept ancestor (or the body).
        int MapBone(int b)
        {
            for (var x = b; x >= 0; x = skeleton[x].ParentIndex)
                if (map[x] >= 0)
                    return map[x];
            return map[body];
        }
        var vertexBone = mesh.VertexBone.Select(b => b >= 0 ? MapBone(b) : map[body]).ToArray();
        var exportMesh = mesh.WithVertices(mesh.Positions, vertexBone, _ => Quaternion.Identity);

        // Clips: re-express every kept bone relative to its new parent, frame by frame.
        var clips = new List<Clip>();
        foreach (var clip in source.Clips)
        {
            var frames = new List<XForm[]>(clip.FrameCount);
            foreach (var locals in clip.Frames)
            {
                var world = new XForm[skeleton.Count];
                for (var i = 0; i < skeleton.Count; i++)
                {
                    var p = skeleton[i].ParentIndex;
                    world[i] = p < 0 ? locals[i] : XForm.Compose(world[p], locals[i]);
                }
                // Body stays at its rest placement; everything else keeps its motion relative to it.
                var bodyWorld = world[body];
                var toRest = XForm.Compose(rest[body], bodyWorld.Inverse());
                var outLocals = new XForm[exportSkeleton.Count];
                foreach (var b in order)
                {
                    var nb = map[b];
                    var parent = NewParent(b);
                    if (parent < 0)
                        outLocals[nb] = rest[body];
                    else
                        outLocals[nb] = XForm.ToLocal(XForm.Compose(toRest, world[parent]), XForm.Compose(toRest, world[b]));
                }
                frames.Add(outLocals);
            }
            clips.Add(new Clip(clip.Name, clip.Fps, clip.Looping, frames, clip.NativeFps));
        }

        var asset = new WeaponAsset
        {
            Name = source.Name,
            Kind = source.Kind,
            SourcePath = source.SourcePath,
            Skeleton = exportSkeleton,
            Mesh = exportMesh,
            Clips = clips,
            Attachments = source.Attachments,
        };
        return new ExportRig { Asset = asset, Triangles = a.WeaponTriangles.Concat(a.SecondWeaponTriangles).ToArray(), RootName = skeleton[body].Name, Map = map };
    }

    private static bool IsDescendant(Skeleton skeleton, int bone, int ancestor)
    {
        for (var p = skeleton[bone].ParentIndex; p >= 0; p = skeleton[p].ParentIndex)
            if (p == ancestor)
                return true;
        return false;
    }
}
notpointless.chomnr_weapon_importer / Editor/WeaponImporter/Core/Hands/HandRig.cs
Editor library
#nullable enable annotations

using System.Numerics;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Rig;

namespace WeaponImporter.Core.Hands;

using Vector3 = System.Numerics.Vector3;

public enum Side { Right, Left }

public enum FingerKind { Thumb, Index, Middle, Ring, Pinky }

/// <summary>One finger: its joint bones from the knuckle outward, with flex axes.</summary>
public sealed class FingerRig
{
    public required FingerKind Kind { get; init; }

    /// <summary>Joint bones, proximal first (metacarpals are not included).</summary>
    public required int[] Joints { get; init; }

    /// <summary>Segment lengths, one per joint (the last one is estimated to the fingertip).</summary>
    public required float[] Lengths { get; init; }

    /// <summary>Flexion axis of each joint in that joint's own rest-local frame.</summary>
    public required Vector3[] FlexAxes { get; init; }

    /// <summary>Spread (abduction) axis of the base joint, local frame.</summary>
    public required Vector3 SpreadAxis { get; init; }

    /// <summary>Flexion limits per joint (radians).</summary>
    public required float[] MaxFlex { get; init; }

    /// <summary>Finger radius used for contact tests.</summary>
    public required float Radius { get; init; }

    /// <summary>Local direction a joint points along (toward its child), rest frame.</summary>
    public required Vector3[] Along { get; init; }
}

/// <summary>
/// A character's hand and arm, read from bone names and the rest pose. Everything the grasp
/// solver needs is derived here once: palm frame, finger chains, flex axes, sizes.
/// </summary>
public sealed class HandRig
{
    public required Side Side { get; init; }
    public required Skeleton Skeleton { get; init; }
    public required int Hand { get; init; }
    public required int LowerArm { get; init; }
    public required int UpperArm { get; init; }
    public int Clavicle { get; init; } = -1;

    /// <summary>Twist/helper bones between shoulder and hand that just follow their parents.</summary>
    public IReadOnlyList<int> Helpers { get; init; } = Array.Empty<int>();

    public required IReadOnlyList<FingerRig> Fingers { get; init; }

    /// <summary>Metacarpal bones (kept at rest).</summary>
    public IReadOnlyList<int> Metacarpals { get; init; } = Array.Empty<int>();

    // ---- hand-local frame (relative to the hand bone's rest transform) ----
    public required Vector3 PalmCenter { get; init; }
    public required Vector3 PalmNormal { get; init; }   // out of the palm
    public required Vector3 FingerDirection { get; init; } // wrist -> knuckles
    public required Vector3 KnuckleLine { get; init; }  // index -> pinky side
    public required Vector3 KnuckleCenter { get; init; }
    public required float HandLength { get; init; }
    public required float PalmWidth { get; init; }
    public float PalmThickness => HandLength * 0.14f;

    /// <summary>
    /// The character's model copies the ring finger onto the pinky (a constraint, like the
    /// human's "CopyPinky"), so the pinky can't be posed on its own: poses give it the ring's angles.
    /// </summary>
    public bool PinkyFollowsRing { get; set; }

    public FingerRig? Finger(FingerKind kind) => Fingers.FirstOrDefault(f => f.Kind == kind);

    /// <summary>All bones this rig may rotate (arm chain and fingers).</summary>
    public IEnumerable<int> Bones
    {
        get
        {
            if (Clavicle >= 0) yield return Clavicle;
            yield return UpperArm;
            yield return LowerArm;
            yield return Hand;
            foreach (var h in Helpers) yield return h;
            foreach (var f in Fingers)
                foreach (var j in f.Joints)
                    yield return j;
        }
    }

    // ------------------------------------------------------------------ building

    private static string SideSuffix(Side side) => side == Side.Right ? "R" : "L";

    /// <summary>
    /// Finds a hand by the s&amp;box naming scheme (<c>hand_R</c>, <c>arm_lower_R</c>,
    /// <c>finger_index_0_R</c>...) with fallbacks for common rigs (Mixamo, UE, Rigify, Biped).
    /// Returns null when the skeleton has no recognisable arm.
    /// </summary>
    public static HandRig? Build(Skeleton skeleton, Side side)
    {
        var s = SideSuffix(side);
        int Find(params string[] names)
        {
            foreach (var n in names)
            {
                var i = skeleton.IndexOf(n);
                if (i >= 0)
                    return i;
            }
            // Case-insensitive fallback.
            foreach (var n in names)
                for (var b = 0; b < skeleton.Count; b++)
                    if (string.Equals(skeleton[b].Name, n, StringComparison.OrdinalIgnoreCase))
                        return b;
            return -1;
        }
        var lower = s.ToLowerInvariant();
        var full = side == Side.Right ? "Right" : "Left";
        var hand = Find($"hand_{s}", $"Hand_{s}", $"hand.{s}", $"hand_{lower}", $"{full}Hand", $"mixamorig:{full}Hand", $"hand_{lower}", $"DEF-hand.{s}", $"Bip01 {s} Hand");
        if (hand < 0)
            hand = FindHandByTokens(skeleton, side);
        if (hand < 0)
            return null;
        var lowerArm = skeleton[hand].ParentIndex;
        // Skip twist helpers between hand and forearm.
        while (lowerArm >= 0 && IsHelper(skeleton[lowerArm].Name))
            lowerArm = skeleton[lowerArm].ParentIndex;
        if (lowerArm < 0)
            return null;
        var upperArm = skeleton[lowerArm].ParentIndex;
        while (upperArm >= 0 && IsHelper(skeleton[upperArm].Name))
            upperArm = skeleton[upperArm].ParentIndex;
        // Rigs whose hands hang off a detached IK forearm (parented to the root) keep the real
        // arm chain elsewhere: the shoulder is then the side's named upper arm.
        if (upperArm < 0 || skeleton[upperArm].ParentIndex < 0)
        {
            var named = Find($"{full}Arm", $"mixamorig:{full}Arm", $"upperarm_{lower}", $"UpperArm_{s}", $"arm_upper_{s}", $"upper_arm.{s}", $"DEF-upper_arm.{s}", $"Bip01 {s} UpperArm");
            if (named < 0)
                for (var b = 0; b < skeleton.Count && named < 0; b++)
                {
                    var bare = skeleton[b].Name;
                    var colon = bare.LastIndexOf(':');
                    if (colon >= 0 && string.Equals(bare[(colon + 1)..], $"{full}Arm", StringComparison.OrdinalIgnoreCase))
                        named = b;
                }
            if (named >= 0)
                upperArm = named;
        }
        if (upperArm < 0)
            return null;
        var clavicle = skeleton[upperArm].ParentIndex;

        var helpers = new List<int>();
        for (var b = 0; b < skeleton.Count; b++)
        {
            var p = skeleton[b].ParentIndex;
            if ((p == upperArm || p == lowerArm) && IsHelper(skeleton[b].Name))
                helpers.Add(b);
        }

        var fingers = new List<FingerRig>();
        var metas = new List<int>();
        var restWorld = skeleton.RestWorld;
        var handRest = restWorld[hand];

        List<int> Chain(string finger)
        {
            var chain = new List<int>();
            for (var k = 0; k < 4; k++)
            {
                // Deform bones before same-named controls (Rigify has both).
                var b = Find($"finger_{finger}_{k}_{s}", $"{finger}_{k}_{s}", $"{full}Hand{Cap(finger)}{k + 1}", $"mixamorig:{full}Hand{Cap(finger)}{k + 1}", $"{finger}_{(k + 1):00}_{lower}",
                    $"DEF-f_{finger}.{(k + 1):00}.{s}", $"DEF-{finger}.{(k + 1):00}.{s}", $"{finger}.{(k + 1):00}.{s}", $"f_{finger}.{(k + 1):00}.{s}");
                if (b < 0)
                    break;
                chain.Add(b);
            }
            return chain;
        }

        var jointsByKind = new Dictionary<FingerKind, List<int>>();
        foreach (var kind in Enum.GetValues<FingerKind>())
        {
            var name = kind.ToString().ToLowerInvariant();
            var chain = Chain(name);
            if (chain.Count == 0 && kind == FingerKind.Pinky)
                chain = Chain("little");
            // Keep only the bones that descend from the hand.
            chain = chain.Where(b => Descends(skeleton, b, hand)).Take(3).ToList();
            if (chain.Count > 0)
                jointsByKind[kind] = chain;
            var meta = Find($"finger_{name}_meta_{s}");
            if (meta >= 0)
                metas.Add(meta);
        }
        if (!jointsByKind.ContainsKey(FingerKind.Index) || !jointsByKind.ContainsKey(FingerKind.Middle))
            jointsByKind = FingersByShape(skeleton, hand) ?? jointsByKind;
        if (!jointsByKind.ContainsKey(FingerKind.Index) || !jointsByKind.ContainsKey(FingerKind.Middle))
            return null;

        Vector3 Local(int bone) => handRest.Inverse().TransformPoint(restWorld[bone].Pos);

        var indexBase = Local(jointsByKind[FingerKind.Index][0]);
        var middleBase = Local(jointsByKind[FingerKind.Middle][0]);
        var outer = jointsByKind.TryGetValue(FingerKind.Pinky, out var pinky) ? pinky : jointsByKind.GetValueOrDefault(FingerKind.Ring) ?? jointsByKind[FingerKind.Middle];
        var outerBase = Local(outer[0]);
        var knuckles = new[] { FingerKind.Index, FingerKind.Middle, FingerKind.Ring, FingerKind.Pinky }
            .Where(jointsByKind.ContainsKey).Select(k => Local(jointsByKind[k][0])).ToList();
        var knuckleCenter = knuckles.Aggregate(Vector3.Zero, (a, v) => a + v) / knuckles.Count;
        var wrist = Vector3.Zero;
        var fingerDir = SafeNormal(knuckleCenter - wrist, Vector3.UnitX);
        var knuckleLine = outerBase - indexBase;
        knuckleLine = SafeNormal(knuckleLine - fingerDir * Vector3.Dot(knuckleLine, fingerDir), MathQ.Perpendicular(fingerDir));

        // Palm normal: perpendicular to fingers and knuckles; the sign comes from anatomy.
        var normal = Vector3.Normalize(Vector3.Cross(fingerDir, knuckleLine));
        var vote = 0f;
        // Thumb sits on the palm side of the knuckle plane.
        if (jointsByKind.TryGetValue(FingerKind.Thumb, out var thumb))
        {
            var thumbTip = Local(thumb[^1]);
            vote += Vector3.Dot(thumbTip - wrist, normal) * 2f;
        }
        // Rest curl bends fingertips toward the palm.
        foreach (var kind in new[] { FingerKind.Index, FingerKind.Middle, FingerKind.Ring })
            if (jointsByKind.TryGetValue(kind, out var chain) && chain.Count >= 2)
                vote += Vector3.Dot(Local(chain[^1]) - Local(chain[0]), normal) - Vector3.Dot(Local(chain[1]) - Local(chain[0]), normal) * 0.5f;
        // Handedness fallback: for a right hand, fingers x knuckles(index->pinky) points out of
        // the back of the hand in a right-handed frame; mirrored for the left.
        if (MathF.Abs(vote) < 1e-3f)
            vote = side == Side.Right ? -1f : 1f;
        if (vote < 0f)
            normal = -normal;

        var middleChain = jointsByKind[FingerKind.Middle];
        var middleTip = Local(middleChain[^1]);
        var lastSeg = middleChain.Count >= 2 ? Vector3.Distance(Local(middleChain[^1]), Local(middleChain[^2])) : 1f;
        var handLength = Vector3.Distance(wrist, middleTip) + lastSeg * 0.8f;
        var palmWidth = MathF.Max(1e-3f, Vector3.Distance(indexBase, outerBase)) * 1.25f;
        var palmCenter = Vector3.Lerp(wrist, knuckleCenter, 0.55f) + normal * 0.0f;

        foreach (var (kind, chain) in jointsByKind)
        {
            var lengths = new float[chain.Count];
            var axes = new Vector3[chain.Count];
            var along = new Vector3[chain.Count];
            for (var j = 0; j < chain.Count; j++)
            {
                var head = Local(chain[j]);
                var tailWorld = j + 1 < chain.Count ? Local(chain[j + 1]) : head + (head - Local(j > 0 ? chain[j - 1] : hand)) * 0.8f;
                var dir = SafeNormal(tailWorld - head, fingerDir);
                lengths[j] = MathF.Max(0.05f, Vector3.Distance(head, tailWorld));
                // Flexion bends the segment toward the palm: axis = dir x normal (right-hand rule
                // turns dir toward -normal... pick the sign that moves the tip toward +normal).
                var axisHand = SafeNormal(Vector3.Cross(dir, normal), knuckleLine);
                if (kind == FingerKind.Thumb)
                {
                    // Thumb flexes across the palm toward the little finger.
                    axisHand = SafeNormal(Vector3.Cross(dir, SafeNormal(knuckleCenter + knuckleLine * palmWidth * 0.5f - head, normal)), axisHand);
                }
                // Check sign: rotating dir about the axis must move it toward the palm (or across it).
                var probeTarget = kind == FingerKind.Thumb ? SafeNormal(knuckleCenter + knuckleLine * palmWidth * 0.5f - head, normal) : normal;
                var rotated = Vector3.Transform(dir, Quaternion.CreateFromAxisAngle(axisHand, 0.3f));
                if (Vector3.Dot(rotated - dir, probeTarget) < 0f)
                    axisHand = -axisHand;
                // Into the joint's own rest frame.
                var jointRestInHand = XForm.Compose(handRest.Inverse(), restWorld[chain[j]]);
                axes[j] = Vector3.Normalize(Vector3.Transform(axisHand, Quaternion.Conjugate(jointRestInHand.Rot)));
                along[j] = Vector3.Normalize(Vector3.Transform(dir, Quaternion.Conjugate(jointRestInHand.Rot)));
            }
            var baseRest = XForm.Compose(handRest.Inverse(), restWorld[chain[0]]);
            var spreadHand = normal;
            var spreadLocal = Vector3.Normalize(Vector3.Transform(spreadHand, Quaternion.Conjugate(baseRest.Rot)));
            var maxFlex = kind == FingerKind.Thumb
                ? new[] { 0.9f, 1.0f, 1.3f }
                : new[] { 1.55f, 1.9f, 1.4f };
            fingers.Add(new FingerRig
            {
                Kind = kind,
                Joints = chain.ToArray(),
                Lengths = lengths,
                FlexAxes = axes,
                SpreadAxis = spreadLocal,
                MaxFlex = maxFlex.Take(chain.Count).ToArray(),
                Radius = handLength * (kind == FingerKind.Thumb ? 0.058f : 0.05f),
                Along = along,
            });
        }

        return new HandRig
        {
            Side = side,
            Skeleton = skeleton,
            Hand = hand,
            LowerArm = lowerArm,
            UpperArm = upperArm,
            Clavicle = clavicle,
            Helpers = helpers,
            Fingers = fingers.OrderBy(f => f.Kind).ToList(),
            Metacarpals = metas,
            PalmCenter = palmCenter,
            PalmNormal = normal,
            FingerDirection = fingerDir,
            KnuckleLine = knuckleLine,
            KnuckleCenter = knuckleCenter,
            HandLength = handLength,
            PalmWidth = palmWidth,
        };
    }

    private static string Cap(string s) => char.ToUpperInvariant(s[0]) + s[1..];

    // ------------------------------------------------------------------ rigs without known names

    /// <summary>Control/mechanism bones that are never the deforming hand or finger.</summary>
    private static readonly HashSet<string> ControlTokens = new(StringComparer.OrdinalIgnoreCase)
    {
        "ik", "ctrl", "cntrl", "control", "mch", "org", "drv", "master", "tweak", "vis", "pole", "target", "end", "helper", "twist", "hold", "attach", "widget", "parent", "socket", "nub",
    };

    /// <summary>Lowercase name tokens: "IK_Hand_Cntrl_L_015" → ik, hand, cntrl, l, 015.</summary>
    public static List<string> Tokens(string name)
    {
        var tokens = new List<string>();
        var current = new System.Text.StringBuilder();
        void Flush()
        {
            if (current.Length > 0)
                tokens.Add(current.ToString().ToLowerInvariant());
            current.Clear();
        }
        for (var i = 0; i < name.Length; i++)
        {
            var c = name[i];
            if (!char.IsLetterOrDigit(c))
            {
                Flush();
                continue;
            }
            // Camel case and letter/digit boundaries split words.
            if (current.Length > 0 && ((char.IsUpper(c) && char.IsLower(current[^1])) || char.IsDigit(c) != char.IsDigit(current[^1])))
                Flush();
            current.Append(c);
        }
        Flush();
        return tokens;
    }

    private static bool IsSide(List<string> tokens, Side side)
        => side == Side.Right ? tokens.Any(t => t is "r" or "right" or "rt") : tokens.Any(t => t is "l" or "left" or "lt");

    /// <summary>
    /// The deforming hand of a side on rigs with other conventions: a bone named hand/wrist with a
    /// side marker, not a control; deform ("DEF") bones and bones with many descendants win.
    /// </summary>
    private static int FindHandByTokens(Skeleton skeleton, Side side)
    {
        var best = -1;
        var bestScore = 0;
        for (var b = 0; b < skeleton.Count; b++)
        {
            var tokens = Tokens(skeleton[b].Name);
            if (!tokens.Any(t => t is "hand" or "wrist") || !IsSide(tokens, side) || tokens.Any(ControlTokens.Contains))
                continue;
            var descendants = 0;
            for (var c = 0; c < skeleton.Count; c++)
                if (Descends(skeleton, c, b))
                    descendants++;
            if (descendants < 6)
                continue;
            var score = descendants + (tokens.Contains("def") ? 1000 : 0);
            if (score > bestScore)
            {
                bestScore = score;
                best = b;
            }
        }
        return best;
    }

    /// <summary>
    /// Finger chains of a hand told apart by shape when their names say nothing ("Bone_R.004"):
    /// each chain is followed from the hand (through palm bones) up to three joints; the thumb is
    /// the chain whose direction differs most from the others, and the fingers are ordered by
    /// their distance from the thumb (index nearest, pinky farthest).
    /// </summary>
    private static Dictionary<FingerKind, List<int>>? FingersByShape(Skeleton skeleton, int hand)
    {
        bool Usable(int b)
        {
            var tokens = Tokens(skeleton[b].Name);
            return !tokens.Any(t => t is "end" or "nub" or "tip" or "mch" or "org" or "ik" or "ctrl" or "cntrl" or "drv" or "master" or "tweak" or "vis" or "helper" or "twist");
        }
        List<int> Children(int b) => Enumerable.Range(0, skeleton.Count).Where(c => skeleton[c].ParentIndex == b && Usable(c)).ToList();

        var roots = new List<int>();
        void Collect(int b, int depth)
        {
            foreach (var c in Children(b))
            {
                var kids = Children(c);
                // A palm/metacarpal bone that fans out: its children are the fingers.
                if (kids.Count >= 2 && depth == 0)
                    Collect(c, depth + 1);
                else if (kids.Count >= 1)
                    roots.Add(c);
            }
        }
        Collect(hand, 0);
        if (roots.Count < 3)
            return null;

        var raw = roots.Select(r =>
        {
            var chain = new List<int> { r };
            while (chain.Count < 5 && Children(chain[^1]) is { Count: > 0 } kids)
                chain.Add(kids[0]);
            return chain;
        }).Where(c => c.Count >= 2).ToList();
        if (raw.Count < 3)
            return null;
        // Four joints: the first is the metacarpal inside the palm, not a finger joint.
        var chains = raw.Select(c => (c.Count >= 4 ? c.Skip(1) : c).Take(3).ToList()).ToList();

        var rest = skeleton.RestWorld;
        Vector3 Dir(List<int> c) => SafeNormal(rest[c[^1]].Pos - rest[c[0]].Pos, Vector3.UnitX);
        // The thumb has one bone fewer than the fingers on most rigs; otherwise its base sits
        // nearest the wrist and it points away from the others.
        var counts = raw.Select(c => c.Count).ToList();
        var shortest = counts.Min();
        List<int> thumb;
        if (counts.Count(c => c == shortest) == 1 && counts.Count(c => c > shortest) >= 3)
            thumb = chains[counts.IndexOf(shortest)];
        else
            thumb = chains.OrderBy(c => Vector3.Distance(rest[c[0]].Pos, rest[hand].Pos) - 0.3f * chains.Where(o => o != c).Sum(o => 1f - Vector3.Dot(Dir(c), Dir(o)))).First();
        // Order along the knuckle line: from the knuckle nearest the thumb to the farthest one.
        var others = chains.Where(c => c != thumb).ToList();
        var near = others.OrderBy(c => Vector3.Distance(rest[c[0]].Pos, rest[thumb[0]].Pos)).First();
        var far = others.OrderByDescending(c => Vector3.Distance(rest[c[0]].Pos, rest[thumb[0]].Pos)).First();
        var line = rest[far[0]].Pos - rest[near[0]].Pos;
        var fingers = others.OrderBy(c => Vector3.Dot(rest[c[0]].Pos - rest[near[0]].Pos, line)).ToList();
        var result = new Dictionary<FingerKind, List<int>> { [FingerKind.Thumb] = thumb };
        var kinds = new[] { FingerKind.Index, FingerKind.Middle, FingerKind.Ring, FingerKind.Pinky };
        for (var i = 0; i < fingers.Count && i < kinds.Length; i++)
            result[kinds[i]] = fingers[i];
        return result;
    }

    private static bool IsHelper(string name)
    {
        var n = name.ToLowerInvariant();
        return n.Contains("twist") || n.Contains("helper") || n.Contains("roll") || n.Contains("ikrule") || n.Contains("_ik") || n.Contains("ik_") || n.Contains("hold") || n.Contains("attach") || n.Contains("target");
    }

    private static bool Descends(Skeleton skeleton, int bone, int ancestor)
    {
        for (var b = skeleton[bone].ParentIndex; b >= 0; b = skeleton[b].ParentIndex)
            if (b == ancestor)
                return true;
        return false;
    }

    private static Vector3 SafeNormal(Vector3 v, Vector3 fallback) => v.LengthSquared() > 1e-10f ? Vector3.Normalize(v) : fallback;

    /// <summary>Palm frame in hand-local space: X = fingers, Z = palm normal, Y completes it.</summary>
    public Quaternion PalmFrame => MathQ.FromAxes(FingerDirection, Vector3.Cross(PalmNormal, FingerDirection));
}
Debug: View Raw JSON Response
{
    "TotalCount": 136,
    "Files": [
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Code/WeaponImporter/WeaponHoldSystem.cs",
            "FileName": "WeaponHoldSystem.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "namespace WeaponImporter;\r\n\r\n/// <summary>\r\n/// Reads every <see cref=\"WeaponHold\"/>'s action triggers after components update and before\r\n/// the animgraph evaluates (Stage.UpdateBones, order -1), so auto-reset graph triggers set by\r\n/// game code in OnUpdate are never missed.\r\n/// </summary>\r\npublic sealed class WeaponHoldSystem : GameObjectSystem\r\n{\r\n    public WeaponHoldSystem( Scene scene ) : base( scene )\r\n    {\r\n        Listen( Stage.UpdateBones, -1, ReadTriggers, \"WeaponHold.ReadTriggers\" );\r\n    }\r\n\r\n    private void ReadTriggers()\r\n    {\r\n        foreach ( var hold in Scene.GetAllComponents<WeaponHold>() )\r\n        {\r\n            if ( hold.Active )\r\n                hold.ReadTriggers();\r\n        }\r\n    }\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Core/Grip/LibraryGripGenerators.cs",
            "FileName": "LibraryGripGenerators.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "#nullable enable annotations\r\n\r\nusing System.Numerics;\r\nusing WeaponImporter.Core.Analysis;\r\nusing WeaponImporter.Core.Geometry;\r\nusing WeaponImporter.Core.Grasp;\r\nusing WeaponImporter.Core.Hands;\r\nusing WeaponImporter.Core.Maths;\r\nusing WeaponImporter.Core.Rig;\r\nusing WeaponImporter.Core.Weapon;\r\n\r\nnamespace WeaponImporter.Core.Grip;\r\n\r\nusing Vector3 = System.Numerics.Vector3;\r\n\r\n/// <summary>\r\n/// Grips from the library (captured from animations): each preset for the requested hand is\r\n/// placed on the grip surface, refined against the geometry for this hand's size, and scored.\r\n/// </summary>\r\npublic sealed class PresetGripGenerator : IGripGenerator\r\n{\r\n    private readonly IReadOnlyList<GripPreset> _presets;\r\n\r\n    /// <summary>Per hand: only this preset is offered (the user picked it).</summary>\r\n    public string? OnlyRight { get; init; }\r\n    public string? OnlyLeft { get; init; }\r\n\r\n    public PresetGripGenerator(IEnumerable<GripPreset> presets) => _presets = presets.ToList();\r\n\r\n    public string Name => \"Library\";\r\n    public bool IsAvailable => _presets.Count > 0;\r\n\r\n    public IEnumerable<GraspCandidate> Generate(GraspRequest request, CancellationToken cancel = default)\r\n    {\r\n        var list = new List<GraspCandidate>();\r\n        foreach (var preset in _presets)\r\n        {\r\n            cancel.ThrowIfCancellationRequested();\r\n            if (preset.Side != request.Hand.Side || !Compatible(preset.Style, request.Style))\r\n                continue;\r\n            var only = request.Hand.Side == Side.Right ? OnlyRight : OnlyLeft;\r\n            if (!string.IsNullOrEmpty(only) && preset.Name != only)\r\n                continue;\r\n            var pose = GripLibrary.Apply(preset, request.Hand, request.Surface);\r\n            ContactRefiner.Refine(request, pose);\r\n            var quality = GraspEvaluator.Evaluate(request, pose);\r\n            list.Add(new GraspCandidate(pose, quality, preset.Name) { Bonus = preset.Priority });\r\n        }\r\n        return list.OrderByDescending(c => c.Rank);\r\n    }\r\n\r\n    /// <summary>A handguard grip can cradle a pump and vice versa; a pistol grip grip is a wrap.</summary>\r\n    public static bool Compatible(GripStyle preset, GripStyle surface) => preset == surface\r\n        || (preset, surface) is (GripStyle.Cradle, GripStyle.Pump) or (GripStyle.Pump, GripStyle.Cradle)\r\n        || (preset, surface) is (GripStyle.Wrap, GripStyle.Handle) or (GripStyle.Handle, GripStyle.Wrap);\r\n}\r\n\r\n/// <summary>\r\n/// Several generators competing: their candidates are pooled and ranked together (the solver\r\n/// then re-ranks the best ones with wrist bend and reach). A learned generator such as GrabNet\r\n/// plugs in here the same way; it runs inside the background grip solve, never on the UI thread.\r\n/// </summary>\r\npublic sealed class CompositeGripGenerator : IGripGenerator\r\n{\r\n    private readonly IReadOnlyList<IGripGenerator> _parts;\r\n\r\n    public CompositeGripGenerator(params IGripGenerator[] parts) => _parts = parts.Where(p => p.IsAvailable).ToList();\r\n\r\n    public string Name => string.Join(\" + \", _parts.Select(p => p.Name));\r\n    public bool IsAvailable => _parts.Count > 0;\r\n\r\n    public IEnumerable<GraspCandidate> Generate(GraspRequest request, CancellationToken cancel = default)\r\n        => _parts.SelectMany(p => p.Generate(request, cancel)).OrderByDescending(c => c.Rank).ToList();\r\n}\r\n\r\n/// <summary>\r\n/// The grip source per hand from the user's choice: \"\" = automatic (library grips and the\r\n/// procedural fit compete), \"Procedural\" = only the procedural fit, a preset name = that grip.\r\n/// </summary>\r\npublic sealed class ChoiceGripGenerator : IGripGenerator\r\n{\r\n    public const string Procedural = \"Procedural\";\r\n\r\n    private readonly IReadOnlyList<GripPreset> _presets;\r\n    private readonly string _right, _left;\r\n    private readonly ProceduralGripGenerator _procedural = new();\r\n\r\n    public ChoiceGripGenerator(IEnumerable<GripPreset> presets, string? right, string? left)\r\n    {\r\n        _presets = presets.ToList();\r\n        _right = right ?? \"\";\r\n        _left = left ?? \"\";\r\n    }\r\n\r\n    public string Name => \"Grips\";\r\n    public bool IsAvailable => true;\r\n\r\n    public IEnumerable<GraspCandidate> Generate(GraspRequest request, CancellationToken cancel = default)\r\n    {\r\n        var choice = request.Hand.Side == Side.Right ? _right : _left;\r\n        if (choice == Procedural)\r\n            return _procedural.Generate(request, cancel);\r\n        if (choice.Length > 0)\r\n        {\r\n            var chosen = new PresetGripGenerator(_presets.Where(p => p.Name == choice)).Generate(request, cancel).ToList();\r\n            if (chosen.Count > 0)\r\n                return chosen;\r\n        }\r\n        return new CompositeGripGenerator(new PresetGripGenerator(_presets), _procedural).Generate(request, cancel);\r\n    }\r\n\r\n    /// <summary>Only the library grips this hand may use (none when the procedural fit is chosen).</summary>\r\n    public IEnumerable<GraspCandidate> GenerateLibrary(GraspRequest request, CancellationToken cancel = default)\r\n    {\r\n        var choice = request.Hand.Side == Side.Right ? _right : _left;\r\n        if (choice == Procedural)\r\n            return Array.Empty<GraspCandidate>();\r\n        var presets = choice.Length > 0 ? _presets.Where(p => p.Name == choice) : _presets;\r\n        return new PresetGripGenerator(presets).Generate(request, cancel);\r\n    }\r\n}\r\n\r\n/// <summary>Captures the grips of a file's own arms (first-person rigs, animation packs).</summary>\r\npublic static class GripExtractor\r\n{\r\n    /// <summary>A hand counts as holding when its palm is this close to the weapon surface (inches).</summary>\r\n    public const float HoldingDistance = 2.5f;\r\n\r\n    /// <summary>Ranking bonus of grips taken from the weapon's own animation (see <see cref=\"GripPreset.Priority\"/>).</summary>\r\n    public const float OwnPriority = 2.5f;\r\n\r\n    /// <summary>The weapon's own grips, preferred when grips compete.</summary>\r\n    public static List<GripPreset> ExtractOwn(WeaponAnalysis a, string source)\r\n    {\r\n        var list = Extract(a, source);\r\n        foreach (var p in list)\r\n            p.Priority = OwnPriority;\r\n        return list;\r\n    }\r\n\r\n    /// <summary>\r\n    /// The hands in the analysed file's reference pose (canonical weapon space), captured on the\r\n    /// grip surface each one holds. Hands that aren't on the weapon are skipped.\r\n    /// </summary>\r\n    public static List<GripPreset> Extract(WeaponAnalysis a, string source)\r\n    {\r\n        var list = new List<GripPreset>();\r\n        var skeleton = a.Asset.Skeleton;\r\n        if (skeleton.Count == 0 || a.WeaponBvh.IsEmpty)\r\n            return list;\r\n        var world = ReferenceWorld(a);\r\n        foreach (var side in new[] { Side.Right, Side.Left })\r\n        {\r\n            var rig = HandRig.Build(skeleton, side);\r\n            if (rig is null || rig.Fingers.Count < 3)\r\n                continue;\r\n            var palm = world[rig.Hand].TransformPoint(rig.PalmCenter);\r\n            var candidate = side == Side.Right ? a.Primary : a.Support;\r\n            GripSurface? surface = null;\r\n            var style = side == Side.Right ? (a.Type.Type == WeaponType.Melee ? GripStyle.Handle : GripStyle.Wrap) : GripStyle.Cradle;\r\n            if (candidate is not null && Vector3.Distance(candidate.Surface.Contact, palm) < rig.HandLength)\r\n            {\r\n                surface = candidate.Surface;\r\n                style = candidate.Style;\r\n            }\r\n            else\r\n            {\r\n                var hint = side == Side.Right ? Vector3.UnitZ : Vector3.UnitX;\r\n                surface = SurfaceProbe.FromPoint(a.WeaponBvh, palm, hint);\r\n            }\r\n            if (surface is null || Vector3.Distance(surface.Contact, palm) > HoldingDistance + rig.PalmThickness)\r\n                continue;\r\n            var name = $\"{source} \u00b7 {(side == Side.Right ? \"right\" : \"left\")}\";\r\n            var preset = GripLibrary.Capture(rig, world, surface, style, name, source);\r\n            // A palm inside the surface means the \"weapon\" is the arms themselves (an arms-only\r\n            // file) or the hand isn't really holding it.\r\n            if (preset.Palm[2] < -0.12f)\r\n                continue;\r\n            preset.WeaponType = a.Type.Type.ToString();\r\n            list.Add(preset);\r\n        }\r\n        return list;\r\n    }\r\n\r\n    /// <summary>World transforms of the analysis reference frame (bind pose without a clip).</summary>\r\n    public static XForm[] ReferenceWorld(WeaponAnalysis a)\r\n    {\r\n        var skeleton = a.Asset.Skeleton;\r\n        var clip = a.Asset.FindClip(a.ReferenceClip);\r\n        if (clip is { FrameCount: > 0 })\r\n        {\r\n            var frame = Math.Clamp(a.ReferenceFrame, 0, clip.FrameCount - 1);\r\n            return new Pose(clip.Frames[frame]).ToWorld(skeleton);\r\n        }\r\n        return skeleton.RestWorld.ToArray();\r\n    }\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Core/Setup/ActionContacts.cs",
            "FileName": "ActionContacts.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "#nullable enable annotations\r\n\r\nusing System.Numerics;\r\nusing WeaponImporter.Core.Analysis;\r\nusing WeaponImporter.Core.Grip;\r\nusing WeaponImporter.Core.Hands;\r\nusing WeaponImporter.Core.Ik;\r\nusing WeaponImporter.Core.Maths;\r\n\r\nnamespace WeaponImporter.Core.Setup;\r\n\r\nusing Vector3 = System.Numerics.Vector3;\r\n\r\n/// <summary>A character action sampled over its normalized time (see CharacterPoser.SampleTrack).</summary>\r\npublic sealed record ActionTrack(AnimationRole Role, float Seconds, IReadOnlyList<(float Time, CharacterPose Pose)> Samples);\r\n\r\n/// <summary>Result of planning the support hand over the actions.</summary>\r\npublic sealed class ActionContactPlan\r\n{\r\n    /// <summary>Support hand on the magazine (weapon space), when the weapon has one.</summary>\r\n    public HandPose? Magazine { get; set; }\r\n    public GripRegion? MagazineRegion { get; set; }\r\n    public Dictionary<AnimationRole, ContactPlanner.Plan> Plans { get; } = new();\r\n}\r\n\r\n/// <summary>\r\n/// Plans the support hand over the character's actions once the grip is solved: where the\r\n/// animated hand goes relative to the weapon (it follows the hold bone), where it should touch\r\n/// this weapon instead (the magazine during reloads), and the contact keys that get it there with\r\n/// the smallest change to the animation. Pure maths; the samples come from the editor.\r\n/// </summary>\r\npublic static class ActionContacts\r\n{\r\n    public static ActionContactPlan Plan(WeaponAnalysis weapon, WeaponSetup setup, CharacterRig character, GripSolution grip, IReadOnlyList<ActionTrack> tracks, GripOptions? options = null, CancellationToken cancel = default)\r\n    {\r\n        var plan = new ActionContactPlan();\r\n        if (grip.Left is null || character.Left is not { } leftRig || !setup.UseSupportHand)\r\n            return plan;\r\n        var region = GripRegions.Magazine(weapon);\r\n        plan.MagazineRegion = region;\r\n\r\n        // Animated support wrist in weapon space, per sample.\r\n        List<ContactPlanner.Sample> Samples(ActionTrack track) => track.Samples\r\n            .Select(s => new ContactPlanner.Sample(s.Time, XForm.ToLocal(GripSolver.WeaponWorld(s.Pose, character, grip.WeaponInHold), s.Pose.World[leftRig.Hand])))\r\n            .ToList();\r\n\r\n        var reloads = tracks.Where(t => ActionTiming.ReloadRoles.Contains(t.Role)).ToList();\r\n        if (!setup.ReloadTouchesMagazine)\r\n        {\r\n            // Default: the character reloads as animated; the hand only lets go and comes back.\r\n            foreach (var track in reloads)\r\n                plan.Plans[track.Role] = ContactPlanner.PlanRelease(Samples(track), track.Seconds);\r\n            return plan;\r\n        }\r\n        if (region?.Surface is not null && reloads.Count > 0)\r\n        {\r\n            // Fit the magazine grab closest to where the animation brings the hand.\r\n            var first = Samples(reloads[0]);\r\n            var nearest = first.OrderBy(s => Vector3.Distance(s.Wrist.Pos, region.Surface.Contact)).First();\r\n            plan.Magazine = GripSolver.SolveOnRegion(weapon, region, character, grip, nearest.Wrist, options, cancel);\r\n        }\r\n\r\n        foreach (var track in reloads)\r\n        {\r\n            cancel.ThrowIfCancellationRequested();\r\n            if (plan.Magazine is null)\r\n                break;\r\n            plan.Plans[track.Role] = ContactPlanner.PlanReload(Samples(track), grip.Left.Wrist, plan.Magazine.Wrist, track.Seconds, weapon.WeaponBounds);\r\n        }\r\n        return plan;\r\n    }\r\n\r\n    /// <summary>Writes the planned tracks into the setup, leaving tracks the user edited alone.</summary>\r\n    public static void Apply(WeaponSetup setup, ActionContactPlan plan)\r\n    {\r\n        foreach (var (role, p) in plan.Plans)\r\n        {\r\n            if (setup.Contacts.TryGetValue(role, out var existing) && existing.Keys.Count > 0 && !existing.Generated)\r\n                continue;\r\n            setup.Contacts[role] = new ContactTrack { Keys = p.Keys, Generated = true, Note = p.Note };\r\n        }\r\n    }\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Tool/UI/Steps/CheckStep.cs",
            "FileName": "CheckStep.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "using Editor;\r\nusing Sandbox;\r\nusing WeaponImporter.Core.Setup;\r\n\r\nnamespace WeaponImporter.Tool.UI;\r\n\r\n/// <summary>Step 4: what still needs attention, then Bake and the generated files.</summary>\r\npublic sealed class CheckStep : StepPanel\r\n{\r\n\tpublic CheckStep( Widget parent, ImporterController controller ) : base( parent, controller )\r\n\t{\r\n\t\tcontroller.BusyChanged += MarkDirty;\r\n\t}\r\n\r\n\tpublic override string Title => \"Export\";\r\n\r\n\tprotected override string StructureKey()\r\n\t{\r\n\t\tvar checks = string.Join( \";\", C.Session.Checks.Select( c => $\"{c.Name}:{c.Severity}:{c.Message}\" ) );\r\n\t\treturn $\"{checks}|{C.Busy}|{C.LastBake?.GetHashCode()}\";\r\n\t}\r\n\r\n\tprotected override void Build()\r\n\t{\r\n\t\tBuildChecks();\r\n\t\tBuildBake();\r\n\t}\r\n\r\n\tprivate void BuildChecks()\r\n\t{\r\n\t\tvar checks = C.Session.Checks;\r\n\t\tvar problems = Validation.Problems( checks ).ToList();\r\n\t\tvar card = AddCard( \"fact_check\", \"Validation\", out var header, \"Everything the importer verified\" );\r\n\t\theader.AddStretchCell();\r\n\t\tif ( problems.Count == 0 )\r\n\t\t\theader.Add( new Pill( card, \"ALL GOOD\", Theme.Green ) );\r\n\t\telse\r\n\t\t\theader.Add( new Pill( card, $\"{problems.Count} TO LOOK AT\", problems.Any( p => p.Severity == CheckSeverity.Error ) ? Theme.Red : Theme.Yellow ) );\r\n\r\n\t\tforeach ( var check in problems )\r\n\t\t\tcard.Layout.Add( new CheckLine( card, check, expanded: true, () => Fix( check ) ) );\r\n\t\tif ( problems.Count > 0 )\r\n\t\t\tcard.Layout.AddSpacingCell( 4 );\r\n\t\tforeach ( var check in checks.Where( c => !c.NeedsAttention ) )\r\n\t\t\tcard.Layout.Add( new CheckLine( card, check, expanded: false, check.AutoFix is null ? null : () => Fix( check ) ) );\r\n\t\tif ( checks.Count == 0 )\r\n\t\t\tcard.Layout.Add( UiStyle.Muted( new Label( \"Nothing checked yet.\", card ) ) );\r\n\t}\r\n\r\n\tprivate void Fix( CheckResult check )\r\n\t{\r\n\t\tSafe( () =>\r\n\t\t{\r\n\t\t\tcheck.AutoFix?.Invoke();\r\n\t\t\tC.Session.Revalidate();\r\n\t\t\tC.MarkChanged();\r\n\t\t\tC.ScheduleResolve( 0.01f );\r\n\t\t\tC.SetStatus( $\"{check.Name}: {check.AutoFixLabel.ToLowerInvariant()} applied.\", Theme.Green );\r\n\t\t} );\r\n\t}\r\n\r\n\tprivate void BuildBake()\r\n\t{\r\n\t\tvar session = C.Session;\r\n\t\tvar card = AddCard( \"inventory_2\", \"Bake\", out _, \"Write the model, animations, attachments and a ready prefab\" );\r\n\t\tcard.Layout.Add( UiStyle.Muted( new Label( $\"Output: {session.OutputFolder}/\", card ) { WordWrap = true, ToolTip = \"Files are written here inside the project's assets\" }, small: true ) );\r\n\t\tvar s = session.Setup;\r\n\t\tif ( session.Analysis?.ArmBones.Count > 0 )\r\n\t\t{\r\n\t\t\tvar fp = card.Layout.Add( new Checkbox( \"Bake the first-person viewmodel\", card ) { Value = s.ExportFirstPerson } );\r\n\t\t\tfp.ToolTip = \"Also write <name>_fp.vmdl: this file's own arms, weapon and camera with every animation as authored. The prefab shows it to the player holding the weapon, in sync with the third-person hold.\";\r\n\t\t\tfp.StateChanged = _ => s.ExportFirstPerson = fp.Value;\r\n\t\t}\r\n\t\tvar sizeRow = UiStyle.FieldRow( card, card.Layout, \"Textures\", \"Largest texture size in the baked weapon; bigger images are scaled down (smaller files, same look at normal distances)\" );\r\n\t\tvar size = sizeRow.Add( UiStyle.Framed( new ComboBox( card ) ), 1 );\r\n\t\tforeach ( var (label, value) in new[] { (\"Original size\", 0), (\"4096\", 4096), (\"2048\", 2048), (\"1024\", 1024) } )\r\n\t\t{\r\n\t\t\tvar v = value;\r\n\t\t\tsize.AddItem( label, \"photo_size_select_large\", () => s.MaxTextureSize = v, selected: s.MaxTextureSize == value );\r\n\t\t}\r\n\t\tvar corrected = card.Layout.Add( new Checkbox( \"Bake corrected third-person animations\", card ) { Value = s.BakeCorrectedAnimations } );\r\n\t\tcorrected.ToolTip = \"Also record the character's actions with the hands on this weapon as new clips (<name>_corrected.vmdl). The character's own animations are never changed.\";\r\n\t\tcorrected.StateChanged = _ => s.BakeCorrectedAnimations = corrected.Value;\r\n\t\tvar use = card.Layout.Add( new Checkbox( \"Play the corrected clips in the prefab\", card ) { Value = s.UseCorrectedAnimations } );\r\n\t\tuse.ToolTip = \"Off: the prefab corrects the character's live animation every frame (works with any animgraph blend). On: it plays the baked corrected clips over the upper body.\";\r\n\t\tuse.StateChanged = _ => s.UseCorrectedAnimations = use.Value;\r\n\t\tvar bake = card.Layout.Add( new Button.Primary( \"Bake\", card ) { Icon = \"inventory_2\", Tint = Theme.Green, FixedHeight = 36, Enabled = !C.Busy, ToolTip = \"Write and compile the weapon model and its prefab\" } );\r\n\t\tbake.Clicked = () => _ = C.BakeAsync();\r\n\r\n\t\tvar result = C.LastBake;\r\n\t\tif ( result is null )\r\n\t\t\treturn;\r\n\r\n\t\tcard.Layout.Add( new SectionHeader( card, result.Success ? \"Baked\" : \"Bake finished with errors\" ) );\r\n\t\tvar links = card.Layout.AddRow();\r\n\t\tlinks.Spacing = 6;\r\n\t\tlinks.Add( new UiButton( card, \"Open model\", \"view_in_ar\", () => Open( result.ModelPath ), result.ModelPath ) { Enabled = FindAsset( result.ModelPath ) is not null } );\r\n\t\tlinks.Add( new UiButton( card, \"Open prefab\", \"inventory_2\", () => Open( result.PrefabPath ), result.PrefabPath ) { Enabled = FindAsset( result.PrefabPath ) is not null } );\r\n\t\tlinks.Add( new UiButton( card, \"Show in asset browser\", \"folder_open\", () => Reveal( result.ModelPath ), \"Reveal the generated model in the asset browser\" ) );\r\n\t\tlinks.AddStretchCell();\r\n\r\n\t\tforeach ( var file in result.Files )\r\n\t\t{\r\n\t\t\tvar errors = result.Errors.TryGetValue( file, out var list ) ? list : null;\r\n\t\t\tvar row = card.Layout.AddRow();\r\n\t\t\trow.Spacing = 6;\r\n\t\t\trow.Add( new IconLabel( card, errors is null ? \"check_circle\" : \"error\", errors is null ? Theme.Green : Theme.Red ) );\r\n\t\t\trow.Add( new Label( file, card ) { ToolTip = AssetCompiler.Absolute( file ) }, 1 );\r\n\t\t\tif ( errors is not null )\r\n\t\t\t{\r\n\t\t\t\tforeach ( var e in errors.Take( 6 ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar er = card.Layout.AddRow();\r\n\t\t\t\t\ter.AddSpacingCell( 26 );\r\n\t\t\t\t\ter.Add( UiStyle.Colored( new Label( e, card ) { WordWrap = true }, Theme.Red ), 1 );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t\tforeach ( var (file, errors) in result.Errors.Where( kv => !result.Files.Contains( kv.Key ) ) )\r\n\t\t\tcard.Layout.Add( UiStyle.Colored( new Label( $\"{file}: {errors.FirstOrDefault()}\", card ) { WordWrap = true }, Theme.Red ) );\r\n\t\tforeach ( var note in result.Notes )\r\n\t\t\tcard.Layout.Add( UiStyle.Muted( new Label( note, card ) { WordWrap = true }, small: true ) );\r\n\t}\r\n\r\n\tprivate static Asset FindAsset( string relative ) => string.IsNullOrEmpty( relative ) ? null : AssetSystem.FindByPath( relative );\r\n\r\n\tprivate void Open( string relative )\r\n\t{\r\n\t\tSafe( () =>\r\n\t\t{\r\n\t\t\tvar asset = FindAsset( relative );\r\n\t\t\tif ( asset is null )\r\n\t\t\t\tC.SetStatus( $\"{relative} is not in the asset system yet.\", Theme.Yellow );\r\n\t\t\telse\r\n\t\t\t\tasset.OpenInEditor();\r\n\t\t} );\r\n\t}\r\n\r\n\tprivate void Reveal( string relative )\r\n\t{\r\n\t\tSafe( () =>\r\n\t\t{\r\n\t\t\tvar asset = FindAsset( relative );\r\n\t\t\tif ( asset is not null )\r\n\t\t\t\tAssetBrowser.OpenTo( asset );\r\n\t\t\telse\r\n\t\t\t\tEditorUtility.OpenFolder( System.IO.Path.GetDirectoryName( AssetCompiler.Absolute( relative ) ) );\r\n\t\t} );\r\n\t}\r\n\r\n\tpublic override void OnDestroyed()\r\n\t{\r\n\t\tC.BusyChanged -= MarkDirty;\r\n\t\tbase.OnDestroyed();\r\n\t}\r\n}\r\n\r\n/// <summary>\"\u2713 Skeleton\" / \"! Left wrist penetration detected\" / \"\u00d7 Muzzle missing\".</summary>\r\npublic sealed class CheckLine : Widget\r\n{\r\n\tprivate readonly CheckResult _check;\r\n\tprivate readonly bool _expanded;\r\n\r\n\tpublic CheckLine( Widget parent, CheckResult check, bool expanded, Action fix ) : base( parent )\r\n\t{\r\n\t\t_check = check;\r\n\t\t_expanded = expanded;\r\n\t\tLayout = Layout.Row();\r\n\t\tLayout.Spacing = 8;\r\n\t\tLayout.Margin = expanded ? new Sandbox.UI.Margin( 10, 7, 7, 7 ) : new Sandbox.UI.Margin( 10, 1, 4, 1 );\r\n\t\tvar color = ColorOf( check.Severity );\r\n\t\tLayout.Add( new Glyph( this, GlyphOf( check.Severity ), color ) );\r\n\t\tvar text = Layout.AddColumn( 1 );\r\n\t\ttext.Spacing = 2;\r\n\t\tif ( expanded )\r\n\t\t{\r\n\t\t\ttext.Add( UiStyle.Colored( new Label( check.Name, this ), color, bold: true ) );\r\n\t\t\tif ( !string.IsNullOrEmpty( check.Message ) )\r\n\t\t\t\ttext.Add( new Label( check.Message, this ) { WordWrap = true } );\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tvar line = text.AddRow();\r\n\t\t\tline.Spacing = 6;\r\n\t\t\tline.Add( new Label( check.Name, this ) );\r\n\t\t\tif ( !string.IsNullOrEmpty( check.Message ) )\r\n\t\t\t\tline.Add( UiStyle.Muted( new Label( check.Message, this ) { ToolTip = check.Message, WordWrap = true, MinimumWidth = 20 }, small: true ), 1 );\r\n\t\t\telse\r\n\t\t\t\tline.AddStretchCell();\r\n\t\t}\r\n\t\tif ( fix is not null && check.AutoFix is not null )\r\n\t\t{\r\n\t\t\tvar button = Layout.Add( new UiButton( this, check.AutoFixLabel, \"auto_fix_high\", null, $\"Apply the safe fix for: {check.Name}\" ) );\r\n\t\t\tbutton.Clicked = fix;\r\n\t\t}\r\n\t\tToolTip = string.IsNullOrEmpty( check.Message ) ? check.Name : $\"{check.Name}: {check.Message}\";\r\n\t}\r\n\r\n\tpublic static Color ColorOf( CheckSeverity s ) => s switch\r\n\t{\r\n\t\tCheckSeverity.Error => Theme.Red,\r\n\t\tCheckSeverity.Warning => Theme.Yellow,\r\n\t\tCheckSeverity.Info => Theme.Blue,\r\n\t\t_ => Theme.Green,\r\n\t};\r\n\r\n\tpublic static string GlyphOf( CheckSeverity s ) => s switch\r\n\t{\r\n\t\tCheckSeverity.Error => \"\u00d7\",\r\n\t\tCheckSeverity.Warning => \"!\",\r\n\t\tCheckSeverity.Info => \"i\",\r\n\t\t_ => \"\u2713\",\r\n\t};\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tif ( !_expanded )\r\n\t\t\treturn;\r\n\t\tPaint.Antialiasing = true;\r\n\t\tvar color = ColorOf( _check.Severity );\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( color.WithAlpha( .07f ) );\r\n\t\tPaint.DrawRect( LocalRect, 4 );\r\n\t\tPaint.SetBrush( color );\r\n\t\tPaint.DrawRect( new Rect( 0, 0, 3, Height ), 1.5f );\r\n\t}\r\n\r\n\tprivate sealed class Glyph : Widget\r\n\t{\r\n\t\tprivate readonly string _glyph;\r\n\t\tprivate readonly Color _color;\r\n\r\n\t\tpublic Glyph( Widget parent, string glyph, Color color ) : base( parent )\r\n\t\t{\r\n\t\t\t_glyph = glyph;\r\n\t\t\t_color = color;\r\n\t\t\tFixedSize = 16;\r\n\t\t}\r\n\r\n\t\tprotected override void OnPaint()\r\n\t\t{\r\n\t\t\tPaint.SetPen( _color );\r\n\t\t\tPaint.SetDefaultFont( 9, 700 );\r\n\t\t\tPaint.DrawText( LocalRect, _glyph, TextFlag.Center );\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Tool/UI/Timeline/TimelineCard.cs",
            "FileName": "TimelineCard.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "using Editor;\r\nusing Sandbox;\r\nusing WeaponImporter.Core.Analysis;\r\n\r\nnamespace WeaponImporter.Tool.UI;\r\n\r\n/// <summary>Transport for the selected action (play, step, speed, loop, scrub) above its contact and event lanes.</summary>\r\npublic sealed class TimelineCard : Card\r\n{\r\n\tprivate readonly ImporterController _c;\r\n\tprivate readonly IconButton _play;\r\n\tprivate readonly IconButton _loop;\r\n\tprivate readonly FloatSlider _scrub;\r\n\tprivate readonly Label _clock;\r\n\tprivate readonly Label _role;\r\n\tprivate readonly TimelineLanes _lanes;\r\n\tprivate bool _scrubbing;\r\n\r\n\tpublic TimelineCard( Widget parent, ImporterController controller ) : base( parent )\r\n\t{\r\n\t\t_c = controller;\r\n\t\tLayout.Margin = new Sandbox.UI.Margin( 10, 6, 10, 8 );\r\n\t\tLayout.Spacing = 4;\r\n\r\n\t\tvar transport = Layout.AddRow();\r\n\t\ttransport.Spacing = 6;\r\n\t\t_role = transport.Add( UiStyle.Bold( new Label( \"Idle\", this ) { FixedWidth = TimelineLanes.LabelWidth - 6, FixedHeight = UiStyle.ControlHeight, ToolTip = \"The action being previewed; pick another in the preview header or in Animations\" } ) );\r\n\t\t_play = transport.Add( UiStyle.Icon( this, \"pause\", controller.TogglePlay, \"Play / pause (Space)\" ) );\r\n\t\ttransport.Add( UiStyle.Icon( this, \"chevron_left\", () => controller.StepFrame( -1 ), \"Previous frame (Left arrow)\" ) );\r\n\t\ttransport.Add( UiStyle.Icon( this, \"chevron_right\", () => controller.StepFrame( 1 ), \"Next frame (Right arrow)\" ) );\r\n\t\t_scrub = transport.Add( new FloatSlider( this ) { Minimum = 0, Maximum = 1, Value = 0, ToolTip = \"Scrub through the action\", FixedHeight = UiStyle.ControlHeight }, 1 );\r\n\t\t_scrub.OnValueEdited = () =>\r\n\t\t{\r\n\t\t\t_scrubbing = true;\r\n\t\t\tcontroller.Seek( _scrub.Value );\r\n\t\t\t_scrubbing = false;\r\n\t\t};\r\n\t\t_clock = transport.Add( UiStyle.Muted( new Label( \"0.00 s\", this ) { FixedWidth = 92, FixedHeight = UiStyle.ControlHeight, Alignment = TextFlag.RightCenter, ToolTip = \"Time in the action / its length\" } ) );\r\n\t\tvar speed = transport.Add( UiStyle.Framed( new ComboBox( this ) { FixedWidth = 70, ToolTip = \"Playback speed\" } ) );\r\n\t\tforeach ( var s in new[] { 0.25f, 0.5f, 1f, 2f } )\r\n\t\t{\r\n\t\t\tvar v = s;\r\n\t\t\tspeed.AddItem( $\"{s:0.##}\u00d7\", null, () => controller.Speed = v, selected: s == 1f );\r\n\t\t}\r\n\t\t_loop = transport.Add( UiStyle.Toggle( this, \"repeat\", true, null, \"Loop the action\" ) );\r\n\t\t_loop.OnToggled = on => controller.Loop = on;\r\n\r\n\t\t_lanes = Layout.Add( new TimelineLanes( this, controller ) );\r\n\r\n\t\tcontroller.RoleChanged += Refresh;\r\n\t\tcontroller.Changed += Refresh;\r\n\t}\r\n\r\n\tprivate void Refresh()\r\n\t{\r\n\t\t_role.Text = AnimationRoles.Label( _c.Role );\r\n\t\t_lanes.Update();\r\n\t}\r\n\r\n\t/// <summary>Per frame: playhead, clock and play icon.</summary>\r\n\tpublic void Tick()\r\n\t{\r\n\t\tvar seconds = _c.RoleSeconds( _c.Role );\r\n\t\tif ( !_scrubbing )\r\n\t\t\t_scrub.Value = _c.Time;\r\n\t\t_clock.Text = $\"{_c.Time * seconds:0.00} / {seconds:0.00} s\";\r\n\t\tvar icon = _c.Playing ? \"pause\" : \"play_arrow\";\r\n\t\tif ( _play.Icon != icon )\r\n\t\t{\r\n\t\t\t_play.Icon = icon;\r\n\t\t\t_play.Update();\r\n\t\t}\r\n\t\tif ( _role.Text != AnimationRoles.Label( _c.Role ) )\r\n\t\t\t_role.Text = AnimationRoles.Label( _c.Role );\r\n\t\t_lanes.SetPlayhead( _c.Time );\r\n\t}\r\n\r\n\tpublic override void OnDestroyed()\r\n\t{\r\n\t\t_c.RoleChanged -= Refresh;\r\n\t\t_c.Changed -= Refresh;\r\n\t\tbase.OnDestroyed();\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Tool/UI/Widgets/Card.cs",
            "FileName": "Card.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "using Editor;\r\nusing Sandbox;\r\n\r\nnamespace WeaponImporter.Tool.UI;\r\n\r\n/// <summary>\r\n/// A rounded dark-gray panel on the gray window. Its column layout holds an optional header\r\n/// row (icon, title, then controls) above the content.\r\n/// </summary>\r\npublic class Card : Widget\r\n{\r\n\tpublic Card( Widget parent, bool row = false ) : base( parent )\r\n\t{\r\n\t\tLayout = row ? Layout.Row() : Layout.Column();\r\n\t\tLayout.Margin = 10;\r\n\t\tLayout.Spacing = 8;\r\n\t}\r\n\r\n\t/// <summary>Adds the header row: an icon and a title, then whatever the caller adds to the returned row.</summary>\r\n\tpublic Layout Header( string icon, string title, string tooltip = null )\r\n\t{\r\n\t\tvar row = Layout.AddRow();\r\n\t\trow.Spacing = 6;\r\n\t\trow.Add( new CardIcon( this, icon ) );\r\n\t\tvar label = row.Add( new Label( title, this ) { ToolTip = tooltip } );\r\n\t\tlabel.SetStyles( \"font-weight: 600;\" );\r\n\t\treturn row;\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.SetPen( Theme.ControlBackground.Lighten( .25f ), 1 );\r\n\t\tPaint.SetBrush( Theme.ControlBackground );\r\n\t\tPaint.DrawRect( LocalRect.Shrink( 1 ), 6 );\r\n\t}\r\n\r\n\tprivate sealed class CardIcon : Widget\r\n\t{\r\n\t\tprivate readonly string _icon;\r\n\r\n\t\tpublic CardIcon( Widget parent, string icon ) : base( parent )\r\n\t\t{\r\n\t\t\t_icon = icon;\r\n\t\t\tFixedSize = 18;\r\n\t\t}\r\n\r\n\t\tprotected override void OnPaint()\r\n\t\t{\r\n\t\t\tPaint.SetPen( Theme.Green );\r\n\t\t\tPaint.DrawIcon( LocalRect, _icon, 16 );\r\n\t\t}\r\n\t}\r\n}\r\n\r\n/// <summary>A small round light before the status text: amber while working, green when ready, red on errors.</summary>\r\npublic sealed class StatusDot : Widget\r\n{\r\n\tprivate Color _color = Theme.TextLight;\r\n\r\n\tpublic StatusDot( Widget parent ) : base( parent )\r\n\t{\r\n\t\tFixedSize = 10;\r\n\t}\r\n\r\n\tpublic Color Color\r\n\t{\r\n\t\tget => _color;\r\n\t\tset\r\n\t\t{\r\n\t\t\tif ( _color == value )\r\n\t\t\t\treturn;\r\n\t\t\t_color = value;\r\n\t\t\tUpdate();\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( _color );\r\n\t\tPaint.DrawRect( LocalRect.Shrink( 1 ), 4 );\r\n\t}\r\n}\r\n\r\n/// <summary>Small uppercase caption with a hairline, separating groups inside a card.</summary>\r\npublic sealed class SectionHeader : Widget\r\n{\r\n\tprivate readonly string _text;\r\n\r\n\tpublic SectionHeader( Widget parent, string text ) : base( parent )\r\n\t{\r\n\t\t_text = text.ToUpperInvariant();\r\n\t\tFixedHeight = 18;\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tPaint.SetDefaultFont( 7, 600 );\r\n\t\tPaint.SetPen( Theme.TextLight );\r\n\t\tvar size = Paint.MeasureText( _text );\r\n\t\tPaint.DrawText( new Rect( 0, 0, size.x + 2, Height ), _text, TextFlag.LeftCenter );\r\n\t\tPaint.SetPen( Theme.ControlBackground.Lighten( .45f ), 1 );\r\n\t\tPaint.DrawLine( new Vector2( size.x + 10, Height * .5f ), new Vector2( Width, Height * .5f ) );\r\n\t}\r\n}\r\n\r\n/// <summary>Small muted uppercase column caption (list headers).</summary>\r\npublic sealed class SectionCaption : Widget\r\n{\r\n\tprivate readonly string _text;\r\n\tprivate readonly TextFlag _align;\r\n\r\n\tpublic SectionCaption( Widget parent, string text, float width, TextFlag align = TextFlag.LeftCenter ) : base( parent )\r\n\t{\r\n\t\t_text = text.ToUpperInvariant();\r\n\t\t_align = align;\r\n\t\tFixedHeight = 16;\r\n\t\tif ( width > 0 )\r\n\t\t\tFixedWidth = width;\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tPaint.SetDefaultFont( 7, 600 );\r\n\t\tPaint.SetPen( Theme.TextLight );\r\n\t\tPaint.DrawText( LocalRect, _text, _align );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Code/WeaponImporter/WeaponGripProfile.cs",
            "FileName": "WeaponGripProfile.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "namespace WeaponImporter;\r\n\r\n/// <summary>\r\n/// A reusable grip: how a character holds one weapon, made by the Weapon Importer. Transforms are\r\n/// \"px,py,pz,qx,qy,qz,qw\" in the weapon model's space, finger poses \"bone=qx,qy,qz,qw;...\" (the\r\n/// same formats as <see cref=\"WeaponHold\"/>). Assign it to a Weapon Hold to use it; nothing here\r\n/// is computed at runtime beyond the small IK correction.\r\n/// </summary>\r\n[AssetType( Name = \"Weapon Grip Profile\", Extension = \"wgrip\", Category = \"Weapons\" )]\r\npublic sealed class WeaponGripProfile : GameResource\r\n{\r\n    [Header( \"Hands\" )]\r\n    /// <summary>The hand that holds the weapon's main grip (\"R\" or \"L\").</summary>\r\n    public string PrimaryHand { get; set; } = \"R\";\r\n\r\n    /// <summary>Primary hand bone in weapon space.</summary>\r\n    public string PrimaryGripTransform { get; set; } = \"\";\r\n\r\n    /// <summary>Primary hand finger rotations.</summary>\r\n    public string PrimaryFingerPose { get; set; } = \"\";\r\n\r\n    /// <summary>Support hand bone in weapon space (\"\" = one-handed).</summary>\r\n    public string SecondaryGripTransform { get; set; } = \"\";\r\n\r\n    /// <summary>Support hand finger rotations.</summary>\r\n    public string SecondaryFingerPose { get; set; } = \"\";\r\n\r\n    /// <summary>Where the index finger rests, weapon space (zero = no trigger).</summary>\r\n    public Vector3 TriggerTarget { get; set; }\r\n\r\n    /// <summary>Where the elbows point, weapon space; the arm IK leans toward them (keeps elbows steady).</summary>\r\n    public Vector3 PrimaryElbowHint { get; set; }\r\n    public Vector3 SecondaryElbowHint { get; set; }\r\n\r\n    public bool TwoHanded { get; set; } = true;\r\n\r\n    [Header( \"Weapon\" )]\r\n    /// <summary>Weapon model relative to the hold bone.</summary>\r\n    public string WeaponOffset { get; set; } = \"\";\r\n\r\n    /// <summary>Character bone the weapon is held by.</summary>\r\n    public string HoldBone { get; set; } = \"hold_R\";\r\n\r\n    /// <summary>Dual weapons: the left-hand copy's root bone (\"\" = a single weapon).</summary>\r\n    public string SecondWeaponBone { get; set; } = \"\";\r\n\r\n    /// <summary>Dual weapons: the character bone the left-hand copy is held by.</summary>\r\n    public string SecondHoldBone { get; set; } = \"\";\r\n\r\n    /// <summary>Dual weapons: the left-hand copy's bone relative to <see cref=\"SecondHoldBone\"/>.</summary>\r\n    public string SecondWeaponOffset { get; set; } = \"\";\r\n\r\n    [Header( \"Contact\" )]\r\n    /// <summary>Per-action hand contact tracks (JSON, see <see cref=\"WeaponHold.Contacts\"/>).</summary>\r\n    public string Contacts { get; set; } = \"\";\r\n\r\n    /// <summary>Per-action timing, weapon sequence and trigger parameter (JSON).</summary>\r\n    public string Actions { get; set; } = \"\";\r\n\r\n    [Range( 0, 1 )] public float Smoothing { get; set; } = 0.35f;\r\n    public float ReachSlack { get; set; } = 1.5f;\r\n    [Range( 0, 1 )] public float TransitionSeconds { get; set; } = 0.15f;\r\n\r\n    [Header( \"Third person\" )]\r\n    /// <summary>Citizen holdtype the weapon was fitted to (-1 = leave to the game).</summary>\r\n    public int HoldType { get; set; } = -1;\r\n\r\n    /// <summary>Character model the grip was fitted to.</summary>\r\n    public string Character { get; set; } = \"\";\r\n\r\n    /// <summary>Replacement character animations per action (JSON).</summary>\r\n    public string CharacterActions { get; set; } = \"\";\r\n\r\n    [Header( \"First person\" )]\r\n    /// <summary>Weapon sequence for each first-person action (JSON: role \u2192 sequence).</summary>\r\n    public string FirstPersonSequences { get; set; } = \"\";\r\n\r\n    /// <summary>Suggested first-person field of view.</summary>\r\n    public float FirstPersonFieldOfView { get; set; } = 70f;\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": ".obj/__compiler_extra.cs",
            "FileName": "__compiler_extra.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"Weapon Importer\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"chomnr_weapon_importer\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"notpointless\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"notpointless.chomnr_weapon_importer\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"29\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-09-26T07:52:10.4512751Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.120.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.120.0\")]"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Core/Import/GltfWeaponReader.cs",
            "FileName": "GltfWeaponReader.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "#nullable enable annotations\r\n\r\nusing System.Numerics;\r\nusing System.Text.Json;\r\nusing WeaponImporter.Core.Formats.Gltf;\r\nusing WeaponImporter.Core.Geometry;\r\nusing WeaponImporter.Core.Maths;\r\nusing WeaponImporter.Core.Rig;\r\nusing WeaponImporter.Core.Weapon;\r\n\r\nnamespace WeaponImporter.Core.Import;\r\n\r\nusing Vector2 = System.Numerics.Vector2;\r\nusing Vector3 = System.Numerics.Vector3;\r\n\r\n/// <summary>Reads a glTF/GLB weapon: skeleton, clips and bind-pose triangles in model space.</summary>\r\npublic static class GltfWeaponReader\r\n{\r\n    public static WeaponAsset Read(byte[] data, string name, string sourcePath = \"\", Func<string, byte[]>? externalBuffers = null, float sampleFps = 30f)\r\n    {\r\n        var imported = GltfImporter.Import(data, new GltfImportOptions { SampleFps = sampleFps, ExternalBufferResolver = externalBuffers }, out var document, out var boneByNode);\r\n        var conversion = SpaceConversion.For(imported);\r\n        var skeleton = conversion.Skeleton(imported.Skeleton);\r\n        var clips = conversion.Clips(imported.Clips, imported.Skeleton, skeleton);\r\n\r\n        var root = document.Root;\r\n        var nodes = document.Nodes;\r\n        var accessors = root.TryGetProperty(\"accessors\", out var acc) ? acc : default;\r\n        var views = root.TryGetProperty(\"bufferViews\", out var bv) ? bv : default;\r\n        if (!root.TryGetProperty(\"meshes\", out var meshes) || meshes.ValueKind != JsonValueKind.Array)\r\n            throw new FormatException(\"The glTF contains no meshes.\");\r\n        root.TryGetProperty(\"skins\", out var skins);\r\n        root.TryGetProperty(\"nodes\", out var nodeArray);\r\n\r\n        // World matrices (meters) of every node.\r\n        var world = new Matrix4x4[nodes.Count];\r\n        var done = new bool[nodes.Count];\r\n        Matrix4x4 World(int i)\r\n        {\r\n            if (done[i])\r\n                return world[i];\r\n            var n = nodes[i];\r\n            var local = Matrix4x4.CreateScale(n.Scale) * Matrix4x4.CreateFromQuaternion(n.Rotation) * Matrix4x4.CreateTranslation(n.Translation);\r\n            done[i] = true; // guards malformed cycles\r\n            world[i] = n.Parent >= 0 ? local * World(n.Parent) : local;\r\n            return world[i];\r\n        }\r\n\r\n        int BoneOf(int node)\r\n        {\r\n            for (var n = node; n >= 0; n = nodes[n].Parent)\r\n                if (boneByNode.TryGetValue(n, out var boneName))\r\n                    return skeleton.IndexOf(boneName);\r\n            return 0;\r\n        }\r\n\r\n        var positions = new List<Vector3>();\r\n        var vertexBone = new List<int>();\r\n        var indices = new List<int>();\r\n        var triPart = new List<int>();\r\n        var partNames = new List<string>();\r\n        var vertexNormals = new List<Vector3>();\r\n        var vertexUVs = new List<Vector2>();\r\n        var triMaterial = new List<int>();\r\n        var anyNormals = false;\r\n        var anyUVs = false;\r\n        var materialReader = new GltfMaterialReader(document, sourcePath, externalBuffers);\r\n\r\n        for (var nodeIndex = 0; nodeIndex < nodes.Count; nodeIndex++)\r\n        {\r\n            var nodeJson = nodeArray[nodeIndex];\r\n            if (!nodeJson.TryGetProperty(\"mesh\", out var meshIndexProp))\r\n                continue;\r\n            var meshIndex = meshIndexProp.GetInt32();\r\n            if (meshIndex < 0 || meshIndex >= meshes.GetArrayLength())\r\n                throw new FormatException($\"glTF node {nodeIndex} references missing mesh {meshIndex}.\");\r\n            var meshJson = meshes[meshIndex];\r\n            int[]? skinJoints = null;\r\n            Matrix4x4[]? inverseBind = null;\r\n            if (nodeJson.TryGetProperty(\"skin\", out var skinProp) && skins.ValueKind == JsonValueKind.Array)\r\n            {\r\n                var skin = skins[skinProp.GetInt32()];\r\n                skinJoints = skin.GetProperty(\"joints\").EnumerateArray().Select(j => j.GetInt32()).ToArray();\r\n                if (skin.TryGetProperty(\"inverseBindMatrices\", out var ibm))\r\n                {\r\n                    var floats = ReadFloats(accessors, views, document.Buffers, ibm.GetInt32(), 16);\r\n                    inverseBind = new Matrix4x4[skinJoints.Length];\r\n                    for (var j = 0; j < skinJoints.Length && (j + 1) * 16 <= floats.Length; j++)\r\n                    {\r\n                        var f = floats.AsSpan(j * 16, 16);\r\n                        // glTF matrices are column-major column-vector = row-major row-vector.\r\n                        inverseBind[j] = new Matrix4x4(f[0], f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8], f[9], f[10], f[11], f[12], f[13], f[14], f[15]);\r\n                    }\r\n                }\r\n            }\r\n\r\n            var nodeBone = BoneOf(nodeIndex);\r\n            var partName = nodes[nodeIndex].Name ?? (meshJson.TryGetProperty(\"name\", out var mn) ? mn.GetString() : null) ?? $\"mesh_{meshIndex}\";\r\n            var part = partNames.Count;\r\n            partNames.Add(partName);\r\n\r\n            foreach (var prim in meshJson.GetProperty(\"primitives\").EnumerateArray())\r\n            {\r\n                var mode = prim.TryGetProperty(\"mode\", out var modeProp) ? modeProp.GetInt32() : 4;\r\n                if (mode != 4)\r\n                    continue; // points/lines/strips are not solid geometry\r\n                var attributes = prim.GetProperty(\"attributes\");\r\n                if (!attributes.TryGetProperty(\"POSITION\", out var posProp))\r\n                    continue;\r\n                var pos = ReadFloats(accessors, views, document.Buffers, posProp.GetInt32(), 3);\r\n                var count = pos.Length / 3;\r\n                float[]? joints = null, weights = null;\r\n                if (skinJoints is not null && attributes.TryGetProperty(\"JOINTS_0\", out var jp) && attributes.TryGetProperty(\"WEIGHTS_0\", out var wp))\r\n                {\r\n                    joints = ReadFloats(accessors, views, document.Buffers, jp.GetInt32(), 4, raw: true);\r\n                    weights = ReadFloats(accessors, views, document.Buffers, wp.GetInt32(), 4);\r\n                }\r\n\r\n                float[]? nrm = null, tex = null;\r\n                if (attributes.TryGetProperty(\"NORMAL\", out var nrmProp))\r\n                {\r\n                    nrm = ReadFloats(accessors, views, document.Buffers, nrmProp.GetInt32(), 3);\r\n                    if (nrm.Length / 3 < count)\r\n                        nrm = null;\r\n                }\r\n                if (attributes.TryGetProperty(\"TEXCOORD_0\", out var texProp))\r\n                {\r\n                    tex = ReadFloats(accessors, views, document.Buffers, texProp.GetInt32(), 2);\r\n                    if (tex.Length / 2 < count)\r\n                        tex = null;\r\n                }\r\n                anyNormals |= nrm is not null;\r\n                anyUVs |= tex is not null;\r\n                var material = materialReader.MaterialIndex(prim.TryGetProperty(\"material\", out var matProp) ? matProp.GetInt32() : -1);\r\n\r\n                var baseVertex = positions.Count;\r\n                var nodeWorld = World(nodeIndex);\r\n                var anchorOfNode = Anchor(nodeIndex);\r\n                var invAnchor = Matrix4x4.Identity;\r\n                if (anchorOfNode >= 0 && Matrix4x4.Invert(World(anchorOfNode), out var inv))\r\n                {\r\n                    // Rigid rest transforms carry no scale; keep the anchor's.\r\n                    Matrix4x4.Decompose(World(anchorOfNode), out var anchorScale, out _, out _);\r\n                    invAnchor = inv * Matrix4x4.CreateScale(anchorScale);\r\n                }\r\n                var nodeNormal = NormalMatrix(nodeWorld);\r\n                var anchoredNormal = NormalMatrix(nodeWorld * invAnchor);\r\n                for (var v = 0; v < count; v++)\r\n                {\r\n                    var p = new Vector3(pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]);\r\n                    var n = nrm is not null ? new Vector3(nrm[v * 3], nrm[v * 3 + 1], nrm[v * 3 + 2]) : Vector3.Zero;\r\n                    int bone;\r\n                    Vector3 model;\r\n                    Vector3 normal;\r\n                    if (joints is not null && weights is not null)\r\n                    {\r\n                        var best = 0;\r\n                        for (var k = 1; k < 4; k++)\r\n                            if (weights[v * 4 + k] > weights[v * 4 + best])\r\n                                best = k;\r\n                        var jointSlot = (int)joints[v * 4 + best];\r\n                        var jointNode = jointSlot >= 0 && jointSlot < skinJoints!.Length ? skinJoints[jointSlot] : nodeIndex;\r\n                        bone = BoneOf(jointNode);\r\n                        // Mesh space -> joint bind space -> joint rest world. The full node matrix\r\n                        // is used (rigid rest transforms drop inherited scale, which would pull\r\n                        // parts on differently scaled joints apart).\r\n                        var ibm = inverseBind is not null && jointSlot < inverseBind.Length ? inverseBind[jointSlot] : Matrix4x4.Identity;\r\n                        var bindWorld = Vector3.Transform(Vector3.Transform(p, ibm), World(jointNode)) * 100f;\r\n                        model = conversion.Point(bindWorld);\r\n                        normal = conversion.Direction(Vector3.TransformNormal(n, NormalMatrix(ibm * World(jointNode))));\r\n                    }\r\n                    else\r\n                    {\r\n                        bone = nodeBone;\r\n                        var anchorNode = anchorOfNode;\r\n                        var rel = Vector3.Transform(Vector3.Transform(p, nodeWorld), invAnchor) * 100f;\r\n                        var anchored = anchorNode >= 0 && boneByNode.ContainsKey(anchorNode);\r\n                        model = anchored\r\n                            ? skeleton.RestWorld[bone].TransformPoint(conversion.Direction(rel) * conversion.Scale)\r\n                            : conversion.Point(Vector3.Transform(p, nodeWorld) * 100f);\r\n                        normal = anchored\r\n                            ? skeleton.RestWorld[bone].TransformVector(conversion.Direction(Vector3.TransformNormal(n, anchoredNormal)))\r\n                            : conversion.Direction(Vector3.TransformNormal(n, nodeNormal));\r\n                    }\r\n                    positions.Add(model);\r\n                    vertexBone.Add(bone);\r\n                    var len = normal.Length();\r\n                    vertexNormals.Add(nrm is not null && len > 1e-12f && float.IsFinite(len) ? normal / len : new Vector3(float.NaN));\r\n                    // glTF UVs already have the top-left origin TriMesh uses.\r\n                    vertexUVs.Add(tex is not null ? new Vector2(tex[v * 2], tex[v * 2 + 1]) : Vector2.Zero);\r\n                }\r\n\r\n                if (prim.TryGetProperty(\"indices\", out var idxProp))\r\n                {\r\n                    var idx = ReadFloats(accessors, views, document.Buffers, idxProp.GetInt32(), 1, raw: true);\r\n                    for (var i = 0; i + 2 < idx.Length; i += 3)\r\n                    {\r\n                        var a = (int)idx[i]; var b = (int)idx[i + 1]; var c = (int)idx[i + 2];\r\n                        if ((uint)a >= (uint)count || (uint)b >= (uint)count || (uint)c >= (uint)count)\r\n                            throw new FormatException($\"glTF mesh '{partName}' has out-of-range indices.\");\r\n                        indices.Add(baseVertex + a); indices.Add(baseVertex + b); indices.Add(baseVertex + c);\r\n                        triPart.Add(part);\r\n                        triMaterial.Add(material);\r\n                    }\r\n                }\r\n                else\r\n                {\r\n                    for (var i = 0; i + 2 < count; i += 3)\r\n                    {\r\n                        indices.Add(baseVertex + i); indices.Add(baseVertex + i + 1); indices.Add(baseVertex + i + 2);\r\n                        triPart.Add(part);\r\n                        triMaterial.Add(material);\r\n                    }\r\n                }\r\n                if (indices.Count / 3 > FbxWeaponReader.MaxTriangles)\r\n                    throw new FormatException($\"The model has more than {FbxWeaponReader.MaxTriangles:N0} triangles; decimate it before importing.\");\r\n            }\r\n        }\r\n\r\n        int Anchor(int node)\r\n        {\r\n            for (var n = node; n >= 0; n = nodes[n].Parent)\r\n                if (boneByNode.ContainsKey(n))\r\n                    return n;\r\n            return -1;\r\n        }\r\n\r\n        if (indices.Count == 0)\r\n            throw new FormatException(\"The glTF contains no triangle geometry.\");\r\n\r\n        var indexArray = indices.ToArray();\r\n        Vector3[]? cornerNormals = null;\r\n        if (anyNormals)\r\n        {\r\n            cornerNormals = indexArray.Select(i => vertexNormals[i]).ToArray();\r\n            if (cornerNormals.Any(n => float.IsNaN(n.X)))\r\n            {\r\n                var smooth = new TriMesh(positions.ToArray(), indexArray).EnsureNormals();\r\n                for (var c = 0; c < cornerNormals.Length; c++)\r\n                    if (float.IsNaN(cornerNormals[c].X))\r\n                        cornerNormals[c] = smooth[c];\r\n            }\r\n        }\r\n        var cornerUVs = anyUVs ? indexArray.Select(i => vertexUVs[i]).ToArray() : null;\r\n\r\n        var asset = new WeaponAsset\r\n        {\r\n            Name = name,\r\n            Kind = SourceKind.Gltf,\r\n            SourcePath = sourcePath,\r\n            Skeleton = skeleton,\r\n            Mesh = new TriMesh(positions.ToArray(), indexArray, vertexBone.ToArray(), triPart.ToArray(), partNames,\r\n                cornerNormals, cornerUVs, triMaterial.ToArray(), materialReader.Materials),\r\n            Clips = clips,\r\n        };\r\n        asset.Notes.AddRange(imported.Notes);\r\n        asset.Notes.AddRange(materialReader.Notes.Distinct().Take(20));\r\n        return asset;\r\n    }\r\n\r\n    /// <summary>Row-vector normal matrix: inverse transpose of the linear part.</summary>\r\n    private static Matrix4x4 NormalMatrix(Matrix4x4 m)\r\n    {\r\n        m.M41 = m.M42 = m.M43 = 0f;\r\n        return Matrix4x4.Invert(m, out var inv) ? Matrix4x4.Transpose(inv) : m;\r\n    }\r\n\r\n    /// <summary>Accessor reader for any component type; <paramref name=\"raw\"/> skips normalisation (indices, joints).</summary>\r\n    private static float[] ReadFloats(JsonElement accessors, JsonElement views, List<byte[]> buffers, int index, int comps, bool raw = false)\r\n    {\r\n        if (accessors.ValueKind != JsonValueKind.Array || index < 0 || index >= accessors.GetArrayLength())\r\n            throw new FormatException($\"glTF accessor {index} does not exist.\");\r\n        var accessor = accessors[index];\r\n        if (accessor.TryGetProperty(\"sparse\", out _))\r\n            throw new FormatException(\"glTF sparse accessors are not supported.\");\r\n        var type = accessor.GetProperty(\"type\").GetString();\r\n        var actual = type switch { \"SCALAR\" => 1, \"VEC2\" => 2, \"VEC3\" => 3, \"VEC4\" => 4, \"MAT4\" => 16, _ => throw new FormatException($\"glTF accessor type '{type}' is not supported.\") };\r\n        if (actual != comps)\r\n            throw new FormatException($\"glTF accessor {index} is {type}, expected {comps} components.\");\r\n        var count = accessor.GetProperty(\"count\").GetInt32();\r\n        var componentType = accessor.GetProperty(\"componentType\").GetInt32();\r\n        var normalized = accessor.TryGetProperty(\"normalized\", out var n) && n.GetBoolean();\r\n        var size = componentType switch { 5126 or 5125 => 4, 5122 or 5123 => 2, 5120 or 5121 => 1, _ => throw new FormatException($\"glTF component type {componentType} is not supported.\") };\r\n        if (count < 0 || count > 50_000_000)\r\n            throw new FormatException($\"glTF accessor {index} has an invalid count.\");\r\n        var result = new float[count * comps];\r\n        if (!accessor.TryGetProperty(\"bufferView\", out var viewProp))\r\n            return result;\r\n        var view = views[viewProp.GetInt32()];\r\n        var buffer = buffers[view.GetProperty(\"buffer\").GetInt32()];\r\n        var start = (long)(view.TryGetProperty(\"byteOffset\", out var vo) ? vo.GetInt32() : 0) + (accessor.TryGetProperty(\"byteOffset\", out var ao) ? ao.GetInt32() : 0);\r\n        var stride = view.TryGetProperty(\"byteStride\", out var st) ? st.GetInt32() : size * comps;\r\n        if (count > 0 && start + (long)(count - 1) * stride + size * comps > buffer.Length)\r\n            throw new FormatException($\"glTF accessor {index} reads past the end of its buffer (truncated file?).\");\r\n        for (var e = 0; e < count; e++)\r\n        {\r\n            var offset = (int)(start + (long)e * stride);\r\n            for (var c = 0; c < comps; c++)\r\n            {\r\n                var at = offset + c * size;\r\n                float value = componentType switch\r\n                {\r\n                    5126 => BitConverter.ToSingle(buffer, at),\r\n                    5125 => BitConverter.ToUInt32(buffer, at),\r\n                    5123 => BitConverter.ToUInt16(buffer, at),\r\n                    5122 => BitConverter.ToInt16(buffer, at),\r\n                    5121 => buffer[at],\r\n                    _ => (sbyte)buffer[at],\r\n                };\r\n                if (!raw && normalized)\r\n                    value = componentType switch { 5121 => value / 255f, 5123 => value / 65535f, 5120 => MathF.Max(value / 127f, -1f), 5122 => MathF.Max(value / 32767f, -1f), _ => value };\r\n                result[e * comps + c] = value;\r\n            }\r\n        }\r\n        return result;\r\n    }\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Core/Import/SpaceConversion.cs",
            "FileName": "SpaceConversion.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "#nullable enable annotations\r\n\r\nusing System.Numerics;\r\nusing WeaponImporter.Core.Maths;\r\nusing WeaponImporter.Core.Rig;\r\n\r\nnamespace WeaponImporter.Core.Import;\r\n\r\nusing Vector3 = System.Numerics.Vector3;\r\n\r\n/// <summary>\r\n/// Converts a source scene (centimetres, file axes) into s&amp;box model space (inches, +Z up).\r\n/// The file's up axis becomes +Z, its coordinate (right) axis becomes +X and its front axis\r\n/// becomes -Y, which matches how ModelDoc imports Y-up FBX files.\r\n/// </summary>\r\npublic sealed class SpaceConversion\r\n{\r\n    public const float CmToInch = 1f / 2.54f;\r\n\r\n    /// <summary>Row-vector basis change: v_sbox = v_file * Basis.</summary>\r\n    public Matrix4x4 Basis { get; }\r\n\r\n    public float Scale { get; }\r\n\r\n    public SpaceConversion(int upAxis, int upSign, int frontAxis, int frontSign, int coordAxis, int coordSign, float scale = CmToInch)\r\n    {\r\n        var m = new Matrix4x4();\r\n        void Set(int row, int col, float v)\r\n        {\r\n            switch (row * 4 + col)\r\n            {\r\n                case 0: m.M11 = v; break; case 1: m.M12 = v; break; case 2: m.M13 = v; break;\r\n                case 4: m.M21 = v; break; case 5: m.M22 = v; break; case 6: m.M23 = v; break;\r\n                case 8: m.M31 = v; break; case 9: m.M32 = v; break; case 10: m.M33 = v; break;\r\n            }\r\n        }\r\n        if (upAxis == frontAxis || upAxis == coordAxis || frontAxis == coordAxis)\r\n        {\r\n            // Malformed axis record: assume the FBX default (Y up, Z front, X right).\r\n            (upAxis, frontAxis, coordAxis) = (1, 2, 0);\r\n        }\r\n        Set(coordAxis, 0, coordSign >= 0 ? 1f : -1f);\r\n        Set(frontAxis, 1, frontSign >= 0 ? -1f : 1f);\r\n        Set(upAxis, 2, upSign >= 0 ? 1f : -1f);\r\n        m.M44 = 1f;\r\n        Basis = m;\r\n        Scale = scale;\r\n    }\r\n\r\n    public static SpaceConversion For(SourceScene scene)\r\n        => new(scene.UpAxis, scene.UpAxisSign, scene.FrontAxis, scene.FrontAxisSign, scene.CoordAxis, scene.CoordAxisSign);\r\n\r\n    public static SpaceConversion Identity { get; } = new(2, 1, 1, -1, 0, 1, 1f);\r\n\r\n    public Vector3 Point(Vector3 cm) => Vector3.Transform(cm, Basis) * Scale;\r\n\r\n    public Vector3 Direction(Vector3 v) => Vector3.Transform(v, Basis);\r\n\r\n    public Quaternion Rotation(Quaternion q)\r\n    {\r\n        // Similarity transform keeps a proper rotation even when the file axes are mirrored.\r\n        var r = Matrix4x4.CreateFromQuaternion(q);\r\n        var t = Matrix4x4.Transpose(Basis);\r\n        var s = t * r * Basis;\r\n        s.M14 = s.M24 = s.M34 = s.M41 = s.M42 = s.M43 = 0f;\r\n        s.M44 = 1f;\r\n        return MathQ.Normalize(Quaternion.CreateFromRotationMatrix(s));\r\n    }\r\n\r\n    public XForm Transform(XForm x) => new(Point(x.Pos), Rotation(x.Rot));\r\n\r\n    public Skeleton Skeleton(Skeleton source)\r\n    {\r\n        var defs = new List<BoneDefinition>(source.Count);\r\n        for (var i = 0; i < source.Count; i++)\r\n        {\r\n            var b = source[i];\r\n            defs.Add(new BoneDefinition(b.Name, b.ParentIndex < 0 ? null : source[b.ParentIndex].Name, Transform(b.RestLocal)));\r\n        }\r\n        return Rig.Skeleton.Create(defs);\r\n    }\r\n\r\n    /// <summary>Clips re-expressed on the converted skeleton (same bone order by name).</summary>\r\n    public List<Clip> Clips(IReadOnlyList<Clip> clips, Skeleton source, Skeleton converted)\r\n    {\r\n        var map = new int[source.Count];\r\n        for (var i = 0; i < source.Count; i++)\r\n            map[i] = converted.IndexOf(source[i].Name);\r\n        var result = new List<Clip>(clips.Count);\r\n        foreach (var clip in clips)\r\n        {\r\n            var frames = new List<XForm[]>(clip.FrameCount);\r\n            foreach (var frame in clip.Frames)\r\n            {\r\n                var locals = new XForm[converted.Count];\r\n                for (var i = 0; i < source.Count && i < frame.Length; i++)\r\n                    if (map[i] >= 0)\r\n                        locals[map[i]] = Transform(frame[i]);\r\n                frames.Add(locals);\r\n            }\r\n            result.Add(new Clip(clip.Name, clip.Fps, clip.Looping, frames, clip.NativeFps));\r\n        }\r\n        return result;\r\n    }\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Core/Rig/Clip.cs",
            "FileName": "Clip.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "#nullable enable annotations\r\n\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing WeaponImporter.Core.Maths;\r\n\r\nnamespace WeaponImporter.Core.Rig;\r\n\r\n/// <summary>\r\n/// A sampled animation clip: a fixed-rate sequence of frames, each holding one\r\n/// parent-relative local transform per bone (skeleton bone order). Clips are always\r\n/// resampled at ingest \u2014 no key data is preserved.\r\n/// </summary>\r\npublic sealed class Clip\r\n{\r\n    /// <summary>Clip (sequence) name.</summary>\r\n    public string Name { get; }\r\n\r\n    /// <summary>Sample rate in frames per second.</summary>\r\n    public float Fps { get; }\r\n\r\n    /// <summary>\r\n    /// Native frame rate of the take in the SOURCE file (FBX GlobalSettings\r\n    /// TimeMode/CustomFrameRate, BVH 1/FrameTime). External frame ranges \u2014 Unity\r\n    /// <c>.fbx.meta</c> <c>clipAnimations</c> definitions \u2014 are expressed in THIS rate, so\r\n    /// they must be rescaled by <c>Fps / NativeFps</c> to index the resampled\r\n    /// <see cref=\"Frames\"/>. Equals <see cref=\"Fps\"/> when the importer records no native rate.\r\n    /// </summary>\r\n    public float NativeFps { get; }\r\n\r\n    /// <summary>Whether the clip is authored to loop.</summary>\r\n    public bool Looping { get; }\r\n\r\n    /// <summary>Frames in playback order; each entry is one local transform per bone.</summary>\r\n    public List<XForm[]> Frames { get; }\r\n\r\n    /// <summary>Number of frames currently in the clip.</summary>\r\n    public int FrameCount => Frames.Count;\r\n\r\n    /// <summary>\r\n    /// Clip duration in seconds at <see cref=\"Fps\"/>: the time span between the first and the\r\n    /// last sample, <c>(FrameCount - 1) / Fps</c> (frames are fence posts, intervals are the\r\n    /// spans between them \u2014 matching the DMX timeFrame this clip serializes to). Zero for\r\n    /// empty and single-frame clips.\r\n    /// </summary>\r\n    public float Duration => FrameCount <= 1 ? 0f : (FrameCount - 1) / Fps;\r\n\r\n    /// <summary>Creates an empty clip.</summary>\r\n    /// <exception cref=\"ArgumentOutOfRangeException\">Thrown when <paramref name=\"fps\"/> is not positive.</exception>\r\n    public Clip(string name, float fps, bool looping)\r\n        : this(name, fps, looping, new List<XForm[]>())\r\n    {\r\n    }\r\n\r\n    /// <summary>Creates a clip wrapping an existing frame list (not copied).</summary>\r\n    /// <param name=\"nativeFps\">Source-file native frame rate (<see cref=\"NativeFps\"/>);\r\n    /// null = same as <paramref name=\"fps\"/>.</param>\r\n    /// <exception cref=\"ArgumentOutOfRangeException\">Thrown when <paramref name=\"fps\"/> (or a\r\n    /// provided <paramref name=\"nativeFps\"/>) is not positive.</exception>\r\n    public Clip(string name, float fps, bool looping, List<XForm[]> frames, float? nativeFps = null)\r\n    {\r\n        ArgumentNullException.ThrowIfNull(name);\r\n        ArgumentNullException.ThrowIfNull(frames);\r\n        if (!(fps > 0f) || !float.IsFinite(fps))\r\n            throw new ArgumentOutOfRangeException(nameof(fps), fps, \"Fps must be a positive finite number.\");\r\n        if (nativeFps is { } native && (!(native > 0f) || !float.IsFinite(native)))\r\n            throw new ArgumentOutOfRangeException(nameof(nativeFps), native, \"NativeFps must be a positive finite number.\");\r\n\r\n        Name = name;\r\n        Fps = fps;\r\n        NativeFps = nativeFps ?? fps;\r\n        Looping = looping;\r\n        Frames = frames;\r\n    }\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Tool/UI/Preview/WeaponViewport.Picking.cs",
            "FileName": "WeaponViewport.Picking.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "using Editor;\r\nusing Sandbox;\r\nusing WeaponImporter.Core.Hands;\r\n\r\nnamespace WeaponImporter.Tool.UI;\r\n\r\n/// <summary>Clicks in the preview: attachment markers, arms (per-action release) and the weapon surface.</summary>\r\npublic sealed partial class WeaponViewport\r\n{\r\n\tprivate const float MarkerRadiusPx = 14f;\r\n\tprivate const float ArmRadiusPx = 12f;\r\n\r\n\tprivate readonly Dictionary<int, Side?> _boneSide = new();\r\n\r\n\t/// <summary>Right/left from the bone name suffix (hand_R, arm_lower_L, finger_index_0_R...).</summary>\r\n\tprivate Side? SideOf( int bone )\r\n\t{\r\n\t\tif ( _boneSide.TryGetValue( bone, out var side ) )\r\n\t\t\treturn side;\r\n\t\tvar name = _body.IsValid() && _body.Model is { } m ? m.GetBoneName( bone ).ToLowerInvariant() : \"\";\r\n\t\tside = name.EndsWith( \"_r\" ) ? Side.Right : name.EndsWith( \"_l\" ) ? Side.Left : null;\r\n\t\t_boneSide[bone] = side;\r\n\t\treturn side;\r\n\t}\r\n\r\n\tprivate bool ToScreen( Vector3 world, out Vector2 screen )\r\n\t{\r\n\t\tscreen = Camera.PointToScreenPixels( world, out var behind );\r\n\t\treturn !behind;\r\n\t}\r\n\r\n\t/// <summary>The muzzle or eject marker under a viewport pixel (None when neither).</summary>\r\n\tpublic PickTarget HitMarker( Vector2 local )\r\n\t{\r\n\t\tvar setup = _c.Setup;\r\n\t\tif ( setup is null || !Camera.IsValid() || !_weaponObject.IsValid() || FirstPerson )\r\n\t\t\treturn PickTarget.None;\r\n\t\tvar weapon = WeaponWorld;\r\n\t\tvar best = PickTarget.None;\r\n\t\tvar bestDistance = MarkerRadiusPx;\r\n\t\tforeach ( var (target, point) in new[] { (PickTarget.Muzzle, setup.Muzzle), (PickTarget.Eject, setup.Eject) } )\r\n\t\t{\r\n\t\t\tif ( point is null || !ToScreen( weapon.PointToWorld( Core.Setup.V.Of( point.Canonical ).ToEngine() ), out var screen ) )\r\n\t\t\t\tcontinue;\r\n\t\t\tvar d = (screen - local).Length;\r\n\t\t\tif ( d < bestDistance )\r\n\t\t\t{\r\n\t\t\t\tbestDistance = d;\r\n\t\t\t\tbest = target;\r\n\t\t\t}\r\n\t\t}\r\n\t\treturn best;\r\n\t}\r\n\r\n\t/// <summary>Which character arm (upper arm, forearm, hand or fingers) is under a viewport pixel.</summary>\r\n\tpublic Side? HitArm( Vector2 local )\r\n\t{\r\n\t\tif ( !Camera.IsValid() || !_body.IsValid() || !_body.SceneModel.IsValid() || FirstPerson )\r\n\t\t\treturn null;\r\n\t\tSide? best = null;\r\n\t\tvar bestDistance = ArmRadiusPx;\r\n\t\tforeach ( var (bone, parent) in _armBones )\r\n\t\t{\r\n\t\t\tif ( SideOf( bone ) is not { } side )\r\n\t\t\t\tcontinue;\r\n\t\t\tif ( !ToScreen( _body.SceneModel.GetBoneWorldTransform( parent ).Position, out var a ) || !ToScreen( _body.SceneModel.GetBoneWorldTransform( bone ).Position, out var b ) )\r\n\t\t\t\tcontinue;\r\n\t\t\tvar d = DistanceToSegment( local, a, b );\r\n\t\t\tif ( d < bestDistance )\r\n\t\t\t{\r\n\t\t\t\tbestDistance = d;\r\n\t\t\t\tbest = side;\r\n\t\t\t}\r\n\t\t}\r\n\t\treturn best;\r\n\t}\r\n\r\n\tprivate static float DistanceToSegment( Vector2 p, Vector2 a, Vector2 b )\r\n\t{\r\n\t\tvar ab = b - a;\r\n\t\tvar lengthSq = ab.x * ab.x + ab.y * ab.y;\r\n\t\tvar t = lengthSq < 1e-4f ? 0f : Math.Clamp( ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / lengthSq, 0f, 1f );\r\n\t\treturn (p - (a + ab * t)).Length;\r\n\t}\r\n\r\n\t/// <summary>Where a marker is on screen (gate and tests), or null.</summary>\r\n\tpublic Vector2? MarkerScreenPosition( PickTarget target )\r\n\t{\r\n\t\tvar point = target == PickTarget.Muzzle ? _c.Setup?.Muzzle : target == PickTarget.Eject ? _c.Setup?.Eject : null;\r\n\t\tif ( point is null || !Camera.IsValid() || !_weaponObject.IsValid() )\r\n\t\t\treturn null;\r\n\t\treturn ToScreen( WeaponWorld.PointToWorld( Core.Setup.V.Of( point.Canonical ).ToEngine() ), out var s ) ? s : null;\r\n\t}\r\n\r\n\t/// <summary>Middle of a forearm on screen (gate and tests), or null.</summary>\r\n\tpublic Vector2? ArmScreenPosition( Side side )\r\n\t{\r\n\t\tif ( !Camera.IsValid() || !_body.IsValid() || !_body.SceneModel.IsValid() )\r\n\t\t\treturn null;\r\n\t\tforeach ( var (bone, parent) in _armBones )\r\n\t\t{\r\n\t\t\tif ( SideOf( bone ) != side || !_body.Model.GetBoneName( bone ).StartsWith( \"hand\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\tcontinue;\r\n\t\t\tvar mid = (_body.SceneModel.GetBoneWorldTransform( parent ).Position + _body.SceneModel.GetBoneWorldTransform( bone ).Position) * 0.5f;\r\n\t\t\treturn ToScreen( mid, out var s ) ? s : null;\r\n\t\t}\r\n\t\treturn null;\r\n\t}\r\n\r\n\tpublic int FirstPersonPieceCount => _fpPieces.Count;\r\n\r\n\t/// <summary>The hand whose grip contact marker is under a viewport pixel (drag to move the grip).</summary>\r\n\tpublic Side? HitGripContact( Vector2 local )\r\n\t{\r\n\t\tvar setup = _c.Setup;\r\n\t\tif ( setup is null || !_c.ShowContacts || !Camera.IsValid() || !_weaponObject.IsValid() || OwnArmsView )\r\n\t\t\treturn null;\r\n\t\tvar weapon = WeaponWorld;\r\n\t\tSide? best = null;\r\n\t\tvar bestDistance = MarkerRadiusPx;\r\n\t\tforeach ( var (side, grip) in new[] { (Side.Right, setup.Primary), (Side.Left, setup.UseSupportHand ? setup.Support : null) } )\r\n\t\t{\r\n\t\t\tif ( grip is null || !ToScreen( weapon.PointToWorld( Core.Setup.V.Of( grip.Contact ).ToEngine() ), out var screen ) )\r\n\t\t\t\tcontinue;\r\n\t\t\tvar d = (screen - local).Length;\r\n\t\t\tif ( d < bestDistance )\r\n\t\t\t{\r\n\t\t\t\tbestDistance = d;\r\n\t\t\t\tbest = side;\r\n\t\t\t}\r\n\t\t}\r\n\t\treturn best;\r\n\t}\r\n\r\n\t/// <summary>Hand grip being dragged across the weapon, or null.</summary>\r\n\tprivate Side? _dragGrip;\r\n\r\n\t/// <summary>Starts dragging a grip contact when the press lands on one.</summary>\r\n\tprivate bool BeginGripDrag( Vector2 local )\r\n\t{\r\n\t\tif ( _c.Pick != PickTarget.None || HitGripContact( local ) is not { } side )\r\n\t\t\treturn false;\r\n\t\t_dragGrip = side;\r\n\t\tCursor = CursorShape.ClosedHand;\r\n\t\t_c.SetStatus( $\"Moving the {(side == Side.Right ? \"right\" : \"left\")} grip: drag across the weapon.\", Theme.Blue );\r\n\t\treturn true;\r\n\t}\r\n\r\n\tprivate void DragGrip( Vector2 local )\r\n\t{\r\n\t\tif ( _dragGrip is not { } side )\r\n\t\t\treturn;\r\n\t\tif ( RaycastWeapon( local ) is { } hit )\r\n\t\t\t_c.DragGrip( side, hit.Point, hit.Normal );\r\n\t}\r\n\r\n\tprivate void EndGripDrag()\r\n\t{\r\n\t\tif ( _dragGrip is null )\r\n\t\t\treturn;\r\n\t\t_dragGrip = null;\r\n\t\tCursor = CursorShape.Arrow;\r\n\t\t_c.EndDragGrip();\r\n\t}\r\n\r\n\t/// <summary>A plain left click (no drag) in the preview.</summary>\r\n\tprivate void Click( Vector2 local )\r\n\t{\r\n\t\tvar pick = _c.Pick;\r\n\t\tif ( pick is PickTarget.Muzzle or PickTarget.Eject )\r\n\t\t{\r\n\t\t\t// Another marker selects that one; the weapon surface moves the selected one; anything else deselects.\r\n\t\t\tvar marker = HitMarker( local );\r\n\t\t\tif ( marker != PickTarget.None && marker != pick )\r\n\t\t\t{\r\n\t\t\t\t_c.BeginPick( marker );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\t\t\tif ( RaycastWeapon( local ) is { } hit )\r\n\t\t\t\t_c.CompletePick( hit.Point, hit.Normal );\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\t_c.BeginPick( PickTarget.None );\r\n\t\t\t\t_c.SetStatus( \"Marker deselected.\", Theme.TextLight );\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( pick != PickTarget.None )\r\n\t\t{\r\n\t\t\tif ( RaycastWeapon( local ) is { } hit )\r\n\t\t\t\t_c.CompletePick( hit.Point, hit.Normal );\r\n\t\t\telse\r\n\t\t\t\t_c.SetStatus( \"That missed the weapon. \" + ImporterController.PickHint( pick ), Theme.Yellow );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar clicked = HitMarker( local );\r\n\t\tif ( clicked != PickTarget.None )\r\n\t\t{\r\n\t\t\t_c.BeginPick( clicked );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( HitArm( local ) is { } side )\r\n\t\t\t_c.ToggleHand( _c.Role, side );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Tool/UI/Steps/StepPanel.cs",
            "FileName": "StepPanel.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "using Editor;\r\nusing Sandbox;\r\n\r\nnamespace WeaponImporter.Tool.UI;\r\n\r\n/// <summary>\r\n/// One step of the workflow in the left column. Steps build their cards from the session and\r\n/// rebuild only when their structure changes (a new weapon, a different part list...); value\r\n/// changes just run the registered refreshers, so a slider being dragged is never destroyed.\r\n/// Rebuilds are deferred to the next frame so a widget is never deleted inside its own callback.\r\n/// </summary>\r\npublic abstract class StepPanel : Widget\r\n{\r\n\tprotected readonly ImporterController C;\r\n\tprivate readonly List<Action> _refreshers = new();\r\n\tprivate string _builtKey;\r\n\tprivate bool _dirty = true;\r\n\r\n\tprotected StepPanel( Widget parent, ImporterController controller ) : base( parent )\r\n\t{\r\n\t\tC = controller;\r\n\t\tLayout = Layout.Column();\r\n\t\tLayout.Spacing = 10;\r\n\t\tcontroller.Changed += MarkDirty;\r\n\t\tcontroller.SessionReplaced += ForceRebuild;\r\n\t}\r\n\r\n\tpublic abstract string Title { get; }\r\n\r\n\t/// <summary>Anything that, when different, needs new widgets rather than new values.</summary>\r\n\tprotected abstract string StructureKey();\r\n\r\n\tprotected abstract void Build();\r\n\r\n\tpublic void MarkDirty() => _dirty = true;\r\n\r\n\tpublic void ForceRebuild()\r\n\t{\r\n\t\t_builtKey = null;\r\n\t\t_dirty = true;\r\n\t}\r\n\r\n\t/// <summary>Called every frame by the window while this step is visible.</summary>\r\n\tpublic void Flush()\r\n\t{\r\n\t\tif ( !_dirty )\r\n\t\t\treturn;\r\n\t\t_dirty = false;\r\n\t\tstring key;\r\n\t\ttry\r\n\t\t{\r\n\t\t\tkey = C.Session?.Setup is null ? \"\" : StructureKey();\r\n\t\t}\r\n\t\tcatch ( Exception )\r\n\t\t{\r\n\t\t\tkey = Guid.NewGuid().ToString();\r\n\t\t}\r\n\t\tif ( key != _builtKey )\r\n\t\t{\r\n\t\t\t_builtKey = key;\r\n\t\t\tRebuild();\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tforeach ( var refresh in _refreshers.ToArray() )\r\n\t\t{\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\trefresh();\r\n\t\t\t}\r\n\t\t\tcatch ( Exception e )\r\n\t\t\t{\r\n\t\t\t\tLog.Warning( $\"[weapon importer] refresh failed: {e.Message}\" );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tprivate void Rebuild()\r\n\t{\r\n\t\tLayout.Clear( true );\r\n\t\t_refreshers.Clear();\r\n\t\tif ( C.Session?.Setup is null )\r\n\t\t\treturn;\r\n\t\ttry\r\n\t\t{\r\n\t\t\tBuild();\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[weapon importer] {Title} panel failed: {e}\" );\r\n\t\t\tLayout.Add( UiStyle.Colored( new Label( $\"This panel could not be shown: {e.Message}\", this ) { WordWrap = true }, Theme.Red ) );\r\n\t\t}\r\n\t\tLayout.AddStretchCell();\r\n\t}\r\n\r\n\t/// <summary>Registers a refresher and runs it once now.</summary>\r\n\tprotected void Bind( Action refresh )\r\n\t{\r\n\t\t_refreshers.Add( refresh );\r\n\t\trefresh();\r\n\t}\r\n\r\n\tprotected Card AddCard( string icon, string title, out Layout header, string tooltip = null )\r\n\t{\r\n\t\tvar card = Layout.Add( new Card( this ) );\r\n\t\theader = card.Header( icon, title, tooltip );\r\n\t\treturn card;\r\n\t}\r\n\r\n\t/// <summary>A labelled slider with a live value readout.</summary>\r\n\tprotected FloatSlider Slider( Widget owner, Layout parent, string caption, string tooltip, float min, float max, float value, Func<float, string> format, Action<float> edited, float step = 0f )\r\n\t{\r\n\t\tvar row = UiStyle.FieldRow( owner, parent, caption, tooltip );\r\n\t\tvar slider = row.Add( new FloatSlider( owner ) { Minimum = min, Maximum = max, Value = value, ToolTip = tooltip, FixedHeight = UiStyle.ControlHeight }, 1 );\r\n\t\tif ( step > 0 )\r\n\t\t\tslider.Step = step;\r\n\t\tvar readout = row.Add( UiStyle.Muted( new Label( format( value ), owner ) { FixedWidth = UiStyle.PillColumn, FixedHeight = UiStyle.ControlHeight, Alignment = TextFlag.RightCenter } ) );\r\n\t\tslider.OnValueEdited = () =>\r\n\t\t{\r\n\t\t\treadout.Text = format( slider.Value );\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tedited( slider.Value );\r\n\t\t\t}\r\n\t\t\tcatch ( Exception e )\r\n\t\t\t{\r\n\t\t\t\tC.SetStatus( e.Message, Theme.Red );\r\n\t\t\t}\r\n\t\t};\r\n\t\treturn slider;\r\n\t}\r\n\r\n\t/// <summary>A framed dropdown (framed like mocap inputs).</summary>\r\n\tprotected ComboBox Combo( Widget owner, Layout row, string tooltip, float width = 0 )\r\n\t{\r\n\t\tvar combo = UiStyle.Framed( new ComboBox( owner ) { ToolTip = tooltip } );\r\n\t\tif ( width > 0 )\r\n\t\t\tcombo.FixedWidth = width;\r\n\t\telse\r\n\t\t\tcombo.MinimumWidth = 60; // long bone/clip names must not widen the column\r\n\t\tcombo.MaxVisibleItems = 24;\r\n\t\trow.Add( combo, width > 0 ? 0 : 1 );\r\n\t\treturn combo;\r\n\t}\r\n\r\n\t/// <summary>Runs a UI handler, turning exceptions into a red status line.</summary>\r\n\tprotected void Safe( Action action )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\taction();\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[weapon importer] {e}\" );\r\n\t\t\tC.SetStatus( e.Message, Theme.Red );\r\n\t\t}\r\n\t}\r\n\r\n\tpublic override void OnDestroyed()\r\n\t{\r\n\t\tC.Changed -= MarkDirty;\r\n\t\tC.SessionReplaced -= ForceRebuild;\r\n\t\tbase.OnDestroyed();\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Tool/UI/Widgets/ProcessingIndicator.cs",
            "FileName": "ProcessingIndicator.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "using Editor;\r\nusing Sandbox;\r\n\r\nnamespace WeaponImporter.Tool.UI;\r\n\r\n/// <summary>\r\n/// Stands in the preview's place while the importer works: a green spinner arc with the step\r\n/// title and the latest progress message below it. Styled like the first-load drop box.\r\n/// </summary>\r\npublic sealed class ProcessingIndicator : Widget\r\n{\r\n\tprivate string _title = \"Processing\u2026\";\r\n\tprivate string _message = \"\";\r\n\r\n\t/// <summary>False once work stopped: the last message stays, without the spinner.</summary>\r\n\tpublic bool Busy { get; set; } = true;\r\n\r\n\tpublic ProcessingIndicator( Widget parent ) : base( parent )\r\n\t{\r\n\t\tMinimumSize = new Vector2( 120, 120 );\r\n\t}\r\n\r\n\tpublic void SetTitle( string title )\r\n\t{\r\n\t\t_title = string.IsNullOrEmpty( title ) ? \"Processing\u2026\" : title;\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tpublic void SetMessage( string message )\r\n\t{\r\n\t\t_message = message ?? \"\";\r\n\t\tUpdate();\r\n\t}\r\n\r\n\t/// <summary>Called every editor frame to turn the spinner.</summary>\r\n\tpublic void Tick()\r\n\t{\r\n\t\tif ( Busy && Visible )\r\n\t\t\tUpdate();\r\n\t}\r\n\r\n\tprotected override void OnPaint() => DrawInto( LocalRect );\r\n\r\n\tpublic void DrawInto( Rect area )\r\n\t{\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.SetPen( Theme.ControlBackground.Lighten( .2f ), 1 );\r\n\t\tPaint.SetBrush( Theme.ControlBackground );\r\n\t\tPaint.DrawRect( area.Shrink( 1 ), 6 );\r\n\t\tvar center = area.Center;\r\n\t\tvar width = MathF.Min( 360f, area.Width - 40f );\r\n\t\tif ( Busy )\r\n\t\t{\r\n\t\t\tvar angle = (float)(RealTime.Now * 300 % 360);\r\n\t\t\tvar c = new Vector2( center.x, center.y - 30 );\r\n\t\t\tPaint.SetPen( Color.White.WithAlpha( .08f ), 4 );\r\n\t\t\tPaint.DrawArc( c, new Vector2( 18, 18 ), 0, 360 );\r\n\t\t\tPaint.SetPen( Theme.Green, 4 );\r\n\t\t\tPaint.DrawArc( c, new Vector2( 18, 18 ), angle, 100 );\r\n\t\t\tPaint.SetDefaultFont( 10, 600 );\r\n\t\t\tPaint.SetPen( Theme.Text );\r\n\t\t\tPaint.DrawText( new Rect( center.x - width / 2, center.y + 2, width, 22 ), _title, TextFlag.Center );\r\n\t\t}\r\n\t\tPaint.SetDefaultFont();\r\n\t\tPaint.SetPen( Theme.TextLight );\r\n\t\tPaint.DrawText( new Rect( center.x - width / 2, center.y + (Busy ? 26 : -20), width, 40 ), _message, TextFlag.Center | TextFlag.WordWrap );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Tool/UI/Widgets/SearchPicker.cs",
            "FileName": "SearchPicker.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "using Editor;\r\nusing Sandbox;\r\n\r\nnamespace WeaponImporter.Tool.UI;\r\n\r\n/// <summary>One choice in a <see cref=\"SearchPicker\"/>.</summary>\r\npublic sealed record PickerItem( string Value, string Label, string Detail = \"\", bool Suggested = false, bool Current = false );\r\n\r\n/// <summary>\r\n/// A popup list with a search box: type to filter (every word must match, anywhere in the\r\n/// name), Enter picks the first match. Suggested items come first. Used for choosing clips.\r\n/// </summary>\r\npublic sealed class SearchPicker : PopupWidget\r\n{\r\n\tprivate readonly IReadOnlyList<PickerItem> _items;\r\n\tprivate readonly Action<string> _picked;\r\n\tprivate readonly LineEdit _search;\r\n\tprivate readonly Widget _list;\r\n\tprivate List<PickerItem> _shown = new();\r\n\r\n\tpublic SearchPicker( Widget parent, string title, IReadOnlyList<PickerItem> items, Action<string> picked ) : base( parent )\r\n\t{\r\n\t\t_items = items;\r\n\t\t_picked = picked;\r\n\t\tFixedWidth = 380;\r\n\t\tLayout = Layout.Column();\r\n\t\tLayout.Margin = 8;\r\n\t\tLayout.Spacing = 6;\r\n\t\tLayout.Add( UiStyle.Bold( new Label( title, this ) ) );\r\n\t\t_search = Layout.Add( new LineEdit( this ) { PlaceholderText = \"Type to search\u2026\", FixedHeight = UiStyle.ControlHeight } );\r\n\t\t_search.TextEdited += _ => Fill();\r\n\t\t_search.ReturnPressed += () =>\r\n\t\t{\r\n\t\t\tif ( _shown.Count > 0 )\r\n\t\t\t\tPick( _shown[0].Value );\r\n\t\t};\r\n\t\tvar scroll = Layout.Add( new ScrollArea( this ), 1 );\r\n\t\tscroll.HorizontalScrollbarMode = ScrollbarMode.Off;\r\n\t\tscroll.MaximumHeight = 420;\r\n\t\tscroll.MinimumHeight = Math.Min( 420, 30 * (items.Count + 1) );\r\n\t\t_list = new Widget( scroll ) { Layout = Layout.Column() };\r\n\t\t_list.Layout.Spacing = 1;\r\n\t\tscroll.Canvas = _list;\r\n\t\tFill();\r\n\t}\r\n\r\n\t/// <summary>Opens at the cursor with the search box focused.</summary>\r\n\tpublic void Open()\r\n\t{\r\n\t\tOpenAtCursor();\r\n\t\t_search.Focus();\r\n\t}\r\n\r\n\tprivate void Fill()\r\n\t{\r\n\t\t_list.Layout.Clear( true );\r\n\t\t_shown = Filter( _items, _search.Text );\r\n\t\tforeach ( var item in _shown )\r\n\t\t{\r\n\t\t\tvar value = item.Value;\r\n\t\t\tvar row = _list.Layout.Add( new ClickRow( _list, () => Pick( value ), item.Detail ) );\r\n\t\t\trow.Selected = item.Current;\r\n\t\t\trow.Layout.Add( new IconButton( item.Current ? \"check\" : item.Suggested ? \"auto_awesome\" : \"movie\" ) { Background = Color.Transparent, FixedSize = 22, TransparentForMouseEvents = true } );\r\n\t\t\trow.Layout.Add( new Label( UiStyle.Breakable( item.Label ), row ) { WordWrap = true, MinimumWidth = 20 }, 1 );\r\n\t\t\tif ( item.Detail.Length > 0 )\r\n\t\t\t\trow.Layout.Add( UiStyle.Muted( new Label( item.Detail, row ), small: true ) );\r\n\t\t}\r\n\t\tif ( _shown.Count == 0 )\r\n\t\t\t_list.Layout.Add( UiStyle.Muted( new Label( \"Nothing matches.\", _list ), small: true ) );\r\n\t\t_list.Layout.AddStretchCell();\r\n\t}\r\n\r\n\t/// <summary>Items matching a query (every word, anywhere in the name or detail), suggested first.</summary>\r\n\tpublic static List<PickerItem> Filter( IEnumerable<PickerItem> items, string query )\r\n\t{\r\n\t\tvar words = (query ?? \"\").ToLowerInvariant().Split( new[] { ' ', '_', '-', '|' }, StringSplitOptions.RemoveEmptyEntries );\r\n\t\treturn items\r\n\t\t\t.Where( i => words.All( w => i.Label.Contains( w, StringComparison.OrdinalIgnoreCase ) || i.Detail.Contains( w, StringComparison.OrdinalIgnoreCase ) ) )\r\n\t\t\t.OrderByDescending( i => i.Suggested )\r\n\t\t\t.ToList();\r\n\t}\r\n\r\n\tprivate void Pick( string value )\r\n\t{\r\n\t\t_picked( value );\r\n\t\tClose();\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Tool/UI/Widgets/NavList.cs",
            "FileName": "NavList.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "using Editor;\r\nusing Sandbox;\r\n\r\nnamespace WeaponImporter.Tool.UI;\r\n\r\n/// <summary>\r\n/// The editor's page list: one clickable row per page (icon, name, a status dot when something\r\n/// needs attention). Rows are the controls; there are no separate buttons.\r\n/// </summary>\r\npublic sealed class NavList : Widget\r\n{\r\n\tpublic const float RowHeight = 34;\r\n\r\n\tprivate readonly List<(string Icon, string Title, string Tip)> _pages = new();\r\n\tprivate readonly Dictionary<int, Color?> _status = new();\r\n\tprivate int _hover = -1;\r\n\r\n\tpublic int Selected { get; private set; }\r\n\tpublic Action<int> OnSelected { get; set; }\r\n\r\n\tpublic NavList( Widget parent ) : base( parent )\r\n\t{\r\n\t\tMouseTracking = true;\r\n\t\tCursor = CursorShape.Finger;\r\n\t\tFixedWidth = 176;\r\n\t}\r\n\r\n\tpublic void Add( string icon, string title, string tooltip )\r\n\t{\r\n\t\t_pages.Add( (icon, title, tooltip) );\r\n\t\tMinimumHeight = _pages.Count * RowHeight + 8;\r\n\t\tUpdate();\r\n\t}\r\n\r\n\t/// <summary>Dot beside a page: null hides it (all good), otherwise its colour (yellow, red).</summary>\r\n\tpublic void SetStatus( int page, Color? color )\r\n\t{\r\n\t\tif ( _status.TryGetValue( page, out var old ) && old == color )\r\n\t\t\treturn;\r\n\t\t_status[page] = color;\r\n\t\tUpdate();\r\n\t}\r\n\r\n\t/// <summary>The dot colour of a page (null = none), for tests.</summary>\r\n\tpublic Color? StatusOf( int page ) => _status.TryGetValue( page, out var c ) ? c : null;\r\n\r\n\tpublic void Select( int page, bool notify = true )\r\n\t{\r\n\t\tpage = Math.Clamp( page, 0, Math.Max( 0, _pages.Count - 1 ) );\r\n\t\tSelected = page;\r\n\t\tUpdate();\r\n\t\tif ( notify )\r\n\t\t\tOnSelected?.Invoke( page );\r\n\t}\r\n\r\n\tprivate int RowAt( float y ) => y < 4 ? -1 : (int)((y - 4) / RowHeight) is var i && i < _pages.Count ? i : -1;\r\n\r\n\tprotected override void OnMouseMove( MouseEvent e )\r\n\t{\r\n\t\tvar row = RowAt( e.LocalPosition.y );\r\n\t\tif ( row != _hover )\r\n\t\t{\r\n\t\t\t_hover = row;\r\n\t\t\tToolTip = row >= 0 ? _pages[row].Tip : null;\r\n\t\t\tUpdate();\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnMouseLeave()\r\n\t{\r\n\t\t_hover = -1;\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tprotected override void OnMousePress( MouseEvent e )\r\n\t{\r\n\t\tif ( !e.LeftMouseButton )\r\n\t\t\treturn;\r\n\t\tvar row = RowAt( e.LocalPosition.y );\r\n\t\tif ( row >= 0 )\r\n\t\t\tSelect( row );\r\n\t\te.Accepted = true;\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tPaint.Antialiasing = true;\r\n\t\tfor ( var i = 0; i < _pages.Count; i++ )\r\n\t\t{\r\n\t\t\tvar (icon, title, _) = _pages[i];\r\n\t\t\tvar rect = new Rect( 0, 4 + i * RowHeight, Width, RowHeight - 2 );\r\n\t\t\tvar selected = i == Selected;\r\n\t\t\tif ( selected || i == _hover )\r\n\t\t\t{\r\n\t\t\t\tPaint.ClearPen();\r\n\t\t\t\tPaint.SetBrush( selected ? Theme.ControlBackground.Lighten( .35f ) : Theme.ControlBackground.Lighten( .15f ) );\r\n\t\t\t\tPaint.DrawRect( rect, 5 );\r\n\t\t\t}\r\n\t\t\tif ( selected )\r\n\t\t\t{\r\n\t\t\t\tPaint.ClearPen();\r\n\t\t\t\tPaint.SetBrush( Theme.Green );\r\n\t\t\t\tPaint.DrawRect( new Rect( rect.Left, rect.Top + 7, 3, rect.Height - 14 ), 1.5f );\r\n\t\t\t}\r\n\t\t\tPaint.SetPen( selected ? Theme.Green : Theme.TextLight );\r\n\t\t\tPaint.DrawIcon( new Rect( rect.Left + 12, rect.Top, 20, rect.Height ), icon, 16 );\r\n\t\t\tPaint.SetPen( selected ? Theme.Text : Theme.Text.WithAlpha( .8f ) );\r\n\t\t\tPaint.SetDefaultFont( 9, selected ? 600 : 400 );\r\n\t\t\tPaint.DrawText( new Rect( rect.Left + 40, rect.Top, rect.Width - 60, rect.Height ), title, TextFlag.LeftCenter );\r\n\t\t\tif ( _status.TryGetValue( i, out var status ) && status is { } dot )\r\n\t\t\t{\r\n\t\t\t\tPaint.ClearPen();\r\n\t\t\t\tPaint.SetBrush( dot );\r\n\t\t\t\tPaint.DrawRect( new Rect( rect.Right - 18, rect.Center.y - 4, 8, 8 ), 4 );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Core/Analysis/AnimationClassifier.cs",
            "FileName": "AnimationClassifier.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "#nullable enable annotations\r\n\r\nnamespace WeaponImporter.Core.Analysis;\r\n\r\n/// <summary>A guess at an animation's role, with how sure the classifier is.</summary>\r\npublic sealed record AnimationGuess(string Animation, AnimationRole Role, float Confidence, string Reason)\r\n{\r\n    /// <summary>First-person (view model) or third-person (world / body) clip, when the name says so.</summary>\r\n    public AnimationPerspective Perspective { get; init; }\r\n}\r\n\r\npublic enum AnimationPerspective { Any, FirstPerson, ThirdPerson }\r\n\r\n/// <summary>\r\n/// Maps animation names onto <see cref=\"AnimationRole\"/>s using aliases common across game\r\n/// rigs and marketplace packs (<c>shoot</c>, <c>reload_empty</c>, <c>deploy</c>, <c>lookat</c>,\r\n/// <c>ads_fire</c>, <c>rechamber</c>...). Scoring is token based so prefixes, numbering and\r\n/// casing do not matter.\r\n/// </summary>\r\npublic static class AnimationClassifier\r\n{\r\n    private sealed record Rule(AnimationRole Role, string[] Any, string[]? Joined = null, string[]? Requires = null, string[]? Excludes = null, float Weight = 1f);\r\n\r\n    private static readonly string[] AimTokens = { \"ads\", \"aim\", \"aiming\", \"iron\", \"ironsight\", \"ironsights\", \"sight\", \"sights\", \"zoom\", \"scope\" };\r\n    private static readonly string[] AimLoopTokens = { \"idle\", \"loop\", \"hold\", \"pose\", \"static\", \"aiming\", \"breathing\", \"breath\" };\r\n    private static readonly string[] AimOutTokens = { \"out\", \"exit\", \"end\", \"lower\", \"leave\", \"stop\", \"release\" };\r\n\r\n    // Order matters only for ties; more specific rules carry higher weights.\r\n    private static readonly Rule[] Rules =\r\n    {\r\n        new(AnimationRole.AdsFire, new[] { \"fire\", \"shoot\", \"shot\", \"attack\", \"firing\" }, new[] { \"adsfire\", \"aimfire\", \"ironfire\", \"firead\", \"fireaim\", \"fireiron\", \"sightfire\", \"scopefire\" }, Requires: new[] { \"ads\", \"aim\", \"iron\", \"ironsight\", \"ironsights\", \"sight\", \"scope\", \"zoom\" }, Weight: 1.35f),\r\n        new(AnimationRole.FireEmpty, new[] { \"fire\", \"shoot\", \"shot\", \"attack\", \"dry\", \"dryfire\" }, new[] { \"fireempty\", \"firelast\", \"shootempty\", \"shootlast\", \"dryfire\", \"lastshot\", \"emptyfire\" }, Requires: new[] { \"empty\", \"last\", \"dry\", \"final\" }, Weight: 1.3f),\r\n        new(AnimationRole.Fire, new[] { \"fire\", \"shoot\", \"shot\", \"attack\", \"firing\", \"recoil\", \"primary\", \"burst\", \"auto\", \"semi\" }, Excludes: new[] { \"reload\", \"select\", \"mode\", \"switch\" }),\r\n\r\n        new(AnimationRole.EmptyReload, new[] { \"reload\", \"rld\", \"reloading\" }, new[] { \"reloadempty\", \"emptyreload\", \"reloadlong\", \"reloaddry\", \"reloadfull\" }, Requires: new[] { \"empty\", \"dry\", \"long\", \"full\", \"outofammo\" }, Weight: 1.3f),\r\n        new(AnimationRole.TacticalReload, new[] { \"reload\", \"rld\", \"reloading\" }, new[] { \"reloadtac\", \"tacreload\", \"reloadtactical\", \"tacticalreload\", \"reloadshort\", \"reloadpartial\" }, Requires: new[] { \"tac\", \"tactical\", \"short\", \"partial\", \"fast\", \"speed\" }, Weight: 1.3f),\r\n        new(AnimationRole.Reload, new[] { \"reload\", \"rld\", \"reloading\", \"magswap\", \"magchange\" }, Excludes: new[] { \"start\", \"end\", \"loop\", \"insert\", \"begin\", \"enter\", \"finish\", \"exit\", \"shell\", \"single\" }),\r\n        // Shell-by-shell reloads (shotguns, tube-fed rifles): start / one shell / end.\r\n        new(AnimationRole.ReloadStart, new[] { \"start\", \"begin\", \"enter\", \"open\", \"in\", \"intro\" }, new[] { \"reloadstart\", \"startreload\", \"reloadbegin\", \"reloadenter\", \"reloadopen\", \"reloadintro\", \"idletoreload\", \"toreload\" }, Requires: new[] { \"reload\", \"rld\", \"reloading\", \"load\", \"loading\" }, Excludes: new[] { \"fire\", \"shoot\" }, Weight: 1.3f),\r\n        new(AnimationRole.ReloadInsert, new[] { \"insert\", \"loop\", \"shell\", \"single\", \"load\", \"inserting\", \"step\" }, new[] { \"reloadinsert\", \"reloadstep\", \"insertshell\", \"shellinsert\", \"reloadloop\", \"loadshell\", \"shellload\", \"reloadshell\", \"reloadsingle\", \"insertround\", \"loadround\" }, Requires: new[] { \"reload\", \"rld\", \"reloading\", \"insert\", \"shell\", \"round\", \"load\" }, Excludes: new[] { \"start\", \"begin\", \"end\", \"finish\", \"exit\", \"fire\", \"shoot\" }, Weight: 1.3f),\r\n        new(AnimationRole.ReloadEnd, new[] { \"end\", \"finish\", \"exit\", \"close\", \"out\", \"outro\", \"stop\" }, new[] { \"reloadend\", \"endreload\", \"reloadfinish\", \"reloadexit\", \"reloadclose\", \"reloadoutro\", \"reloadtoidle\", \"reloadto\" }, Requires: new[] { \"reload\", \"rld\", \"reloading\", \"load\", \"loading\" }, Excludes: new[] { \"fire\", \"shoot\" }, Weight: 1.3f),\r\n\r\n        new(AnimationRole.Unjam, new[] { \"unjam\", \"clear\", \"clearjam\", \"fixjam\", \"malfunctionclear\", \"tapack\", \"remedy\" }, new[] { \"unjam\", \"clearjam\", \"fixjam\", \"jamclear\", \"jamfix\" }, Weight: 1.3f),\r\n        new(AnimationRole.Jam, new[] { \"jam\", \"jammed\", \"malfunction\", \"misfire\", \"stovepipe\" }, Excludes: new[] { \"clear\", \"fix\", \"un\" }),\r\n        new(AnimationRole.Bolt, new[] { \"bolt\", \"rechamber\", \"chamber\", \"pump\", \"charge\", \"charging\", \"cock\", \"cocking\", \"rack\", \"cycle\", \"lever\", \"slide\" }, Excludes: new[] { \"reload\", \"fire\", \"shoot\", \"idle\" }),\r\n\r\n        new(AnimationRole.Draw, new[] { \"draw\", \"deploy\", \"equip\", \"pullout\", \"raise\", \"unholster\", \"takeout\", \"ready\", \"select\", \"pickup\" }, new[] { \"firstdraw\", \"drawfirst\", \"pullout\", \"takeout\" }, Excludes: new[] { \"reload\" }),\r\n        new(AnimationRole.Holster, new[] { \"holster\", \"putaway\", \"unequip\", \"lower\", \"stow\", \"putdown\", \"deselect\", \"hide\" }, new[] { \"putaway\", \"putdown\" }, Excludes: new[] { \"unholster\" }),\r\n        new(AnimationRole.Inspect, new[] { \"inspect\", \"lookat\", \"examine\", \"check\", \"fidget\", \"admire\", \"showoff\", \"look\" }, new[] { \"lookat\", \"checkmag\", \"magcheck\", \"showoff\" }),\r\n\r\n        new(AnimationRole.Sprint, new[] { \"sprint\", \"run\", \"running\", \"dash\" }),\r\n        new(AnimationRole.Walk, new[] { \"walk\", \"walking\", \"move\", \"moving\", \"jog\", \"strafe\", \"bob\" }),\r\n        // Aiming, any naming style: raising the sights (\"Aim_In\", \"ADS\", \"IronIn\", \"Zoom_Enter\"),\r\n        // the held loop (\"Aim_Idle\", \"ADS_Loop\"), lowering them (\"Aim_Out\", \"ADS_Exit\", \"Unaim\").\r\n        new(AnimationRole.Ads, AimTokens.Concat(new[] { \"adsin\", \"aimin\" }).ToArray(), new[] { \"aimin\", \"adsin\", \"ironin\", \"zoomin\", \"sightin\", \"scopein\", \"aimenter\", \"adsenter\", \"aimstart\", \"adsstart\" }, Excludes: AimOutTokens.Concat(AimLoopTokens).Concat(new[] { \"fire\", \"shoot\", \"unaim\", \"unads\" }).ToArray()),\r\n        new(AnimationRole.AdsIdle, AimLoopTokens, new[] { \"aimidle\", \"adsidle\", \"aimloop\", \"adsloop\", \"ironidle\", \"zoomidle\", \"aimhold\", \"adshold\", \"aimingidle\" }, Requires: AimTokens, Excludes: AimOutTokens.Concat(new[] { \"fire\", \"shoot\" }).ToArray(), Weight: 1.3f),\r\n        new(AnimationRole.AdsOut, AimOutTokens.Concat(new[] { \"unaim\", \"unads\", \"adsout\", \"aimout\" }).ToArray(), new[] { \"aimout\", \"adsout\", \"ironout\", \"zoomout\", \"sightout\", \"scopeout\", \"unaim\", \"unads\", \"aimexit\", \"adsexit\", \"aimend\", \"adsend\" }, Requires: AimTokens.Concat(new[] { \"unaim\", \"unads\", \"adsout\", \"aimout\" }).ToArray(), Excludes: new[] { \"fire\", \"shoot\" }, Weight: 1.3f),\r\n        new(AnimationRole.Melee, new[] { \"melee\", \"bash\", \"stab\", \"slash\", \"swing\", \"hit\", \"knife\", \"punch\", \"butt\", \"strike\", \"attackmelee\", \"jab\", \"hook\", \"uppercut\", \"chop\", \"thrust\" }, new[] { \"meleeattack\" }, Excludes: new[] { \"heavy\", \"strong\", \"power\", \"charged\", \"secondary\", \"alt\", \"get\", \"react\", \"reaction\", \"damage\", \"hurt\", \"gethit\", \"hitreact\" }, Weight: 1.1f),\r\n        new(AnimationRole.Attack2, new[] { \"heavy\", \"strong\", \"power\", \"charged\", \"secondary\", \"alt\", \"attack2\", \"special\" }, new[] { \"heavyattack\", \"attackheavy\", \"secondaryattack\", \"attacksecondary\", \"attack2\", \"altattack\", \"attackalt\", \"powerattack\", \"chargedattack\", \"strongattack\", \"heavyslash\", \"heavyswing\" }, Requires: new[] { \"attack\", \"slash\", \"swing\", \"stab\", \"hit\", \"strike\", \"punch\", \"melee\", \"chop\", \"attack2\" }, Weight: 1.3f),\r\n        // Guarding: raise / hold / lower.\r\n        new(AnimationRole.BlockStart, new[] { \"start\", \"begin\", \"enter\", \"in\", \"raise\", \"up\" }, new[] { \"blockstart\", \"startblock\", \"blockin\", \"blockbegin\", \"parrystart\", \"guardstart\", \"guardup\", \"blockenter\" }, Requires: new[] { \"block\", \"parry\", \"guard\", \"defend\", \"defense\" }, Weight: 1.3f),\r\n        new(AnimationRole.Block, new[] { \"block\", \"parry\", \"guard\", \"defend\", \"defense\", \"blocking\" }, new[] { \"blockloop\", \"blockidle\", \"blockhold\", \"parryloop\", \"guardloop\", \"guardidle\" }, Excludes: new[] { \"start\", \"begin\", \"enter\", \"end\", \"stop\", \"exit\", \"out\" }),\r\n        new(AnimationRole.BlockEnd, new[] { \"end\", \"stop\", \"exit\", \"out\", \"lower\", \"down\", \"release\" }, new[] { \"blockend\", \"blockstop\", \"endblock\", \"blockout\", \"blockexit\", \"parryend\", \"guardend\", \"guarddown\" }, Requires: new[] { \"block\", \"parry\", \"guard\", \"defend\", \"defense\" }, Weight: 1.3f),\r\n        // Items: use once, or start / hold / finish a use.\r\n        new(AnimationRole.Use, new[] { \"use\", \"drink\", \"eat\", \"inject\", \"heal\", \"consume\", \"apply\", \"bandage\", \"smoke\", \"injection\", \"healing\", \"sip\", \"gulp\", \"swallow\", \"spray\", \"activate\", \"press\", \"toggle\" }, new[] { \"useitem\", \"itemuse\" }, Excludes: new[] { \"start\", \"begin\", \"enter\", \"loop\", \"hold\", \"end\", \"stop\", \"exit\", \"finish\", \"takeout\", \"out\" }),\r\n        new(AnimationRole.UseStart, new[] { \"start\", \"begin\", \"enter\", \"in\", \"raise\", \"up\", \"open\" }, new[] { \"usestart\", \"startuse\", \"drinkstart\", \"eatstart\", \"takestart\", \"healstart\", \"injectstart\" }, Requires: new[] { \"use\", \"drink\", \"eat\", \"inject\", \"heal\", \"consume\", \"take\", \"apply\", \"bandage\", \"smoke\" }, Weight: 1.3f),\r\n        new(AnimationRole.UseLoop, new[] { \"loop\", \"hold\", \"idle\", \"holding\", \"continue\" }, new[] { \"useloop\", \"drinkloop\", \"eatloop\", \"takeloop\", \"healloop\", \"usehold\" }, Requires: new[] { \"use\", \"drink\", \"eat\", \"inject\", \"heal\", \"consume\", \"take\", \"apply\", \"bandage\", \"smoke\" }, Weight: 1.3f),\r\n        new(AnimationRole.UseEnd, new[] { \"end\", \"stop\", \"exit\", \"finish\", \"out\", \"lower\", \"down\", \"close\" }, new[] { \"useend\", \"usestop\", \"drinkend\", \"eatend\", \"takestop\", \"takeend\", \"healend\", \"injectend\" }, Requires: new[] { \"use\", \"drink\", \"eat\", \"inject\", \"heal\", \"consume\", \"take\", \"apply\", \"bandage\", \"smoke\" }, Weight: 1.3f),\r\n        new(AnimationRole.Throw, new[] { \"throw\", \"toss\", \"lob\", \"pitch\", \"hurl\" }, new[] { \"throwgrenade\", \"grenadethrow\" }, Weight: 1.2f),\r\n        new(AnimationRole.Idle, new[] { \"idle\", \"rest\", \"hold\", \"static\", \"pose\", \"bind\", \"stand\", \"base\", \"breathing\", \"breath\", \"breathe\" }, new[] { \"breathing\", \"idleloop\" }, Excludes: new[] { \"to\", \"fire\", \"reload\" }, Weight: 0.9f),\r\n    };\r\n\r\n    private static readonly string[] FirstPersonTokens = { \"fp\", \"1p\", \"vm\", \"v\", \"view\", \"viewmodel\", \"arms\", \"firstperson\", \"fps\" };\r\n    private static readonly string[] ThirdPersonTokens = { \"tp\", \"3p\", \"wm\", \"w\", \"world\", \"worldmodel\", \"thirdperson\", \"tps\", \"body\", \"player\", \"citizen\" };\r\n\r\n    /// <summary>Classifies one animation name.</summary>\r\n    public static AnimationGuess Classify(string name, AnimationMotionHint? motion = null)\r\n    {\r\n        var tokens = NameTokens.Split(name);\r\n        var joined = NameTokens.Joined(tokens);\r\n        var perspective = NameTokens.Has(tokens, ThirdPersonTokens) ? AnimationPerspective.ThirdPerson\r\n            : NameTokens.Has(tokens, FirstPersonTokens) ? AnimationPerspective.FirstPerson\r\n            : AnimationPerspective.Any;\r\n\r\n        AnimationRole best = AnimationRole.Unknown;\r\n        float bestScore = 0f;\r\n        string reason = \"no matching name\";\r\n\r\n        foreach (var rule in Rules)\r\n        {\r\n            var score = 0f;\r\n            string why = \"\";\r\n            if (rule.Joined is { } joinedAliases && joinedAliases.Any(j => joined.Contains(j, StringComparison.Ordinal)))\r\n            {\r\n                score = 1f;\r\n                why = $\"name contains '{joinedAliases.First(j => joined.Contains(j, StringComparison.Ordinal))}'\";\r\n            }\r\n            else if (NameTokens.Has(tokens, rule.Any))\r\n            {\r\n                if (rule.Requires is { } req && !NameTokens.Has(tokens, req))\r\n                    continue;\r\n                score = rule.Requires is null ? 0.8f : 0.95f;\r\n                why = $\"'{tokens.First(t => rule.Any.Any(a => t == a || (a.Length >= 4 && t.StartsWith(a, StringComparison.Ordinal))))}' in name\";\r\n            }\r\n            else\r\n            {\r\n                continue;\r\n            }\r\n\r\n            if (rule.Excludes is { } ex && NameTokens.Has(tokens, ex))\r\n                score *= 0.45f;\r\n            // The role's own word (\"Idle\") beats a synonym (\"Hold\") on a tie.\r\n            if (tokens.Contains(rule.Any[0]))\r\n                score += 0.02f;\r\n            score *= rule.Weight;\r\n\r\n            if (score > bestScore)\r\n            {\r\n                bestScore = score;\r\n                best = rule.Role;\r\n                reason = why;\r\n            }\r\n        }\r\n\r\n        // Motion evidence breaks ties and rescues meaningless names (\"anim_03\").\r\n        if (motion is { } m)\r\n        {\r\n            var (role, conf, why) = FromMotion(m);\r\n            if (best == AnimationRole.Unknown && role != AnimationRole.Unknown)\r\n            {\r\n                best = role;\r\n                bestScore = conf;\r\n                reason = why;\r\n            }\r\n            else if (role == best && role != AnimationRole.Unknown)\r\n            {\r\n                bestScore = MathF.Min(1f, bestScore + 0.1f);\r\n            }\r\n        }\r\n\r\n        return new AnimationGuess(name, best, MathF.Min(1f, bestScore), reason) { Perspective = perspective };\r\n    }\r\n\r\n    /// <summary>\r\n    /// Classifies a set of animations and resolves duplicates: each role goes to the clip with\r\n    /// the highest confidence, preferring shorter base names (\"reload\" over \"reload_02\").\r\n    /// </summary>\r\n    public static IReadOnlyDictionary<AnimationRole, AnimationGuess> Assign(IEnumerable<AnimationGuess> guesses, AnimationPerspective prefer = AnimationPerspective.Any)\r\n    {\r\n        var result = new Dictionary<AnimationRole, AnimationGuess>();\r\n        foreach (var group in guesses.Where(g => g.Role != AnimationRole.Unknown).GroupBy(g => g.Role))\r\n        {\r\n            var pick = group\r\n                .OrderByDescending(g => g.Confidence + (prefer != AnimationPerspective.Any && g.Perspective == prefer ? 0.15f : 0f) - (prefer != AnimationPerspective.Any && g.Perspective != AnimationPerspective.Any && g.Perspective != prefer ? 0.3f : 0f))\r\n                .ThenBy(g => g.Animation.Count(char.IsDigit))\r\n                .ThenBy(g => g.Animation.Length)\r\n                .First();\r\n            result[group.Key] = pick;\r\n        }\r\n\r\n        // A lone \"reload\" that is actually the empty one leaves Reload itself free: promote.\r\n        if (!result.ContainsKey(AnimationRole.Reload))\r\n        {\r\n            if (result.TryGetValue(AnimationRole.TacticalReload, out var tac))\r\n                result[AnimationRole.Reload] = tac with { Role = AnimationRole.Reload, Confidence = tac.Confidence * 0.9f, Reason = \"tactical reload used as reload\" };\r\n            else if (result.TryGetValue(AnimationRole.EmptyReload, out var empty))\r\n                result[AnimationRole.Reload] = empty with { Role = AnimationRole.Reload, Confidence = empty.Confidence * 0.85f, Reason = \"empty reload used as reload\" };\r\n        }\r\n        // \"IdleToReload\" / \"Reload\" / \"ReloadToIdle\": between a start and an end, the plain\r\n        // reload is the one-shell part.\r\n        if (result.ContainsKey(AnimationRole.ReloadStart) && result.ContainsKey(AnimationRole.ReloadEnd)\r\n            && !result.ContainsKey(AnimationRole.ReloadInsert) && result.TryGetValue(AnimationRole.Reload, out var middle))\r\n        {\r\n            result[AnimationRole.ReloadInsert] = middle with { Role = AnimationRole.ReloadInsert, Reason = \"reload between a reload start and end\" };\r\n            result.Remove(AnimationRole.Reload);\r\n        }\r\n        if (!result.ContainsKey(AnimationRole.Fire) && result.TryGetValue(AnimationRole.AdsFire, out var adsFire))\r\n            result[AnimationRole.Fire] = adsFire with { Role = AnimationRole.Fire, Confidence = adsFire.Confidence * 0.8f, Reason = \"ADS fire used as fire\" };\r\n        return result;\r\n    }\r\n\r\n    private static (AnimationRole, float, string) FromMotion(AnimationMotionHint m)\r\n    {\r\n        if (m.MagazineTravel > 2f && m.Duration > 1f)\r\n            return (AnimationRole.Reload, 0.6f, \"magazine leaves the weapon\");\r\n        if (m.BoltTravel > 0.3f && m.Duration < 0.6f)\r\n            return (AnimationRole.Fire, 0.55f, \"short clip with slide/bolt cycling\");\r\n        if (m.BoltTravel > 0.3f && m.Duration >= 0.6f)\r\n            return (AnimationRole.Bolt, 0.5f, \"bolt cycles without magazine change\");\r\n        if (m.RootTravel < 0.05f && m.Duration > 0.5f && m.MaxBoneMotion < 0.1f)\r\n            return (AnimationRole.Idle, 0.5f, \"almost no motion\");\r\n        return (AnimationRole.Unknown, 0f, \"motion inconclusive\");\r\n    }\r\n}\r\n\r\n/// <summary>Motion measurements used when a name alone is not enough.</summary>\r\npublic readonly record struct AnimationMotionHint(float Duration, float MagazineTravel, float BoltTravel, float RootTravel, float MaxBoneMotion);\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Code/WeaponImporter/CharacterOverlay.cs",
            "FileName": "CharacterOverlay.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "using System.Text.Json;\r\n\r\nnamespace WeaponImporter;\r\n\r\n/// <summary>A replacement character animation for one action.</summary>\r\npublic sealed class OverlayInfo\r\n{\r\n    public string Role;\r\n    /// <summary>Model that owns the sequence (\"\" = the character's own model). Must share the character's skeleton.</summary>\r\n    public string Model;\r\n    public string Sequence;\r\n    /// <summary>Fade in/out time in seconds.</summary>\r\n    public float Blend = 0.2f;\r\n    /// <summary>More sequences of the same model played in turn with <see cref=\"Sequence\"/> (attack combos).</summary>\r\n    public List<string> Variants = new();\r\n}\r\n\r\n/// <summary>\r\n/// Plays a replacement character sequence for an action on a hidden model with the same\r\n/// skeleton and blends it over the upper body (spine and everything above), so the weapon and\r\n/// the hands follow it while the animgraph keeps driving the legs.\r\n/// </summary>\r\ninternal sealed class CharacterOverlay\r\n{\r\n    private SceneModel _model;\r\n    private Model _source;\r\n    private string _sequence;\r\n    private readonly Dictionary<string, int> _boneIndex = new( StringComparer.Ordinal );\r\n\r\n    public bool Active { get; private set; }\r\n    public float Weight { get; private set; }\r\n    public float Duration { get; private set; }\r\n\r\n    /// <summary>\r\n    /// Bone the overlay is attached at: its upper body is moved so this bone stays where the\r\n    /// character's own animation has it (a lunge or a crouch in the sequence bends the upper\r\n    /// body without pulling it off the hips the animgraph keeps).\r\n    /// </summary>\r\n    public const string AnchorBone = \"spine_0\";\r\n\r\n    private Vector3 _offset;\r\n\r\n    /// <summary>Starts the overlay; returns false when the model or sequence is unusable.</summary>\r\n    public bool Begin( SkinnedModelRenderer body, OverlayInfo info, out string error ) => Begin( body, info, info?.Sequence, out error );\r\n\r\n    /// <summary>Starts the overlay with one of the info's sequences (its own or a variant).</summary>\r\n    public bool Begin( SkinnedModelRenderer body, OverlayInfo info, string sequence, out string error )\r\n    {\r\n        error = null;\r\n        Active = false;\r\n        Weight = 0f;\r\n        if ( info == null || string.IsNullOrEmpty( sequence ) || body?.SceneModel == null )\r\n            return false;\r\n\r\n        var model = string.IsNullOrEmpty( info.Model ) ? body.Model : Sandbox.Model.Load( info.Model );\r\n        if ( model == null || model.IsError )\r\n        {\r\n            error = $\"model '{info.Model}' could not be loaded\";\r\n            return false;\r\n        }\r\n        if ( !model.AnimationNames.Contains( sequence ) )\r\n        {\r\n            error = $\"model '{model.Name}' has no sequence '{sequence}'\";\r\n            return false;\r\n        }\r\n\r\n        if ( _model == null || !_model.IsValid() || _source != model )\r\n        {\r\n            Dispose();\r\n            _model = new SceneModel( body.SceneModel.World, model, body.SceneModel.Transform )\r\n            {\r\n                UseAnimGraph = false,\r\n                RenderingEnabled = false,\r\n            };\r\n            _source = model;\r\n            _boneIndex.Clear();\r\n            for ( int i = 0; i < model.BoneCount; i++ )\r\n                _boneIndex[model.GetBoneName( i )] = i;\r\n        }\r\n\r\n        _sequence = sequence;\r\n        _model.CurrentSequence.Name = _sequence;\r\n        _model.Update( 0f );\r\n        Duration = _model.CurrentSequence.Duration;\r\n        Active = true;\r\n        return true;\r\n    }\r\n\r\n    /// <summary>Poses the overlay at <paramref name=\"seconds\"/> into the action with the given blend weight.</summary>\r\n    public void Update( SkinnedModelRenderer body, float seconds, float weight )\r\n    {\r\n        if ( !Active || _model == null || !_model.IsValid() )\r\n            return;\r\n        _model.Transform = body.SceneModel.Transform;\r\n        _model.CurrentSequence.Name = _sequence;\r\n        _model.CurrentSequence.Time = Math.Clamp( seconds, 0f, MathF.Max( 0f, Duration ) );\r\n        _model.Update( 0f );\r\n        Weight = Math.Clamp( weight, 0f, 1f );\r\n        _offset = Vector3.Zero;\r\n        var anchor = body.Model?.Bones.GetBone( AnchorBone );\r\n        if ( anchor != null && _boneIndex.TryGetValue( AnchorBone, out var index ) && body.TryGetBoneTransformAnimation( anchor, out var own ) )\r\n            _offset = own.Position - _model.GetBoneWorldTransform( index ).Position;\r\n    }\r\n\r\n    /// <summary>World transform of a bone in the overlay pose.</summary>\r\n    public bool TryGetWorld( string bone, out Transform world )\r\n    {\r\n        world = global::Transform.Zero;\r\n        if ( !Active || _model == null || !_boneIndex.TryGetValue( bone, out var index ) )\r\n            return false;\r\n        world = _model.GetBoneWorldTransform( index );\r\n        world.Position += _offset;\r\n        return true;\r\n    }\r\n\r\n    public void End()\r\n    {\r\n        Active = false;\r\n        Weight = 0f;\r\n    }\r\n\r\n    public void Dispose()\r\n    {\r\n        if ( _model != null && _model.IsValid() )\r\n            _model.Delete();\r\n        _model = null;\r\n        _source = null;\r\n        Active = false;\r\n    }\r\n\r\n    /// <summary>Parses <c>{\"reload\":{\"model\":\"\",\"sequence\":\"reload_rifle\",\"blend\":0.2,\"variants\":[\"reload_2\"]}}</c>.</summary>\r\n    public static bool TryParse( string json, Dictionary<string, OverlayInfo> into, out string error )\r\n    {\r\n        into.Clear();\r\n        error = null;\r\n        if ( string.IsNullOrWhiteSpace( json ) )\r\n            return true;\r\n        try\r\n        {\r\n            using var doc = JsonDocument.Parse( json );\r\n            if ( doc.RootElement.ValueKind != JsonValueKind.Object )\r\n            {\r\n                error = \"expected an object\";\r\n                return false;\r\n            }\r\n            foreach ( var entry in doc.RootElement.EnumerateObject() )\r\n            {\r\n                if ( entry.Value.ValueKind != JsonValueKind.Object )\r\n                    continue;\r\n                var info = new OverlayInfo { Role = entry.Name.ToLowerInvariant() };\r\n                if ( entry.Value.TryGetProperty( \"model\", out var m ) && m.ValueKind == JsonValueKind.String )\r\n                    info.Model = m.GetString();\r\n                if ( entry.Value.TryGetProperty( \"sequence\", out var s ) && s.ValueKind == JsonValueKind.String )\r\n                    info.Sequence = s.GetString();\r\n                if ( entry.Value.TryGetProperty( \"blend\", out var b ) && b.ValueKind == JsonValueKind.Number )\r\n                    info.Blend = MathF.Max( 0f, b.GetSingle() );\r\n                if ( entry.Value.TryGetProperty( \"variants\", out var v ) && v.ValueKind == JsonValueKind.Array )\r\n                    foreach ( var item in v.EnumerateArray() )\r\n                        if ( item.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty( item.GetString() ) )\r\n                            info.Variants.Add( item.GetString() );\r\n                if ( !string.IsNullOrEmpty( info.Sequence ) )\r\n                    into[info.Role] = info;\r\n            }\r\n            return true;\r\n        }\r\n        catch ( JsonException e )\r\n        {\r\n            error = e.Message;\r\n            return false;\r\n        }\r\n    }\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Core/Generation/ExportRig.cs",
            "FileName": "ExportRig.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "#nullable enable annotations\r\n\r\nusing System.Numerics;\r\nusing WeaponImporter.Core.Analysis;\r\nusing WeaponImporter.Core.Maths;\r\nusing WeaponImporter.Core.Rig;\r\nusing WeaponImporter.Core.Weapon;\r\n\r\nnamespace WeaponImporter.Core.Generation;\r\n\r\nusing Vector3 = System.Numerics.Vector3;\r\n\r\n/// <summary>\r\n/// The rig a world model ships with: the weapon body becomes the fixed root, only bones the\r\n/// weapon uses are kept (arms, cameras and scene helpers are dropped) and parts parented\r\n/// elsewhere (a magazine hanging off a hand or the scene root) are re-parented under the body\r\n/// with their motion relative to it preserved. Root motion of the body is removed, so a\r\n/// first-person sway never moves the weapon in third person.\r\n/// </summary>\r\npublic sealed class ExportRig\r\n{\r\n    public required WeaponAsset Asset { get; init; }\r\n    public required int[] Triangles { get; init; }\r\n    public required string RootName { get; init; }\r\n\r\n    /// <summary>Old bone index -> new bone index (-1 when dropped).</summary>\r\n    public required int[] Map { get; init; }\r\n\r\n    public static ExportRig Build(WeaponAnalysis a, IEnumerable<string>? keepNames = null)\r\n    {\r\n        var source = a.Asset;\r\n        var skeleton = source.Skeleton;\r\n        var mesh = source.Mesh;\r\n\r\n        // Body: the bone most weapon triangles are skinned to.\r\n        var counts = new Dictionary<int, int>();\r\n        foreach (var t in a.WeaponTriangles)\r\n        {\r\n            var b = mesh.TriangleBone(t);\r\n            if (b >= 0)\r\n                counts[b] = counts.GetValueOrDefault(b) + 1;\r\n        }\r\n        var body = counts.Count > 0 ? counts.OrderByDescending(kv => kv.Value).First().Key : a.RootBone;\r\n\r\n        // Bones to keep: skinned weapon bones, named/moving parts, explicitly requested ones.\r\n        var keep = new HashSet<int> { body };\r\n        foreach (var b in counts.Keys)\r\n            keep.Add(b);\r\n        foreach (var p in a.Parts)\r\n            if (p.Bone.Length > 0 && skeleton.IndexOf(p.Bone) is var pi && pi >= 0)\r\n                keep.Add(pi);\r\n        // The left-hand copy of a dual weapon: its bones keep their own hierarchy under its root.\r\n        var second = a.SecondWeaponBone is { } secondName ? skeleton.IndexOf(secondName) : -1;\r\n        if (second >= 0)\r\n        {\r\n            keep.Add(second);\r\n            foreach (var t in a.SecondWeaponTriangles)\r\n                if (mesh.TriangleBone(t) is var sb && sb >= 0)\r\n                    keep.Add(sb);\r\n        }\r\n        bool Under(int bone) => bone == body || IsDescendant(skeleton, bone, body) || (second >= 0 && (bone == second || IsDescendant(skeleton, bone, second)));\r\n        if (keepNames is not null)\r\n            foreach (var n in keepNames)\r\n                if (!string.IsNullOrEmpty(n) && skeleton.IndexOf(n) is var ki && ki >= 0 && !a.ArmBones.Contains(ki))\r\n                    keep.Add(ki);\r\n        // Close over ancestors that sit between the body and a kept bone (keeps hierarchies intact).\r\n        foreach (var b in keep.ToList())\r\n            for (var p = skeleton[b].ParentIndex; p >= 0 && p != body && p != second; p = skeleton[p].ParentIndex)\r\n            {\r\n                if (!Under(p))\r\n                    break;\r\n                keep.Add(p);\r\n            }\r\n\r\n        // New parent of each kept bone: nearest kept ancestor under the body, else the body.\r\n        int NewParent(int b)\r\n        {\r\n            if (b == body)\r\n                return -1;\r\n            if (b == second)\r\n                return body;\r\n            for (var p = skeleton[b].ParentIndex; p >= 0; p = skeleton[p].ParentIndex)\r\n                if (keep.Contains(p) && Under(p))\r\n                    return p;\r\n            return body;\r\n        }\r\n\r\n        // Order: body first, then topological by new parent (stable, source order).\r\n        var order = new List<int> { body };\r\n        var placed = new HashSet<int> { body };\r\n        var remaining = skeleton.Bones.Select(x => x.Index).Where(i => keep.Contains(i) && i != body).ToList();\r\n        while (remaining.Count > 0)\r\n        {\r\n            var progress = false;\r\n            foreach (var b in remaining.ToList())\r\n                if (placed.Contains(NewParent(b)))\r\n                {\r\n                    order.Add(b);\r\n                    placed.Add(b);\r\n                    remaining.Remove(b);\r\n                    progress = true;\r\n                }\r\n            if (!progress)\r\n                break;\r\n        }\r\n\r\n        var rest = skeleton.RestWorld;\r\n        var names = new HashSet<string>(StringComparer.Ordinal);\r\n        var defs = new List<BoneDefinition>();\r\n        foreach (var b in order)\r\n        {\r\n            var parent = NewParent(b);\r\n            var local = parent < 0 ? rest[b] : XForm.ToLocal(rest[parent], rest[b]);\r\n            defs.Add(new BoneDefinition(skeleton[b].Name, parent < 0 ? null : skeleton[parent].Name, local));\r\n            names.Add(skeleton[b].Name);\r\n        }\r\n        var exportSkeleton = Skeleton.Create(defs);\r\n        var map = new int[skeleton.Count];\r\n        for (var i = 0; i < map.Length; i++)\r\n            map[i] = keep.Contains(i) ? exportSkeleton.IndexOf(skeleton[i].Name) : -1;\r\n\r\n        // Vertices skinned to dropped bones fall back to their nearest kept ancestor (or the body).\r\n        int MapBone(int b)\r\n        {\r\n            for (var x = b; x >= 0; x = skeleton[x].ParentIndex)\r\n                if (map[x] >= 0)\r\n                    return map[x];\r\n            return map[body];\r\n        }\r\n        var vertexBone = mesh.VertexBone.Select(b => b >= 0 ? MapBone(b) : map[body]).ToArray();\r\n        var exportMesh = mesh.WithVertices(mesh.Positions, vertexBone, _ => Quaternion.Identity);\r\n\r\n        // Clips: re-express every kept bone relative to its new parent, frame by frame.\r\n        var clips = new List<Clip>();\r\n        foreach (var clip in source.Clips)\r\n        {\r\n            var frames = new List<XForm[]>(clip.FrameCount);\r\n            foreach (var locals in clip.Frames)\r\n            {\r\n                var world = new XForm[skeleton.Count];\r\n                for (var i = 0; i < skeleton.Count; i++)\r\n                {\r\n                    var p = skeleton[i].ParentIndex;\r\n                    world[i] = p < 0 ? locals[i] : XForm.Compose(world[p], locals[i]);\r\n                }\r\n                // Body stays at its rest placement; everything else keeps its motion relative to it.\r\n                var bodyWorld = world[body];\r\n                var toRest = XForm.Compose(rest[body], bodyWorld.Inverse());\r\n                var outLocals = new XForm[exportSkeleton.Count];\r\n                foreach (var b in order)\r\n                {\r\n                    var nb = map[b];\r\n                    var parent = NewParent(b);\r\n                    if (parent < 0)\r\n                        outLocals[nb] = rest[body];\r\n                    else\r\n                        outLocals[nb] = XForm.ToLocal(XForm.Compose(toRest, world[parent]), XForm.Compose(toRest, world[b]));\r\n                }\r\n                frames.Add(outLocals);\r\n            }\r\n            clips.Add(new Clip(clip.Name, clip.Fps, clip.Looping, frames, clip.NativeFps));\r\n        }\r\n\r\n        var asset = new WeaponAsset\r\n        {\r\n            Name = source.Name,\r\n            Kind = source.Kind,\r\n            SourcePath = source.SourcePath,\r\n            Skeleton = exportSkeleton,\r\n            Mesh = exportMesh,\r\n            Clips = clips,\r\n            Attachments = source.Attachments,\r\n        };\r\n        return new ExportRig { Asset = asset, Triangles = a.WeaponTriangles.Concat(a.SecondWeaponTriangles).ToArray(), RootName = skeleton[body].Name, Map = map };\r\n    }\r\n\r\n    private static bool IsDescendant(Skeleton skeleton, int bone, int ancestor)\r\n    {\r\n        for (var p = skeleton[bone].ParentIndex; p >= 0; p = skeleton[p].ParentIndex)\r\n            if (p == ancestor)\r\n                return true;\r\n        return false;\r\n    }\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_weapon_importer",
            "Path": "Editor/WeaponImporter/Core/Hands/HandRig.cs",
            "FileName": "HandRig.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 397047,
            "IsPrivate": false,
            "Code": "#nullable enable annotations\r\n\r\nusing System.Numerics;\r\nusing WeaponImporter.Core.Maths;\r\nusing WeaponImporter.Core.Rig;\r\n\r\nnamespace WeaponImporter.Core.Hands;\r\n\r\nusing Vector3 = System.Numerics.Vector3;\r\n\r\npublic enum Side { Right, Left }\r\n\r\npublic enum FingerKind { Thumb, Index, Middle, Ring, Pinky }\r\n\r\n/// <summary>One finger: its joint bones from the knuckle outward, with flex axes.</summary>\r\npublic sealed class FingerRig\r\n{\r\n    public required FingerKind Kind { get; init; }\r\n\r\n    /// <summary>Joint bones, proximal first (metacarpals are not included).</summary>\r\n    public required int[] Joints { get; init; }\r\n\r\n    /// <summary>Segment lengths, one per joint (the last one is estimated to the fingertip).</summary>\r\n    public required float[] Lengths { get; init; }\r\n\r\n    /// <summary>Flexion axis of each joint in that joint's own rest-local frame.</summary>\r\n    public required Vector3[] FlexAxes { get; init; }\r\n\r\n    /// <summary>Spread (abduction) axis of the base joint, local frame.</summary>\r\n    public required Vector3 SpreadAxis { get; init; }\r\n\r\n    /// <summary>Flexion limits per joint (radians).</summary>\r\n    public required float[] MaxFlex { get; init; }\r\n\r\n    /// <summary>Finger radius used for contact tests.</summary>\r\n    public required float Radius { get; init; }\r\n\r\n    /// <summary>Local direction a joint points along (toward its child), rest frame.</summary>\r\n    public required Vector3[] Along { get; init; }\r\n}\r\n\r\n/// <summary>\r\n/// A character's hand and arm, read from bone names and the rest pose. Everything the grasp\r\n/// solver needs is derived here once: palm frame, finger chains, flex axes, sizes.\r\n/// </summary>\r\npublic sealed class HandRig\r\n{\r\n    public required Side Side { get; init; }\r\n    public required Skeleton Skeleton { get; init; }\r\n    public required int Hand { get; init; }\r\n    public required int LowerArm { get; init; }\r\n    public required int UpperArm { get; init; }\r\n    public int Clavicle { get; init; } = -1;\r\n\r\n    /// <summary>Twist/helper bones between shoulder and hand that just follow their parents.</summary>\r\n    public IReadOnlyList<int> Helpers { get; init; } = Array.Empty<int>();\r\n\r\n    public required IReadOnlyList<FingerRig> Fingers { get; init; }\r\n\r\n    /// <summary>Metacarpal bones (kept at rest).</summary>\r\n    public IReadOnlyList<int> Metacarpals { get; init; } = Array.Empty<int>();\r\n\r\n    // ---- hand-local frame (relative to the hand bone's rest transform) ----\r\n    public required Vector3 PalmCenter { get; init; }\r\n    public required Vector3 PalmNormal { get; init; }   // out of the palm\r\n    public required Vector3 FingerDirection { get; init; } // wrist -> knuckles\r\n    public required Vector3 KnuckleLine { get; init; }  // index -> pinky side\r\n    public required Vector3 KnuckleCenter { get; init; }\r\n    public required float HandLength { get; init; }\r\n    public required float PalmWidth { get; init; }\r\n    public float PalmThickness => HandLength * 0.14f;\r\n\r\n    /// <summary>\r\n    /// The character's model copies the ring finger onto the pinky (a constraint, like the\r\n    /// human's \"CopyPinky\"), so the pinky can't be posed on its own: poses give it the ring's angles.\r\n    /// </summary>\r\n    public bool PinkyFollowsRing { get; set; }\r\n\r\n    public FingerRig? Finger(FingerKind kind) => Fingers.FirstOrDefault(f => f.Kind == kind);\r\n\r\n    /// <summary>All bones this rig may rotate (arm chain and fingers).</summary>\r\n    public IEnumerable<int> Bones\r\n    {\r\n        get\r\n        {\r\n            if (Clavicle >= 0) yield return Clavicle;\r\n            yield return UpperArm;\r\n            yield return LowerArm;\r\n            yield return Hand;\r\n            foreach (var h in Helpers) yield return h;\r\n            foreach (var f in Fingers)\r\n                foreach (var j in f.Joints)\r\n                    yield return j;\r\n        }\r\n    }\r\n\r\n    // ------------------------------------------------------------------ building\r\n\r\n    private static string SideSuffix(Side side) => side == Side.Right ? \"R\" : \"L\";\r\n\r\n    /// <summary>\r\n    /// Finds a hand by the s&amp;box naming scheme (<c>hand_R</c>, <c>arm_lower_R</c>,\r\n    /// <c>finger_index_0_R</c>...) with fallbacks for common rigs (Mixamo, UE, Rigify, Biped).\r\n    /// Returns null when the skeleton has no recognisable arm.\r\n    /// </summary>\r\n    public static HandRig? Build(Skeleton skeleton, Side side)\r\n    {\r\n        var s = SideSuffix(side);\r\n        int Find(params string[] names)\r\n        {\r\n            foreach (var n in names)\r\n            {\r\n                var i = skeleton.IndexOf(n);\r\n                if (i >= 0)\r\n                    return i;\r\n            }\r\n            // Case-insensitive fallback.\r\n            foreach (var n in names)\r\n                for (var b = 0; b < skeleton.Count; b++)\r\n                    if (string.Equals(skeleton[b].Name, n, StringComparison.OrdinalIgnoreCase))\r\n                        return b;\r\n            return -1;\r\n        }\r\n        var lower = s.ToLowerInvariant();\r\n        var full = side == Side.Right ? \"Right\" : \"Left\";\r\n        var hand = Find($\"hand_{s}\", $\"Hand_{s}\", $\"hand.{s}\", $\"hand_{lower}\", $\"{full}Hand\", $\"mixamorig:{full}Hand\", $\"hand_{lower}\", $\"DEF-hand.{s}\", $\"Bip01 {s} Hand\");\r\n        if (hand < 0)\r\n            hand = FindHandByTokens(skeleton, side);\r\n        if (hand < 0)\r\n            return null;\r\n        var lowerArm = skeleton[hand].ParentIndex;\r\n        // Skip twist helpers between hand and forearm.\r\n        while (lowerArm >= 0 && IsHelper(skeleton[lowerArm].Name))\r\n            lowerArm = skeleton[lowerArm].ParentIndex;\r\n        if (lowerArm < 0)\r\n            return null;\r\n        var upperArm = skeleton[lowerArm].ParentIndex;\r\n        while (upperArm >= 0 && IsHelper(skeleton[upperArm].Name))\r\n            upperArm = skeleton[upperArm].ParentIndex;\r\n        // Rigs whose hands hang off a detached IK forearm (parented to the root) keep the real\r\n        // arm chain elsewhere: the shoulder is then the side's named upper arm.\r\n        if (upperArm < 0 || skeleton[upperArm].ParentIndex < 0)\r\n        {\r\n            var named = Find($\"{full}Arm\", $\"mixamorig:{full}Arm\", $\"upperarm_{lower}\", $\"UpperArm_{s}\", $\"arm_upper_{s}\", $\"upper_arm.{s}\", $\"DEF-upper_arm.{s}\", $\"Bip01 {s} UpperArm\");\r\n            if (named < 0)\r\n                for (var b = 0; b < skeleton.Count && named < 0; b++)\r\n                {\r\n                    var bare = skeleton[b].Name;\r\n                    var colon = bare.LastIndexOf(':');\r\n                    if (colon >= 0 && string.Equals(bare[(colon + 1)..], $\"{full}Arm\", StringComparison.OrdinalIgnoreCase))\r\n                        named = b;\r\n                }\r\n            if (named >= 0)\r\n                upperArm = named;\r\n        }\r\n        if (upperArm < 0)\r\n            return null;\r\n        var clavicle = skeleton[upperArm].ParentIndex;\r\n\r\n        var helpers = new List<int>();\r\n        for (var b = 0; b < skeleton.Count; b++)\r\n        {\r\n            var p = skeleton[b].ParentIndex;\r\n            if ((p == upperArm || p == lowerArm) && IsHelper(skeleton[b].Name))\r\n                helpers.Add(b);\r\n        }\r\n\r\n        var fingers = new List<FingerRig>();\r\n        var metas = new List<int>();\r\n        var restWorld = skeleton.RestWorld;\r\n        var handRest = restWorld[hand];\r\n\r\n        List<int> Chain(string finger)\r\n        {\r\n            var chain = new List<int>();\r\n            for (var k = 0; k < 4; k++)\r\n            {\r\n                // Deform bones before same-named controls (Rigify has both).\r\n                var b = Find($\"finger_{finger}_{k}_{s}\", $\"{finger}_{k}_{s}\", $\"{full}Hand{Cap(finger)}{k + 1}\", $\"mixamorig:{full}Hand{Cap(finger)}{k + 1}\", $\"{finger}_{(k + 1):00}_{lower}\",\r\n                    $\"DEF-f_{finger}.{(k + 1):00}.{s}\", $\"DEF-{finger}.{(k + 1):00}.{s}\", $\"{finger}.{(k + 1):00}.{s}\", $\"f_{finger}.{(k + 1):00}.{s}\");\r\n                if (b < 0)\r\n                    break;\r\n                chain.Add(b);\r\n            }\r\n            return chain;\r\n        }\r\n\r\n        var jointsByKind = new Dictionary<FingerKind, List<int>>();\r\n        foreach (var kind in Enum.GetValues<FingerKind>())\r\n        {\r\n            var name = kind.ToString().ToLowerInvariant();\r\n            var chain = Chain(name);\r\n            if (chain.Count == 0 && kind == FingerKind.Pinky)\r\n                chain = Chain(\"little\");\r\n            // Keep only the bones that descend from the hand.\r\n            chain = chain.Where(b => Descends(skeleton, b, hand)).Take(3).ToList();\r\n            if (chain.Count > 0)\r\n                jointsByKind[kind] = chain;\r\n            var meta = Find($\"finger_{name}_meta_{s}\");\r\n            if (meta >= 0)\r\n                metas.Add(meta);\r\n        }\r\n        if (!jointsByKind.ContainsKey(FingerKind.Index) || !jointsByKind.ContainsKey(FingerKind.Middle))\r\n            jointsByKind = FingersByShape(skeleton, hand) ?? jointsByKind;\r\n        if (!jointsByKind.ContainsKey(FingerKind.Index) || !jointsByKind.ContainsKey(FingerKind.Middle))\r\n            return null;\r\n\r\n        Vector3 Local(int bone) => handRest.Inverse().TransformPoint(restWorld[bone].Pos);\r\n\r\n        var indexBase = Local(jointsByKind[FingerKind.Index][0]);\r\n        var middleBase = Local(jointsByKind[FingerKind.Middle][0]);\r\n        var outer = jointsByKind.TryGetValue(FingerKind.Pinky, out var pinky) ? pinky : jointsByKind.GetValueOrDefault(FingerKind.Ring) ?? jointsByKind[FingerKind.Middle];\r\n        var outerBase = Local(outer[0]);\r\n        var knuckles = new[] { FingerKind.Index, FingerKind.Middle, FingerKind.Ring, FingerKind.Pinky }\r\n            .Where(jointsByKind.ContainsKey).Select(k => Local(jointsByKind[k][0])).ToList();\r\n        var knuckleCenter = knuckles.Aggregate(Vector3.Zero, (a, v) => a + v) / knuckles.Count;\r\n        var wrist = Vector3.Zero;\r\n        var fingerDir = SafeNormal(knuckleCenter - wrist, Vector3.UnitX);\r\n        var knuckleLine = outerBase - indexBase;\r\n        knuckleLine = SafeNormal(knuckleLine - fingerDir * Vector3.Dot(knuckleLine, fingerDir), MathQ.Perpendicular(fingerDir));\r\n\r\n        // Palm normal: perpendicular to fingers and knuckles; the sign comes from anatomy.\r\n        var normal = Vector3.Normalize(Vector3.Cross(fingerDir, knuckleLine));\r\n        var vote = 0f;\r\n        // Thumb sits on the palm side of the knuckle plane.\r\n        if (jointsByKind.TryGetValue(FingerKind.Thumb, out var thumb))\r\n        {\r\n            var thumbTip = Local(thumb[^1]);\r\n            vote += Vector3.Dot(thumbTip - wrist, normal) * 2f;\r\n        }\r\n        // Rest curl bends fingertips toward the palm.\r\n        foreach (var kind in new[] { FingerKind.Index, FingerKind.Middle, FingerKind.Ring })\r\n            if (jointsByKind.TryGetValue(kind, out var chain) && chain.Count >= 2)\r\n                vote += Vector3.Dot(Local(chain[^1]) - Local(chain[0]), normal) - Vector3.Dot(Local(chain[1]) - Local(chain[0]), normal) * 0.5f;\r\n        // Handedness fallback: for a right hand, fingers x knuckles(index->pinky) points out of\r\n        // the back of the hand in a right-handed frame; mirrored for the left.\r\n        if (MathF.Abs(vote) < 1e-3f)\r\n            vote = side == Side.Right ? -1f : 1f;\r\n        if (vote < 0f)\r\n            normal = -normal;\r\n\r\n        var middleChain = jointsByKind[FingerKind.Middle];\r\n        var middleTip = Local(middleChain[^1]);\r\n        var lastSeg = middleChain.Count >= 2 ? Vector3.Distance(Local(middleChain[^1]), Local(middleChain[^2])) : 1f;\r\n        var handLength = Vector3.Distance(wrist, middleTip) + lastSeg * 0.8f;\r\n        var palmWidth = MathF.Max(1e-3f, Vector3.Distance(indexBase, outerBase)) * 1.25f;\r\n        var palmCenter = Vector3.Lerp(wrist, knuckleCenter, 0.55f) + normal * 0.0f;\r\n\r\n        foreach (var (kind, chain) in jointsByKind)\r\n        {\r\n            var lengths = new float[chain.Count];\r\n            var axes = new Vector3[chain.Count];\r\n            var along = new Vector3[chain.Count];\r\n            for (var j = 0; j < chain.Count; j++)\r\n            {\r\n                var head = Local(chain[j]);\r\n                var tailWorld = j + 1 < chain.Count ? Local(chain[j + 1]) : head + (head - Local(j > 0 ? chain[j - 1] : hand)) * 0.8f;\r\n                var dir = SafeNormal(tailWorld - head, fingerDir);\r\n                lengths[j] = MathF.Max(0.05f, Vector3.Distance(head, tailWorld));\r\n                // Flexion bends the segment toward the palm: axis = dir x normal (right-hand rule\r\n                // turns dir toward -normal... pick the sign that moves the tip toward +normal).\r\n                var axisHand = SafeNormal(Vector3.Cross(dir, normal), knuckleLine);\r\n                if (kind == FingerKind.Thumb)\r\n                {\r\n                    // Thumb flexes across the palm toward the little finger.\r\n                    axisHand = SafeNormal(Vector3.Cross(dir, SafeNormal(knuckleCenter + knuckleLine * palmWidth * 0.5f - head, normal)), axisHand);\r\n                }\r\n                // Check sign: rotating dir about the axis must move it toward the palm (or across it).\r\n                var probeTarget = kind == FingerKind.Thumb ? SafeNormal(knuckleCenter + knuckleLine * palmWidth * 0.5f - head, normal) : normal;\r\n                var rotated = Vector3.Transform(dir, Quaternion.CreateFromAxisAngle(axisHand, 0.3f));\r\n                if (Vector3.Dot(rotated - dir, probeTarget) < 0f)\r\n                    axisHand = -axisHand;\r\n                // Into the joint's own rest frame.\r\n                var jointRestInHand = XForm.Compose(handRest.Inverse(), restWorld[chain[j]]);\r\n                axes[j] = Vector3.Normalize(Vector3.Transform(axisHand, Quaternion.Conjugate(jointRestInHand.Rot)));\r\n                along[j] = Vector3.Normalize(Vector3.Transform(dir, Quaternion.Conjugate(jointRestInHand.Rot)));\r\n            }\r\n            var baseRest = XForm.Compose(handRest.Inverse(), restWorld[chain[0]]);\r\n            var spreadHand = normal;\r\n            var spreadLocal = Vector3.Normalize(Vector3.Transform(spreadHand, Quaternion.Conjugate(baseRest.Rot)));\r\n            var maxFlex = kind == FingerKind.Thumb\r\n                ? new[] { 0.9f, 1.0f, 1.3f }\r\n                : new[] { 1.55f, 1.9f, 1.4f };\r\n            fingers.Add(new FingerRig\r\n            {\r\n                Kind = kind,\r\n                Joints = chain.ToArray(),\r\n                Lengths = lengths,\r\n                FlexAxes = axes,\r\n                SpreadAxis = spreadLocal,\r\n                MaxFlex = maxFlex.Take(chain.Count).ToArray(),\r\n                Radius = handLength * (kind == FingerKind.Thumb ? 0.058f : 0.05f),\r\n                Along = along,\r\n            });\r\n        }\r\n\r\n        return new HandRig\r\n        {\r\n            Side = side,\r\n            Skeleton = skeleton,\r\n            Hand = hand,\r\n            LowerArm = lowerArm,\r\n            UpperArm = upperArm,\r\n            Clavicle = clavicle,\r\n            Helpers = helpers,\r\n            Fingers = fingers.OrderBy(f => f.Kind).ToList(),\r\n            Metacarpals = metas,\r\n            PalmCenter = palmCenter,\r\n            PalmNormal = normal,\r\n            FingerDirection = fingerDir,\r\n            KnuckleLine = knuckleLine,\r\n            KnuckleCenter = knuckleCenter,\r\n            HandLength = handLength,\r\n            PalmWidth = palmWidth,\r\n        };\r\n    }\r\n\r\n    private static string Cap(string s) => char.ToUpperInvariant(s[0]) + s[1..];\r\n\r\n    // ------------------------------------------------------------------ rigs without known names\r\n\r\n    /// <summary>Control/mechanism bones that are never the deforming hand or finger.</summary>\r\n    private static readonly HashSet<string> ControlTokens = new(StringComparer.OrdinalIgnoreCase)\r\n    {\r\n        \"ik\", \"ctrl\", \"cntrl\", \"control\", \"mch\", \"org\", \"drv\", \"master\", \"tweak\", \"vis\", \"pole\", \"target\", \"end\", \"helper\", \"twist\", \"hold\", \"attach\", \"widget\", \"parent\", \"socket\", \"nub\",\r\n    };\r\n\r\n    /// <summary>Lowercase name tokens: \"IK_Hand_Cntrl_L_015\" \u2192 ik, hand, cntrl, l, 015.</summary>\r\n    public static List<string> Tokens(string name)\r\n    {\r\n        var tokens = new List<string>();\r\n        var current = new System.Text.StringBuilder();\r\n        void Flush()\r\n        {\r\n            if (current.Length > 0)\r\n                tokens.Add(current.ToString().ToLowerInvariant());\r\n            current.Clear();\r\n        }\r\n        for (var i = 0; i < name.Length; i++)\r\n        {\r\n            var c = name[i];\r\n            if (!char.IsLetterOrDigit(c))\r\n            {\r\n                Flush();\r\n                continue;\r\n            }\r\n            // Camel case and letter/digit boundaries split words.\r\n            if (current.Length > 0 && ((char.IsUpper(c) && char.IsLower(current[^1])) || char.IsDigit(c) != char.IsDigit(current[^1])))\r\n                Flush();\r\n            current.Append(c);\r\n        }\r\n        Flush();\r\n        return tokens;\r\n    }\r\n\r\n    private static bool IsSide(List<string> tokens, Side side)\r\n        => side == Side.Right ? tokens.Any(t => t is \"r\" or \"right\" or \"rt\") : tokens.Any(t => t is \"l\" or \"left\" or \"lt\");\r\n\r\n    /// <summary>\r\n    /// The deforming hand of a side on rigs with other conventions: a bone named hand/wrist with a\r\n    /// side marker, not a control; deform (\"DEF\") bones and bones with many descendants win.\r\n    /// </summary>\r\n    private static int FindHandByTokens(Skeleton skeleton, Side side)\r\n    {\r\n        var best = -1;\r\n        var bestScore = 0;\r\n        for (var b = 0; b < skeleton.Count; b++)\r\n        {\r\n            var tokens = Tokens(skeleton[b].Name);\r\n            if (!tokens.Any(t => t is \"hand\" or \"wrist\") || !IsSide(tokens, side) || tokens.Any(ControlTokens.Contains))\r\n                continue;\r\n            var descendants = 0;\r\n            for (var c = 0; c < skeleton.Count; c++)\r\n                if (Descends(skeleton, c, b))\r\n                    descendants++;\r\n            if (descendants < 6)\r\n                continue;\r\n            var score = descendants + (tokens.Contains(\"def\") ? 1000 : 0);\r\n            if (score > bestScore)\r\n            {\r\n                bestScore = score;\r\n                best = b;\r\n            }\r\n        }\r\n        return best;\r\n    }\r\n\r\n    /// <summary>\r\n    /// Finger chains of a hand told apart by shape when their names say nothing (\"Bone_R.004\"):\r\n    /// each chain is followed from the hand (through palm bones) up to three joints; the thumb is\r\n    /// the chain whose direction differs most from the others, and the fingers are ordered by\r\n    /// their distance from the thumb (index nearest, pinky farthest).\r\n    /// </summary>\r\n    private static Dictionary<FingerKind, List<int>>? FingersByShape(Skeleton skeleton, int hand)\r\n    {\r\n        bool Usable(int b)\r\n        {\r\n            var tokens = Tokens(skeleton[b].Name);\r\n            return !tokens.Any(t => t is \"end\" or \"nub\" or \"tip\" or \"mch\" or \"org\" or \"ik\" or \"ctrl\" or \"cntrl\" or \"drv\" or \"master\" or \"tweak\" or \"vis\" or \"helper\" or \"twist\");\r\n        }\r\n        List<int> Children(int b) => Enumerable.Range(0, skeleton.Count).Where(c => skeleton[c].ParentIndex == b && Usable(c)).ToList();\r\n\r\n        var roots = new List<int>();\r\n        void Collect(int b, int depth)\r\n        {\r\n            foreach (var c in Children(b))\r\n            {\r\n                var kids = Children(c);\r\n                // A palm/metacarpal bone that fans out: its children are the fingers.\r\n                if (kids.Count >= 2 && depth == 0)\r\n                    Collect(c, depth + 1);\r\n                else if (kids.Count >= 1)\r\n                    roots.Add(c);\r\n            }\r\n        }\r\n        Collect(hand, 0);\r\n        if (roots.Count < 3)\r\n            return null;\r\n\r\n        var raw = roots.Select(r =>\r\n        {\r\n            var chain = new List<int> { r };\r\n            while (chain.Count < 5 && Children(chain[^1]) is { Count: > 0 } kids)\r\n                chain.Add(kids[0]);\r\n            return chain;\r\n        }).Where(c => c.Count >= 2).ToList();\r\n        if (raw.Count < 3)\r\n            return null;\r\n        // Four joints: the first is the metacarpal inside the palm, not a finger joint.\r\n        var chains = raw.Select(c => (c.Count >= 4 ? c.Skip(1) : c).Take(3).ToList()).ToList();\r\n\r\n        var rest = skeleton.RestWorld;\r\n        Vector3 Dir(List<int> c) => SafeNormal(rest[c[^1]].Pos - rest[c[0]].Pos, Vector3.UnitX);\r\n        // The thumb has one bone fewer than the fingers on most rigs; otherwise its base sits\r\n        // nearest the wrist and it points away from the others.\r\n        var counts = raw.Select(c => c.Count).ToList();\r\n        var shortest = counts.Min();\r\n        List<int> thumb;\r\n        if (counts.Count(c => c == shortest) == 1 && counts.Count(c => c > shortest) >= 3)\r\n            thumb = chains[counts.IndexOf(shortest)];\r\n        else\r\n            thumb = chains.OrderBy(c => Vector3.Distance(rest[c[0]].Pos, rest[hand].Pos) - 0.3f * chains.Where(o => o != c).Sum(o => 1f - Vector3.Dot(Dir(c), Dir(o)))).First();\r\n        // Order along the knuckle line: from the knuckle nearest the thumb to the farthest one.\r\n        var others = chains.Where(c => c != thumb).ToList();\r\n        var near = others.OrderBy(c => Vector3.Distance(rest[c[0]].Pos, rest[thumb[0]].Pos)).First();\r\n        var far = others.OrderByDescending(c => Vector3.Distance(rest[c[0]].Pos, rest[thumb[0]].Pos)).First();\r\n        var line = rest[far[0]].Pos - rest[near[0]].Pos;\r\n        var fingers = others.OrderBy(c => Vector3.Dot(rest[c[0]].Pos - rest[near[0]].Pos, line)).ToList();\r\n        var result = new Dictionary<FingerKind, List<int>> { [FingerKind.Thumb] = thumb };\r\n        var kinds = new[] { FingerKind.Index, FingerKind.Middle, FingerKind.Ring, FingerKind.Pinky };\r\n        for (var i = 0; i < fingers.Count && i < kinds.Length; i++)\r\n            result[kinds[i]] = fingers[i];\r\n        return result;\r\n    }\r\n\r\n    private static bool IsHelper(string name)\r\n    {\r\n        var n = name.ToLowerInvariant();\r\n        return n.Contains(\"twist\") || n.Contains(\"helper\") || n.Contains(\"roll\") || n.Contains(\"ikrule\") || n.Contains(\"_ik\") || n.Contains(\"ik_\") || n.Contains(\"hold\") || n.Contains(\"attach\") || n.Contains(\"target\");\r\n    }\r\n\r\n    private static bool Descends(Skeleton skeleton, int bone, int ancestor)\r\n    {\r\n        for (var b = skeleton[bone].ParentIndex; b >= 0; b = skeleton[b].ParentIndex)\r\n            if (b == ancestor)\r\n                return true;\r\n        return false;\r\n    }\r\n\r\n    private static Vector3 SafeNormal(Vector3 v, Vector3 fallback) => v.LengthSquared() > 1e-10f ? Vector3.Normalize(v) : fallback;\r\n\r\n    /// <summary>Palm frame in hand-local space: X = fingers, Z = palm normal, Y completes it.</summary>\r\n    public Quaternion PalmFrame => MathQ.FromAxes(FingerDirection, Vector3.Cross(PalmNormal, FingerDirection));\r\n}\r\n"
        }
    ]
}