Editor/HumanoidRetargeter/SmartPortModels.cs
#nullable enable annotations
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using HumanoidRetargeter.Target;
using Sandbox;
namespace HumanoidRetargeter.Editor;
internal static class SmartPortModels
{
internal static string Check( Asset source, Asset target )
=> CheckCore( source, target, false );
internal static string CheckExtended( Asset source, Asset target )
=> CheckCore( source, target, true );
static string CheckCore( Asset source, Asset target, bool extend )
{
if ( source is null || target is null ) return "Choose an animation source and a target character.";
var reference = Model.Load( source.Path );
var custom = Model.Load( target.Path );
if ( reference is null || reference.IsError || custom is null || custom.IsError ) return "Both models must load successfully.";
if ( reference.AnimationCount == 0 ) return "The source has no animations.";
var graphOwner = extend ? custom : reference;
if ( graphOwner.AnimGraph is null || graphOwner.AnimGraph.IsError )
return extend ? "The target needs a working animation graph to extend." : "The source has no working animation graph.";
if ( TargetPickers.FromModelAsset( target, out var error ) is null ) return error;
if ( ArmatureError( custom, reference ) is null ) return null;
try { _ = new SmartPortRig( TargetPickers.SkeletonFromModel( reference ), TargetPickers.SkeletonFromModel( custom ) ); }
catch ( ArgumentException e ) { return e.Message; }
return null;
}
static string ArmatureError( Model target, Model source )
{
var error = SmartPortSetup.CompatibilityError( TargetPickers.SkeletonFromModel( target ), TargetPickers.SkeletonFromModel( source ) );
if ( error is not null ) return error;
// XForm intentionally has no scale; compare it on the authoritative engine bones.
var bones = target.Bones.AllBones.ToDictionary( b => b.Name, StringComparer.Ordinal );
foreach ( var bone in source.Bones.AllBones )
{
if ( !bones.TryGetValue( bone.Name, out var other ) ) return "Target is missing source bone: " + bone.Name;
var difference = bone.LocalTransform.Scale.Distance( other.LocalTransform.Scale );
if ( !float.IsFinite( difference ) || difference > .0001f ) return "Bone has a different bind scale: " + bone.Name;
}
return null;
}
internal static Task<EditorPipeline.WriteResult> CreateAsync( Asset source, Asset target, string folder, string name,
Action<string> progress = null, CancellationToken token = default )
=> CreateCoreAsync( source, target, folder, name, progress, token, false );
internal static Task<EditorPipeline.WriteResult> CreateExtendedAsync( Asset source, Asset target, string folder, string name,
Action<string> progress = null, CancellationToken token = default )
=> CreateCoreAsync( source, target, folder, name, progress, token, true );
static async Task<EditorPipeline.WriteResult> CreateCoreAsync( Asset source, Asset target, string folder, string name,
Action<string> progress, CancellationToken token, bool extend )
{
await EditorPipeline.SwitchToMainThread();
var error = CheckCore( source, target, extend );
if ( error is not null ) throw new InvalidOperationException( error );
if ( string.IsNullOrWhiteSpace( name ) || name.IndexOfAny( Path.GetInvalidFileNameChars() ) >= 0 || name.Contains( '/' ) || name.Contains( '\\' ) || name is "." or ".." )
throw new InvalidOperationException( "Use a plain filename for the new model." );
folder = folder.Trim().Replace( '\\', '/' ).TrimEnd( '/' );
name = name.EndsWith( ".vmdl", StringComparison.OrdinalIgnoreCase ) ? name[..^5] : name;
if ( string.IsNullOrWhiteSpace( folder ) || string.IsNullOrWhiteSpace( name ) ) throw new InvalidOperationException( "Specify an output folder and model name." );
var modelPath = folder + "/" + name + ".vmdl";
var modelFile = CitizenAnimationModels.ProjectFile( modelPath );
var dataFolder = folder + "/" + name + "_smart_port";
var dataRoot = CitizenAnimationModels.ProjectFile( dataFolder );
var graphPath = dataFolder + "/graphs/" + name + ".vanmgrph";
if ( File.Exists( modelFile ) || File.Exists( modelFile + "_c" ) || Directory.Exists( dataRoot ) )
throw new InvalidOperationException( "That Smart Port output already exists. Choose another name; existing files are never overwritten." );
var sourceText = ReadSource( source );
var targetText = ReadSource( target );
var reference = Model.Load( source.Path );
var custom = Model.Load( target.Path );
var needsRetargeting = ArmatureError( custom, reference ) is not null;
var graphAsset = AssetSystem.FindByPath( (extend ? custom : reference).AnimGraph.Name );
if ( graphAsset is null ) throw new InvalidOperationException( "Animgraph asset could not be resolved." );
var graphText = ReadSource( graphAsset );
if ( graphText is null ) graphText = await SmartPortDecompiler.RecoverAsync( graphAsset, dataFolder + "/graph_source", token );
var rig = needsRetargeting ? new SmartPortRig( TargetPickers.SkeletonFromModel( reference ), TargetPickers.SkeletonFromModel( custom ),
!extend ? SmartPortIkTargets.Read( source.GetCompiledFile( true ), graphText ) : null,
!extend ? SmartPortAttachments.BoneNames( source.GetCompiledFile( true ) ) : null ) : null;
SmartPortClip[] clips = null;
if ( extend )
{
var compiled = source.GetCompiledFile( true );
var probeRig = rig ?? new SmartPortRig( TargetPickers.SkeletonFromModel( reference ), TargetPickers.SkeletonFromModel( custom ) );
clips = await Task.Run( () => SmartPortAnimationExport.ReadClips( compiled, probeRig ), token );
}
var recoverModels = extend || rig is not null || sourceText is null || targetText is null;
if ( !recoverModels )
{
// Different source units/modifiers need compiled-space recovery, even when the
// resulting compiled skeletons match. Never double-scale the imported clips.
try { _ = SmartPortSetup.Apply( targetText, sourceText, graphPath ); }
catch ( InvalidOperationException ) { recoverModels = true; }
}
token.ThrowIfCancellationRequested();
Directory.CreateDirectory( dataRoot );
if ( recoverModels )
{
progress?.Invoke( "Recovering source ModelDoc and animations…" );
sourceText = await SmartPortDecompiler.RecoverAsync( source, dataFolder + "/source", token, rig );
progress?.Invoke( "Recovering the target mesh and armature…" );
targetText = await SmartPortDecompiler.RecoverAsync( target, dataFolder + "/target", token );
}
var vmdl = rig is null ? SmartPortSetup.Apply( targetText, sourceText, graphPath )
: SmartPortSetup.ApplyRetargeted( targetText, sourceText, graphPath, rig );
// Calibrate the merged list so newly copied sockets get the same correction as fitted ones.
if ( rig is not null && !extend ) vmdl = SmartPortAttachments.Align( vmdl, sourceText, rig );
var expectedAnimations = reference.AnimationNames.ToArray();
if ( extend )
{
vmdl = SmartPortExtension.MergeModel( targetText, vmdl, graphPath, out var names );
var playable = clips.Where( c => !c.Hidden ).Select( c => c with { Name = names[c.Name] } ).ToArray();
graphText = SmartPortExtension.ExtendGraph( graphText, modelPath, playable, out var parameter );
expectedAnimations = custom.AnimationNames.Concat( names.Values ).ToArray();
var guide = "Smart Port added clips\n\nThe original graph is the default (0). Select a clip with:\n"
+ $"renderer.Set(\"{parameter}\", 1);\nReturn to the original graph with renderer.Set(\"{parameter}\", 0);\n"
+ "Non-looping clips hold their final frame until you select 0. Set 0 before replaying the same clip.\n"
+ "Additive clips layer over the original graph. Hidden/world-space clips are retained in the model for manual graph editing.\n\n"
+ string.Join( "\n", playable.Select( (c, i) => $"{i + 1}: {c.Name} ({(c.Additive ? "additive, " : "")}{(c.Looping ? "loop" : "one-shot")})" ) );
await File.WriteAllTextAsync( Path.Combine( dataRoot, "clips.txt" ), guide, token );
}
else if ( rig is not null ) graphText = SmartPortSetup.Rebase( graphText, rig.BoneNames );
graphText = StockAnimationGraph.CopyForModel( graphText, modelPath );
var graphFile = CitizenAnimationModels.ProjectFile( graphPath );
Directory.CreateDirectory( Path.GetDirectoryName( graphFile ) );
await File.WriteAllTextAsync( graphFile, graphText, token );
token.ThrowIfCancellationRequested();
progress?.Invoke( "Compiling and verifying the ported model and graph…" );
var result = await EditorPipeline.WriteAndCompileAsync( new RetargetBatchResult { StandaloneVmdl = vmdl }, folder,
standaloneVmdlName: name, compileTimeoutSeconds: EditorPipeline.MeshCompileTimeoutSeconds,
allowAnimationSetupOnly: true, additionalAssetPaths: Directory.GetFiles( dataRoot, "*", SearchOption.AllDirectories ) );
await EditorPipeline.SwitchToMainThread();
CitizenAnimationModels.VerifyGraph( result, graphPath );
if ( result.Compiled )
{
var output = Model.Load( result.VmdlAsset.Path );
var missing = expectedAnimations.Except( output.AnimationNames ).ToArray();
if ( missing.Length > 0 ) result.Errors.Add( "Recovery omitted sequences: " + string.Join( ", ", missing ) );
var changed = ArmatureError( output, Model.Load( target.Path ) );
if ( changed is not null ) result.Errors.Add( "Compiled target armature changed: " + changed );
result.Compiled = result.Errors.Count == 0;
}
return result;
}
static string ReadSource( Asset asset )
{
var path = asset.GetSourceFile( true );
return !string.IsNullOrEmpty( path ) && File.Exists( path ) ? File.ReadAllText( path ) : null;
}
}