s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=notpointless.chomnr_weapon_importer&take=20
Showing code results for query:
*
(136 total matches found)
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();
}
}
}
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();
}
}
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 };
}
}
}
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 );
}
}
}
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();
}
}
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 );
}
}
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;
}
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")]
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;
}
}
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&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;
}
}
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 );
}
}
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();
}
}
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 );
}
}
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();
}
}
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 );
}
}
}
}
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);
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;
}
}
}
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;
}
}
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&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&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&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"
}
]
}