Editor/WeaponImporter/Tool/Engine/WeaponBaker.cs
using System.Text.Json;
using System.Text.Json.Nodes;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Generation;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Output;
using WeaponImporter.Core.Setup;
using WeaponImporter.Core.Weapon;
using N = System.Numerics;
namespace WeaponImporter.Tool;
/// <summary>What a bake produced.</summary>
public sealed class BakeResult
{
public bool Success { get; set; }
public string ModelPath { get; set; } = "";
public string PrefabPath { get; set; } = "";
public string ProfilePath { get; set; } = "";
public string CorrectedModelPath { get; set; } = "";
/// <summary>The first-person viewmodel (empty when the file has no first-person arms).</summary>
public string FirstPersonModelPath { get; set; } = "";
/// <summary>The weapon's AnimGraph (empty when there is no idle animation to build it on).</summary>
public string GraphPath { get; set; } = "";
public string SetupPath { get; set; } = "";
public List<string> Files { get; } = new();
public Dictionary<string, IReadOnlyList<string>> Errors { get; } = new();
public List<string> Sequences { get; } = new();
public List<string> Notes { get; } = new();
}
/// <summary>
/// Turns a session into game-ready assets: model DMX + clip DMX, materials, the VMDL with
/// attachments and events, and a prefab carrying the baked <c>WeaponHold</c>. Everything is
/// written under the output folder, registered and compiled; compile errors come back per file.
/// </summary>
public static partial class WeaponBaker
{
public static async Task<BakeResult> BakeAsync( ImportSession session, IProgress<string> progress = null, CancellationToken cancel = default )
{
await EditorThread.SwitchToMainThread();
var result = new BakeResult();
var setup = session.Setup;
var analysis = session.Analysis;
if ( setup is null || analysis is null )
{
result.Notes.Add( "Nothing to bake." );
return result;
}
if ( session.Grip is null )
await session.ResolveGripAsync( progress, cancel );
var folder = session.OutputFolder;
var name = setup.Name;
// Never write over the file being imported (a VMDL already at weapons/<name>/<name>.vmdl).
var source = SourceAbsolute( session.SourcePath );
AssetCompiler.Protected = source;
if ( source is not null && SamePath( AssetCompiler.Absolute( $"{folder}/{name}.vmdl" ), source ) )
{
name = $"{name}_weapon";
result.Notes.Add( $"The source is {folder}/{setup.Name}.vmdl, so the baked weapon is written as {name} beside it (the source is left untouched)." );
}
var generated = $"{folder}/generated";
var modelPath = $"{folder}/{name}.vmdl";
var prefabPath = $"{folder}/{name}.prefab";
result.ModelPath = modelPath;
result.PrefabPath = prefabPath;
result.SetupPath = session.SetupPath;
// 1. Geometry, clips and materials (see WeaponBaker.Generate.cs).
progress?.Report( "Writing model data" );
var assetsRoot = AssetCompiler.AssetsRoot;
ExportedWeapon exported;
try
{
exported = await Task.Run( () => Generate( session, folder, generated, name, assetsRoot ), cancel );
}
catch ( Exception e ) when ( e is not OperationCanceledException )
{
await EditorThread.SwitchToMainThread();
result.Errors["export"] = new[] { e.Message };
result.Notes.Add( e.ToString() );
session.Revalidate( result.Errors );
session.MarkChanged();
return result;
}
await EditorThread.SwitchToMainThread();
result.Files.AddRange( exported.WrittenFiles );
foreach ( var (rel, text) in exported.TextFiles )
{
AssetCompiler.WriteText( rel, text );
result.Files.Add( rel );
}
foreach ( var (rel, bytes) in exported.BinaryFiles )
{
AssetCompiler.WriteBytes( rel, bytes );
result.Files.Add( rel );
}
result.Notes.AddRange( exported.Notes );
// 2. VMDL.
progress?.Report( "Writing model" );
var vmdl = new VmdlBuilder { MeshFile = exported.MeshFile };
vmdl.MaterialRemaps.AddRange( exported.MaterialRemaps );
var sequenceByRole = new Dictionary<AnimationRole, string>();
foreach ( var clip in exported.Clips )
{
var roles = setup.WeaponAnimations.Where( kv => kv.Value.Clip == clip.Clip ).Select( kv => kv.Key ).ToList();
foreach ( var r in roles )
sequenceByRole[r] = clip.Sequence;
var events = setup.Events.Where( e => roles.Contains( e.Role ) ).Select( e => (WeaponEvent.EngineName( e.Kind ), e.Time) ).ToList();
vmdl.Animations.Add( new VmdlAnimation
{
Name = clip.Sequence,
File = clip.File,
Looping = roles.Any( AnimationRoles.Loops ),
FrameCount = clip.FrameCount,
Fps = clip.Fps,
Events = events,
} );
result.Sequences.Add( clip.Sequence );
}
foreach ( var part in setup.Parts.Where( p => p.Bone.Length > 0 ) )
vmdl.KeepBones.Add( EngineNames.Bone( part.Bone ) );
// AnimGraph for the weapon's own sequences (first-person code drives it with the usual parameters).
if ( exported.Clips.Count > 0 && sequenceByRole.ContainsKey( AnimationRole.Idle ) )
{
var graphClips = sequenceByRole.Select( kv => new Core.Graph.GraphClip( kv.Key, kv.Value, AnimationRoles.Loops( kv.Key ), ActionTiming.Seconds( setup, session.Analysis?.Asset, kv.Key ) ) ).ToList();
var graphPath = $"{folder}/{name}.vanmgrph";
WriteEditable( result, setup, graphPath, Core.Graph.WeaponGraphGenerator.Generate( name, graphClips, null, out _, modelPath ) );
result.Files.Add( graphPath );
result.GraphPath = graphPath;
vmdl.AnimGraph = graphPath;
}
AddAttachments( vmdl, session, exported.Rig );
AssetCompiler.WriteText( modelPath, vmdl.Build() );
result.Files.Add( modelPath );
// 2b. First-person viewmodel: the file's own arms, weapon and camera, as authored.
if ( exported.FirstPerson is { } fp )
{
var fpName = $"{name}_fp";
var fpModelPath = $"{folder}/{fpName}.vmdl";
var fpVmdl = new VmdlBuilder { MeshFile = exported.FirstPersonMeshFile };
fpVmdl.MaterialRemaps.AddRange( exported.FirstPersonMaterialRemaps );
var fpSequences = new Dictionary<AnimationRole, string>();
foreach ( var clip in exported.FirstPersonClips )
{
var roles = setup.WeaponAnimations.Where( kv => kv.Value.Clip == clip.Clip ).Select( kv => kv.Key ).ToList();
foreach ( var r in roles )
fpSequences[r] = clip.Sequence;
var events = setup.Events.Where( e => roles.Contains( e.Role ) ).Select( e => (WeaponEvent.EngineName( e.Kind ), e.Time) ).ToList();
fpVmdl.Animations.Add( new VmdlAnimation { Name = clip.Sequence, File = clip.File, Looping = roles.Any( AnimationRoles.Loops ), FrameCount = clip.FrameCount, Fps = clip.Fps, Events = events } );
}
foreach ( var (point, p) in new[] { ("muzzle", setup.Muzzle), ("eject", setup.Eject) } )
if ( p is not null && fp.Asset.Skeleton.IndexOf( p.Bone ) >= 0 )
fpVmdl.Attachments.Add( new VmdlAttachment( point, EngineNames.Bone( p.Bone ), V.Of( p.Position ) * setup.Scale, V.Q( p.Rotation ) ) );
if ( fpSequences.ContainsKey( AnimationRole.Idle ) )
{
var fpClips = fpSequences.Select( kv => new Core.Graph.GraphClip( kv.Key, kv.Value, AnimationRoles.Loops( kv.Key ), ActionTiming.Seconds( setup, session.Analysis?.Asset, kv.Key ) ) ).ToList();
var fpGraph = $"{folder}/{fpName}.vanmgrph";
var camera = fp.CameraBone.Length > 0 ? EngineNames.Bone( fp.CameraBone ) : null;
WriteEditable( result, setup, fpGraph, Core.Graph.WeaponGraphGenerator.Generate( fpName, fpClips, camera, out _, fpModelPath ) );
result.Files.Add( fpGraph );
fpVmdl.AnimGraph = fpGraph;
}
AssetCompiler.WriteText( fpModelPath, fpVmdl.Build() );
result.Files.Add( fpModelPath );
result.FirstPersonModelPath = fpModelPath;
result.Notes.Add( $"First-person viewmodel: {fpName}, seen from {fp.EyeSource} (fine-tune with Offset on Weapon Viewmodel)." );
}
// 3a. Corrected third-person animations (new clips; the character's own stay untouched).
if ( setup.BakeCorrectedAnimations && session.Analysis is not null )
{
try
{
var corrected = await CorrectedAnimationBaker.BakeAsync( session, folder, name, progress, cancel );
result.Files.AddRange( corrected.Files );
result.Notes.AddRange( corrected.Errors );
if ( corrected.Clips.Count > 0 )
{
setup.CorrectedModel = corrected.ModelPath;
setup.CorrectedClips = corrected.Clips.ToDictionary( c => c.Role, c => c.Sequence );
result.CorrectedModelPath = corrected.ModelPath;
}
}
catch ( Exception e ) when ( e is not OperationCanceledException )
{
result.Errors["corrected animations"] = new[] { e.Message };
}
}
// 3. Grip profile (reusable) and the prefab holding the weapon with it.
progress?.Report( "Writing grip profile and prefab" );
var profilePath = $"{folder}/{name}.wgrip";
AssetCompiler.WriteText( profilePath, BuildProfile( session, sequenceByRole ) );
result.Files.Add( profilePath );
result.ProfilePath = profilePath;
WriteEditable( result, setup, prefabPath, BuildPrefab( session, modelPath, prefabPath, sequenceByRole, profilePath, result.FirstPersonModelPath, exported.FirstPerson ) );
result.Files.Add( prefabPath );
// 4. Setup (so reimports and templates keep every choice).
session.Save();
result.Files.Add( session.SetupPath );
// 5. Register + compile.
progress?.Report( "Compiling" );
await AssetCompiler.RegisterAsync( result.Files );
await EditorThread.Delay( 1500, cancel );
// The model references the graph, so the graph compiles first.
if ( result.GraphPath.Length > 0 )
{
var g = await AssetCompiler.CompileAsync( result.GraphPath, 60f, cancel );
if ( !g.Success )
result.Errors[result.GraphPath] = g.Errors;
}
var compile = await AssetCompiler.CompileAsync( modelPath, 240f, cancel );
if ( !compile.Success )
result.Errors[modelPath] = compile.Errors;
foreach ( var vmat in result.Files.Where( f => f.EndsWith( ".vmat" ) ) )
{
var c = await AssetCompiler.CompileAsync( vmat, 60f, cancel );
if ( !c.Success )
result.Errors[vmat] = c.Errors;
}
if ( result.CorrectedModelPath.Length > 0 )
{
var c = await AssetCompiler.CompileAsync( result.CorrectedModelPath, 240f, cancel );
if ( !c.Success )
result.Errors[result.CorrectedModelPath] = c.Errors;
else if ( await AssetCompiler.LoadModelAsync( result.CorrectedModelPath, setup.CorrectedClips.Values.ToList() ) is not { IsError: false } corrected )
result.Errors[result.CorrectedModelPath] = new[] { "the corrected animations could not be loaded" };
else
result.Notes.Add( $"Corrected animations: {string.Join( ", ", corrected.AnimationNames.Where( setup.CorrectedClips.Values.Contains ) )}" );
}
if ( result.FirstPersonModelPath.Length > 0 )
{
var fpGraph = result.FirstPersonModelPath[..^".vmdl".Length] + ".vanmgrph";
if ( result.Files.Contains( fpGraph ) )
{
var g = await AssetCompiler.CompileAsync( fpGraph, 60f, cancel );
if ( !g.Success )
result.Errors[fpGraph] = g.Errors;
}
var fpCompile = await AssetCompiler.CompileAsync( result.FirstPersonModelPath, 240f, cancel );
var fpSequences = exported.FirstPersonClips.Select( c => c.Sequence ).Distinct().ToList();
if ( !fpCompile.Success )
result.Errors[result.FirstPersonModelPath] = fpCompile.Errors;
else if ( await AssetCompiler.LoadModelAsync( result.FirstPersonModelPath, fpSequences ) is not { IsError: false } fpModel || !fpSequences.All( fpModel.AnimationNames.Contains ) )
result.Errors[result.FirstPersonModelPath] = new[] { "the compiled first-person model is missing animations" };
}
IReadOnlyCollection<string> compiledSequences = null;
if ( compile.Success )
{
var model = await AssetCompiler.LoadModelAsync( modelPath, result.Sequences );
if ( model is null || model.IsError )
result.Errors[modelPath] = new[] { "compiled model could not be loaded" };
else
compiledSequences = model.AnimationNames.ToList();
}
session.Revalidate( result.Errors, compiledSequences );
session.MarkChanged();
result.Success = result.Errors.Count == 0;
progress?.Report( result.Success ? "Baked" : "Bake finished with errors" );
return result;
}
/// <summary>
/// Writes a file users may open and edit (animgraphs, the prefab). When the copy on disk no
/// longer matches what the last bake wrote, it was edited by hand: it is kept and noted
/// (delete it to have it generated again).
/// </summary>
private static void WriteEditable( BakeResult result, WeaponSetup setup, string relative, string text )
{
setup.GeneratedHashes ??= new Dictionary<string, string>();
var abs = AssetCompiler.Absolute( relative );
if ( System.IO.File.Exists( abs ) && setup.GeneratedHashes.TryGetValue( relative, out var written ) && Fingerprint( System.IO.File.ReadAllText( abs ) ) != written )
{
result.Notes.Add( $"Kept your edited {relative} (delete it to have it generated again)." );
return;
}
AssetCompiler.WriteText( relative, text );
setup.GeneratedHashes[relative] = Fingerprint( text );
}
private static string Fingerprint( string text )
=> Convert.ToHexString( System.Security.Cryptography.SHA256.HashData( System.Text.Encoding.UTF8.GetBytes( text.Replace( "\r\n", "\n" ) ) ), 0, 12 );
/// <summary>Absolute path of the imported file (source of a compiled model), or null.</summary>
private static string SourceAbsolute( string path )
{
if ( string.IsNullOrEmpty( path ) )
return null;
var abs = System.IO.Path.IsPathRooted( path ) ? System.IO.Path.GetFullPath( path ) : AssetCompiler.Absolute( path );
return abs.EndsWith( "_c", StringComparison.OrdinalIgnoreCase ) ? abs[..^2] : abs;
}
private static bool SamePath( string a, string b ) => string.Equals( System.IO.Path.GetFullPath( a ), System.IO.Path.GetFullPath( b ), StringComparison.OrdinalIgnoreCase );
private static void AddAttachments( VmdlBuilder vmdl, ImportSession session, ExportRig rig )
{
var setup = session.Setup;
var source = session.Analysis.Asset.Skeleton;
var export = rig.Asset.Skeleton;
var root = export.IndexOf( rig.RootName );
var rootWorld = export.RestWorld[root];
// Attach to the named bone when it survived the export, else re-express on the root.
void Place( string name, string bone, XForm local )
{
var ei = export.IndexOf( bone );
if ( ei >= 0 )
{
vmdl.Attachments.Add( new VmdlAttachment( name, EngineNames.Bone( bone ), local.Pos, local.Rot ) );
return;
}
var si = source.IndexOf( bone );
var world = si >= 0 ? XForm.Compose( source.RestWorld[si], local ) : local;
var onRoot = XForm.ToLocal( rootWorld, world );
vmdl.Attachments.Add( new VmdlAttachment( name, EngineNames.Bone( rig.RootName ), onRoot.Pos, onRoot.Rot ) );
}
void Point( string name, PointSetup p )
{
if ( p is null )
return;
// Stored in source units; generated models are written in canonical inches.
Place( name, p.Bone, new XForm( V.Of( p.Position ) * setup.Scale, V.Q( p.Rotation ) ) );
}
Point( "muzzle", setup.Muzzle );
Point( "eject", setup.Eject );
// Hand anchors in weapon space, for games that place hands themselves.
if ( session.Grip is { } g )
{
var r = XForm.ToLocal( rootWorld, g.Right.Wrist );
vmdl.Attachments.Add( new VmdlAttachment( "hand_r", EngineNames.Bone( rig.RootName ), r.Pos, r.Rot ) );
if ( g.Left is not null )
{
var l = XForm.ToLocal( rootWorld, g.Left.Wrist );
vmdl.Attachments.Add( new VmdlAttachment( "hand_l", EngineNames.Bone( rig.RootName ), l.Pos, l.Rot ) );
}
}
}
/// <summary>
/// The grip as a <c>WeaponGripProfile</c> resource (JSON of its properties): the same values
/// the prefab's Weapon Hold carries, reusable on other holds and characters.
/// </summary>
public static string BuildProfile( ImportSession session, Dictionary<AnimationRole, string> sequenceByRole = null )
{
var v = HoldValues( session, sequenceByRole );
var setup = session.Setup;
string S( string key ) => v[key]?.GetValue<string>() ?? "";
var trigger = session.Analysis?.Part( PartKind.Trigger )?.Center ?? N.Vector3.Zero;
var firstPerson = new JsonObject();
foreach ( var (role, seq) in sequenceByRole ?? SequencesByRole( setup ) )
firstPerson[role.ToString().ToLowerInvariant()] = seq;
string V3( N.Vector3 p ) => $"{p.X.ToString( "0.####", System.Globalization.CultureInfo.InvariantCulture )},{p.Y.ToString( "0.####", System.Globalization.CultureInfo.InvariantCulture )},{p.Z.ToString( "0.####", System.Globalization.CultureInfo.InvariantCulture )}";
var profile = new JsonObject
{
["PrimaryHand"] = "R",
["PrimaryGripTransform"] = S( "RightHand" ),
["PrimaryFingerPose"] = S( "RightFingers" ),
["SecondaryGripTransform"] = S( "LeftHand" ),
["SecondaryFingerPose"] = S( "LeftFingers" ),
["TriggerTarget"] = V3( trigger ),
["PrimaryElbowHint"] = S( "RightElbowHint" ),
["SecondaryElbowHint"] = S( "LeftElbowHint" ),
["TwoHanded"] = setup.UseSupportHand,
["WeaponOffset"] = S( "WeaponInHold" ),
["HoldBone"] = S( "HoldBone" ),
["Contacts"] = S( "Contacts" ),
["Actions"] = S( "Actions" ),
["Smoothing"] = setup.Ik.Smoothing,
["ReachSlack"] = setup.Ik.ReachSlack,
["TransitionSeconds"] = 0.15f,
["HoldType"] = setup.EffectiveHoldType,
["Character"] = session.CharacterModel,
["CharacterActions"] = S( "CharacterActions" ),
["FirstPersonSequences"] = firstPerson.ToJsonString(),
["FirstPersonFieldOfView"] = 70f,
};
return profile.ToJsonString( new JsonSerializerOptions { WriteIndented = true } );
}
private static string BuildPrefab( ImportSession session, string modelPath, string prefabPath, Dictionary<AnimationRole, string> sequenceByRole, string profilePath, string firstPersonModel = "", FirstPersonRig firstPerson = null )
{
var setup = session.Setup;
var values = HoldValues( session, sequenceByRole ).ToDictionary( kv => kv.Key, kv => kv.Value );
values["Profile"] = profilePath;
var hold = new PrefabComponent( "WeaponImporter.WeaponHold", values );
var root = new PrefabObject { Name = setup.Name, Tags = "weapon" };
root.Components.Add( PrefabBuilder.ModelRenderer( modelPath ) );
root.Components.Add( hold );
if ( !string.IsNullOrEmpty( firstPersonModel ) && firstPerson is not null )
{
// The first-person view: shown only to the player holding the weapon, in sync with the hold.
string Q( System.Numerics.Quaternion q ) => string.Join( ",", new[] { q.X, q.Y, q.Z, q.W }.Select( v => v.ToString( "0.######", System.Globalization.CultureInfo.InvariantCulture ) ) );
string P( System.Numerics.Vector3 v ) => string.Join( ",", new[] { v.X, v.Y, v.Z }.Select( f => f.ToString( "0.####", System.Globalization.CultureInfo.InvariantCulture ) ) );
var viewmodel = new PrefabObject { Name = "viewmodel", Tags = "viewmodel" };
viewmodel.Components.Add( PrefabBuilder.ModelRenderer( firstPersonModel ) );
viewmodel.Components.Add( new PrefabComponent( "WeaponImporter.WeaponViewmodel", new Dictionary<string, JsonNode>
{
["__enabled"] = true,
["FirstPerson"] = true,
["HideWorldWeapon"] = true,
["CameraBone"] = firstPerson.CameraBone.Length > 0 ? EngineNames.Bone( firstPerson.CameraBone ) : "",
["CameraAxes"] = Q( firstPerson.CameraAxes ),
["EyeInModel"] = new JsonObject { ["Position"] = P( firstPerson.Eye.Pos ), ["Rotation"] = Q( firstPerson.Eye.Rot ), ["Scale"] = "1,1,1" },
["Offset"] = new JsonObject { ["Position"] = "0,0,0", ["Rotation"] = "0,0,0,1", ["Scale"] = "1,1,1" },
} ) );
root.Children.Add( viewmodel );
}
return PrefabBuilder.Build( prefabPath, root );
}
/// <summary>Sequence name the baked model uses for each mapped role.</summary>
public static Dictionary<AnimationRole, string> SequencesByRole( WeaponSetup setup )
=> setup.WeaponAnimations.Where( kv => !string.IsNullOrEmpty( kv.Value.Clip ) ).ToDictionary( kv => kv.Key, kv => EngineNames.Sequence( kv.Value.Clip ) );
/// <summary>
/// Everything <c>WeaponHold</c> needs, as property values. The prefab and the editor preview
/// both use this, so the preview behaves exactly like the game.
/// </summary>
public static Dictionary<string, JsonNode> HoldValues( ImportSession session, Dictionary<AnimationRole, string> sequenceByRole = null )
{
var setup = session.Setup;
sequenceByRole ??= SequencesByRole( setup );
var baked = setup.Baked ?? new BakedGrip();
string Tx( float[] a ) => a is { Length: 7 } ? string.Join( ",", a.Select( v => v.ToString( "0.######", System.Globalization.CultureInfo.InvariantCulture ) ) ) : "";
string Vec( float[] a ) => a is { Length: 3 } ? string.Join( ",", a.Select( v => v.ToString( "0.####", System.Globalization.CultureInfo.InvariantCulture ) ) ) : "0,0,0";
string Fingers( Dictionary<string, float[]> f ) => string.Join( ";", f.Select( kv => kv.Key + "=" + string.Join( ",", kv.Value.Select( v => v.ToString( "0.######", System.Globalization.CultureInfo.InvariantCulture ) ) ) ) );
var contacts = new JsonObject();
foreach ( var (role, track) in setup.Contacts )
{
var keys = new JsonArray();
foreach ( var k in track.Keys.OrderBy( k => k.Time ) )
{
var key = new JsonObject { ["hand"] = k.Hand == Core.Hands.Side.Left ? "L" : "R", ["t"] = Math.Round( k.Time, 4 ), ["lock"] = k.State == ContactState.Locked, ["blend"] = Math.Round( k.Blend, 3 ) };
if ( !string.IsNullOrEmpty( k.Follow ) ) key["follow"] = EngineNames.Bone( k.Follow );
if ( k.Offset is { Length: 7 } ) key["offset"] = new JsonArray( k.Offset.Select( v => (JsonNode)Math.Round( v, 5 ) ).ToArray() );
keys.Add( key );
}
contacts[role.ToString().ToLowerInvariant()] = keys;
}
var actions = new JsonObject();
foreach ( var role in AnimationRoles.All.Prepend( AnimationRole.Idle ).Distinct() )
{
sequenceByRole.TryGetValue( role, out var seq );
var seconds = ActionTiming.Seconds( setup, session.Analysis?.Asset, role );
var trigger = AnimationRoles.GraphTrigger( role ) ?? "";
if ( seq is null && trigger.Length == 0 && role != AnimationRole.Idle && !setup.Contacts.ContainsKey( role ) )
continue;
actions[role.ToString().ToLowerInvariant()] = new JsonObject { ["seconds"] = Math.Round( MathF.Max( seconds, 0.05f ), 3 ), ["sequence"] = seq ?? "", ["trigger"] = role == AnimationRole.Idle ? "" : trigger };
}
// Replacement character animations (upper-body overlay in WeaponHold).
var characterActions = new JsonObject();
foreach ( var (role, tp) in setup.ThirdPerson )
if ( tp.Source == CharacterAnimationSource.Sequence && !string.IsNullOrEmpty( tp.Sequence ) )
characterActions[role.ToString().ToLowerInvariant()] = new JsonObject { ["model"] = tp.Model ?? "", ["sequence"] = tp.Sequence, ["blend"] = 0.2 };
// The baked corrected clips, when chosen, for actions the user didn't give their own animation.
if ( setup.UseCorrectedAnimations && !string.IsNullOrEmpty( setup.CorrectedModel ) )
foreach ( var (role, sequence) in setup.CorrectedClips )
{
var key = role.ToString().ToLowerInvariant();
if ( role != AnimationRole.Idle && !characterActions.ContainsKey( key ) )
characterActions[key] = new JsonObject { ["model"] = setup.CorrectedModel, ["sequence"] = sequence, ["blend"] = 0.15 };
}
return new Dictionary<string, JsonNode>
{
["__enabled"] = true,
["CharacterActions"] = characterActions.Count > 0 ? characterActions.ToJsonString() : "",
["HoldBone"] = baked.HoldBone,
["HoldType"] = setup.EffectiveHoldType,
["WeaponInHold"] = Tx( baked.WeaponInHold ),
["RightHand"] = Tx( baked.RightHand ),
["LeftHand"] = setup.UseSupportHand && baked.LeftHand is not null ? Tx( baked.LeftHand ) : "",
["RightFingers"] = Fingers( baked.RightFingers ),
["LeftFingers"] = Fingers( baked.LeftFingers ),
["Contacts"] = contacts.ToJsonString(),
["Actions"] = actions.ToJsonString(),
["Smoothing"] = setup.Ik.Smoothing,
["ReachSlack"] = setup.Ik.ReachSlack,
["SupportHand"] = setup.UseSupportHand,
["RightElbowHint"] = Vec( baked.RightElbow ),
["LeftElbowHint"] = setup.UseSupportHand ? Vec( baked.LeftElbow ) : "0,0,0",
};
}
/// <summary>Applies <see cref="HoldValues"/> to a live component (editor preview).</summary>
public static void ConfigureHold( WeaponImporter.WeaponHold hold, ImportSession session )
{
var v = HoldValues( session );
string S( string key ) => v[key]?.GetValue<string>() ?? "";
hold.HoldBone = S( "HoldBone" );
hold.HoldType = v["HoldType"]!.GetValue<int>();
hold.WeaponInHold = S( "WeaponInHold" );
hold.RightHand = S( "RightHand" );
hold.LeftHand = S( "LeftHand" );
hold.RightFingers = S( "RightFingers" );
hold.LeftFingers = S( "LeftFingers" );
hold.Contacts = S( "Contacts" );
hold.Actions = S( "Actions" );
hold.CharacterActions = S( "CharacterActions" );
hold.Smoothing = v["Smoothing"]!.GetValue<float>();
hold.ReachSlack = v["ReachSlack"]!.GetValue<float>();
hold.SupportHand = v["SupportHand"]!.GetValue<bool>();
hold.RightElbowHint = Vector3.Parse( S( "RightElbowHint" ) );
hold.LeftElbowHint = Vector3.Parse( S( "LeftElbowHint" ) );
}
}
/// <summary>Files and references produced by the geometry/material export step.</summary>
public sealed class ExportedWeapon
{
public ExportRig Rig { get; set; }
public string MeshFile { get; set; } = "";
/// <summary>Files the export already wrote to disk (materials, textures).</summary>
public List<string> WrittenFiles { get; } = new();
public List<(string Path, string Text)> TextFiles { get; } = new();
public List<(string Path, byte[] Bytes)> BinaryFiles { get; } = new();
public List<(string From, string To)> MaterialRemaps { get; } = new();
public List<ExportedClip> Clips { get; } = new();
public List<string> Notes { get; } = new();
/// <summary>First-person viewmodel (null when not exported).</summary>
public FirstPersonRig FirstPerson { get; set; }
public string FirstPersonMeshFile { get; set; } = "";
public List<(string From, string To)> FirstPersonMaterialRemaps { get; } = new();
public List<ExportedClip> FirstPersonClips { get; } = new();
}
public sealed record ExportedClip( string Clip, string Sequence, string File, int FrameCount, float Fps );