Editor/HumanoidHandRetargeter/HandEditorPipeline.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using HumanoidHandRetargeter.Calibration;
using HumanoidHandRetargeter.Formats.Dmx;
using HumanoidHandRetargeter.Formats.Fbx;
using HumanoidHandRetargeter.Mapping;
using HumanoidHandRetargeter.Maths;
using HumanoidHandRetargeter.Retargeting;
using HumanoidHandRetargeter.Skeleton;
using HumanoidHandRetargeter.Target;
using Skel = HumanoidHandRetargeter.Skeleton.Skeleton;
using Vec = System.Numerics.Vector3;
using Quat = System.Numerics.Quaternion;

namespace HumanoidHandRetargeter.Editor;

public sealed class HandSource
{
    public required string Path { get; init; }
    public required SourceScene Scene { get; init; }
    public required HandMappingResult Mapping { get; set; }
    public string? ModelPath { get; init; }
    public IReadOnlyDictionary<HandSide, Quat>? PalmFrames { get; set; }
}

public sealed class HandTarget
{
    public IReadOnlyList<string> ImportNotes { get; init; } = Array.Empty<string>();
    public required string ModelPath { get; init; }
    public required Skel Skeleton { get; init; }
    public required HandMappingResult Mapping { get; set; }
    public IReadOnlyDictionary<HandSide, Quat>? PalmFrames { get; set; }
}

public sealed record HandExportResult(string ModelPath,IReadOnlyList<string> Changes,string? BackupPath,IReadOnlyList<string>? WeaponPrefabs=null);

public sealed record HandBakedClip(HandSource Source, Clip Original, Clip Baked, IReadOnlyList<string> Notes,Vec? WeaponOffset=null,HandMotionOptions? MotionOptions=null);

/// <summary>Editor adapter over the pure importer, shared bake and transactional setup.
/// Engine calls use the same main-thread dispatch convention as the audited EditorPipeline.</summary>
public static class HandEditorPipeline
{
    public const string HumanArms = "models/first_person/v_first_person_arms_human.vmdl";
    public const string CitizenArms = "models/first_person/v_first_person_arms_citizen.vmdl";
    public static string Assets => Project.Current?.GetAssetsPath() ?? throw new InvalidOperationException("Open an s&box project first.");
    public static MainThreadAwaitable MainThread() => default;
    public readonly struct MainThreadAwaitable : INotifyCompletion
    {
        public MainThreadAwaitable GetAwaiter() => this;
        public bool IsCompleted => ThreadSafe.IsMainThread;
        public void OnCompleted(Action continuation) => Sandbox.MainThread.Queue(continuation);
        public void GetResult() { }
    }

    public static Transform ToEngine(XForm value)
        => new(new Vector3(value.Pos.X, value.Pos.Y, value.Pos.Z) / 2.54f, new Rotation(value.Rot.X,value.Rot.Y,value.Rot.Z,value.Rot.W));
    public static XForm FromEngine(Transform value)
        => new(new Vec(value.Position.x,value.Position.y,value.Position.z)*2.54f, new Quat(value.Rotation.x,value.Rotation.y,value.Rotation.z,value.Rotation.w));

    public static Skel ReadSkeleton(Model model)
    {
        return Skel.Create(model.Bones.AllBones.Select(b => {
            var world = FromEngine(b.LocalTransform);
            return new BoneDefinition(b.Name,b.Parent?.Name,b.Parent is null ? world : XForm.ToLocal(FromEngine(b.Parent.LocalTransform),world));
        }).ToArray());
    }

    public static async Task<HandTarget> LoadTargetAsync(string path, CancellationToken cancel = default)
    {
        await MainThread();
        var importNotes=new List<string>();
        if (path.EndsWith(".fbx",StringComparison.OrdinalIgnoreCase)) path = await PrepareMeshAsync(path,cancel,importNotes);
        path = ModelPath(path);
        var model = Model.Load(path);
        if (model is null || model.IsError || model.BoneCount == 0) throw new InvalidOperationException($"Cannot load a skinned model from '{path}'.");

        var skeleton = ReadSkeleton(model);
        return new HandTarget { ImportNotes=importNotes, ModelPath=path, Skeleton=skeleton, PalmFrames=HandPresetStore.LoadPalms(skeleton), Mapping=HandPresetStore.Load(skeleton) ?? HandRigDetector.Detect(skeleton) };
    }

    public static async Task<HandSource> LoadSourceAsync(string path, float fps = 30, CancellationToken cancel = default)
    {
        if (path.EndsWith(".fbx",StringComparison.OrdinalIgnoreCase))
        {
            var scene = await Task.Run(()=>FbxImporter.Import(File.ReadAllBytes(path),new FbxImportOptions {SampleFps=fps}),cancel);
            await MainThread();
            return new() { Path=path, Scene=scene, PalmFrames=HandPresetStore.LoadPalms(scene.Skeleton), Mapping=HandPresetStore.Load(scene.Skeleton) ?? HandRigDetector.Detect(scene.Skeleton) };
        }
        await MainThread();
        path = ModelPath(path);
        var model = Model.Load(path);
        if (model is null || model.IsError) throw new InvalidOperationException($"Cannot load source model '{path}'.");
        var skeleton = ReadSkeleton(model);
        var clips = new List<Clip>();
        using var sampling = new ModelSampler(model,skeleton);
        foreach (var name in model.AnimationNames.ToArray())
        {
            cancel.ThrowIfCancellationRequested();
            clips.Add(sampling.Sample(name,fps,cancel));
            await Task.Delay(1,cancel); await MainThread();
        }
        // Facepunch weapons author recoil as additive layers. Capture their complete firing
        // pose through the original weapon graph instead of retargeting a delta as a full pose.
        if(skeleton.IndexOf("weapon_root")>=0&&!clips.Any(c=>WeaponAnimGraph.Classify(c.Name)==WeaponAction.Fire)
            &&clips.FirstOrDefault(c=>c.Name.StartsWith("Fire_",StringComparison.OrdinalIgnoreCase)&&c.Name.EndsWith("_delta",StringComparison.OrdinalIgnoreCase)
                &&!c.Name.Contains("Hold",StringComparison.OrdinalIgnoreCase)&&!c.Name.Contains("Dry",StringComparison.OrdinalIgnoreCase)) is {} recoil)
        {
            var firing=sampling.SampleFire(Math.Max(.25f,(recoil.FrameCount-1)/recoil.Fps),fps,cancel);
            if(firing is not null)clips.Add(firing);
        }
        return new() { Path=path,ModelPath=path, Scene=new SourceScene(skeleton,clips,2.54f,upAxis:2,frontAxis:0,coordAxis:1),
            PalmFrames=HandPresetStore.LoadPalms(skeleton), Mapping=HandPresetStore.Load(skeleton) ?? HandRigDetector.Detect(skeleton) };
    }

    public static HandRetargetProfile Calibrate(HandSource source,HandTarget target)
        => HandRetargetProfile.Calibrate(source.Scene.Skeleton,source.Mapping,target.Skeleton,target.Mapping,source.PalmFrames,target.PalmFrames);

    public static HandBakedClip Bake(HandSource source, Clip clip, HandTarget target, HandMotionOptions options, CancellationToken cancel = default, HandRetargetProfile? profile = null)
    {
        profile ??= Calibrate(source,target);
        // Compiled s&box models already share X-forward/Z-up coordinates. A wrist's
        // bind rotation must not turn vertical reload travel into sideways/downward motion.
        var weaponOffset=options.WeaponSpaceOffset;
        if(source.ModelPath is not null&&options.PreserveWeaponGrip&&options.TransferWristPosition
            &&source.Scene.Skeleton.Bones.Any(b=>b.Name.Equals("weapon_root",StringComparison.OrdinalIgnoreCase)))
            weaponOffset ??= HandViewSpace.EyePosition(target.Skeleton,target.Mapping)-HandViewSpace.EyePosition(source.Scene.Skeleton,source.Mapping);
        options=new HandMotionOptions{TransferWristPosition=options.TransferWristPosition,ScaleWristTravel=options.ScaleWristTravel,
            SolveArmIk=options.SolveArmIk,PreserveWeaponGrip=options.PreserveWeaponGrip,WeaponSpaceOffset=weaponOffset,
            WristTravelBasis=options.WristTravelBasis??(source.ModelPath is not null?Quat.Identity:null)};
        var notes=weaponOffset.HasValue?profile.Notes.Concat(new[]{"Preserved both palm grip anchors in one fixed weapon space; weapon motion is not scaled per arm."}).ToArray():profile.Notes;
        return new(source,clip,HandRetargeter.Bake(profile,clip,options,cancel),notes,weaponOffset,options);
    }

    public static async Task<string> ExportAsync(HandTarget target,IReadOnlyList<HandBakedClip> clips,string outputModel,
        bool autoGraph,bool backup,bool weaponCompatible,bool preserveSourceTracks,CancellationToken cancel=default)
        => (await ExportWithReportAsync(target,clips,outputModel,autoGraph,backup,weaponCompatible,preserveSourceTracks,cancel)).ModelPath;

    public static async Task<HandExportResult> ExportWithReportAsync(HandTarget target, IReadOnlyList<HandBakedClip> clips, string outputModel,
        bool autoGraph, bool backup, bool weaponCompatible, bool preserveSourceTracks, CancellationToken cancel = default)
    {
        await MainThread();
        if(clips.Count==0)throw new InvalidOperationException("Select at least one animation to export.");
        if(weaponCompatible)
        {
            foreach(var clip in clips)
            {
                if(clip.Source.ModelPath is null)throw new InvalidOperationException("Weapon-compatible mode requires a weapon VMDL source so its animation owner can be verified.");
                var missing=target.Mapping.Hands.SelectMany(h=>new[]{h.Wrist}.Concat(h.Digits.SelectMany(d=>d.Bones))).Select(i=>target.Skeleton[i].Name).Where(n=>clip.Source.Scene.Skeleton.IndexOf(n)<0).ToArray();
                if(missing.Length>0)throw new InvalidOperationException("These target bones cannot bonemerge onto the selected weapon: "+string.Join(", ",missing)+". Use standalone export for this custom rig, or choose a weapon whose skeleton contains those bones.");
            }
        }
        outputModel = VmdlSetupService.NormalizeAssetPath(outputModel,".vmdl");
        var absolute = System.IO.Path.Combine(Assets,outputModel);
        var existing = File.Exists(absolute) ? File.ReadAllText(absolute) : null;
        if(existing is not null)
        {
            var existingModel=Model.Load(outputModel);
            if(existingModel is null||existingModel.IsError)throw new InvalidOperationException("The existing output model must compile before animations can be added.");
            var existingRig=ReadSkeleton(existingModel);
            if(existingRig.Count!=target.Skeleton.Count||target.Skeleton.Bones.Any(b=>existingRig.IndexOf(b.Name)<0))throw new InvalidOperationException("The existing output model has a different skeleton. Choose the target model or a new output path.");
        }
        var targetSource=System.IO.Path.Combine(Assets,target.ModelPath);
        var original = existing ?? (File.Exists(targetSource)?File.ReadAllText(targetSource):HandModelFactory.Create(baseModel:target.ModelPath));
        var folder = outputModel[..^5] + "_animations";
        var files = new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);
        var entries = new List<HandAnimationEntry>();
        var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "bindPose" };
        // A compiled target is in inches. Existing model scaling controls the animation
        // compiler's input units; new wrappers use the inherited cm-to-inch modifier.
        var factor = ExportPositionFactor(original);
        var turn=ExportRootRotation(original);
        XForm ExportLocal(XForm value,int bone)=>target.Skeleton[bone].ParentIndex<0
            ?new XForm(Vec.Transform(value.Pos*factor,turn),Quat.Normalize(turn*value.Rot))
            :new XForm(value.Pos*factor,value.Rot);
        Clip ExportUnits(Clip clip) => new(clip.Name,clip.Fps,clip.Looping,clip.Frames.Select(frame=>frame.Select(ExportLocal).ToArray()).ToList(),clip.NativeFps);
        var exportSkeleton = Skel.Create(target.Skeleton.Bones.Select(b=>new BoneDefinition(b.Name,b.ParentIndex<0?null:target.Skeleton[b.ParentIndex].Name,ExportLocal(b.RestLocal,b.Index))).ToArray());
        var bind = new Clip("bindPose",30,false,new() { Pose.Rest(exportSkeleton).Locals });
        files[folder+"/bind.dmx"] = DmxWriter.Write(exportSkeleton,bind,new() {Name="bindPose",UpAxisY=false,ForwardParity=1});
        foreach(var clip in clips)
        {
            var stem=SourceKey(clip.Source)+"_"+SafeName(clip.Baked.Name); var name=stem; var suffix=1;
            while(!names.Add(name)) name=stem+"_"+(++suffix);
            var content=DmxWriter.Write(exportSkeleton,ExportUnits(clip.Baked),new() {Name=name,UpAxisY=false,ForwardParity=1,ChannelExcludedBones=target.Mapping.Hands.SelectMany(h=>h.TwistOrHelperBones).ToHashSet()});
            var file=folder+"/"+name+"_"+ContentKey(content)+".dmx";
            files[file]=content;
            entries.Add(new(name,file,clip.Baked.Looping));
            if(preserveSourceTracks)
            {
                // Full source companion intentionally retains every authored camera,
                // weapon and IK track with its original hierarchy, units and timing.
                var tracks=DmxWriter.Write(clip.Source.Scene.Skeleton,clip.Original,new() {Name=name+"_source_tracks",UpAxisY=clip.Source.Scene.UpAxis==1,ForwardParity=clip.Source.ModelPath is null?2:1});
                files[folder+"/source_tracks/"+name+"_"+ContentKey(tracks)+".dmx"]=tracks;
            }
        }
        var prepared=VmdlSetupService.Prepare(original,entries,new() {ModelPath=outputModel,BindPoseSource=folder+"/bind.dmx",AutoConfigureAnimGraph=autoGraph,WeaponCompatibleArms=weaponCompatible});
        var generatedAssets=new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);
        var bundles=autoGraph?HandWeaponExport.Prepare(target,clips,outputModel,files,generatedAssets,cancel):Array.Empty<HandWeaponExport.Bundle>();
        foreach(var bundle in bundles)generatedAssets[bundle.PrefabPath]=HandWeaponExport.Prefab(bundle);
        var committed=await VmdlSetupTransaction.CommitAsync(Assets,outputModel,existing,prepared,files,async(paths,token)=>{
            await MainThread(); foreach(var path in paths) AssetSystem.RegisterFile(path);
            foreach(var bundle in bundles)
            {
                if(bundle.LiveCalibration is null)
                {
                    if(!await CompileAsync(System.IO.Path.Combine(Assets,bundle.ModelPath),token))throw new InvalidOperationException("Weapon animation model did not compile: "+bundle.ModelPath);
                    await MainThread();
                    await Task.Delay(100,token);await MainThread();
                    var owner=Model.Load(bundle.ModelPath);var graph=AnimationGraph.Load(bundle.GraphPath);
                    if(owner is null||owner.IsError||!owner.HasRenderMeshes()||owner.BoneCount<bundle.BoneCount||graph is null||graph.IsError
                        ||!bundle.Actions.All(a=>owner.AnimationNames.Contains(a.Sequence)))throw new InvalidOperationException($"Weapon animation owner validation failed: bones {owner?.BoneCount}/{bundle.BoneCount}, meshes {owner?.MeshCount}, vertices {owner?.MeshInfo.TotalVertices}, triangles {owner?.MeshInfo.TotalTriangles}, graph error {graph?.IsError}, sequences {string.Join(",",owner?.AnimationNames??Array.Empty<string>())}.");
                    if(target.Skeleton.Bones.Any(b=>owner.Bones.GetBone(WeaponClipBuilder.HandPrefix+b.Name) is null))return false;
                }

                if(!await CompileAsync(System.IO.Path.Combine(Assets,bundle.PrefabPath),token))throw new InvalidOperationException("Weapon prefab did not compile: "+bundle.PrefabPath);
                await MainThread();
                // The compiled file reaches disk before the editor's asset record updates.
                // Managed resource loading uses that record, not File.Exists.
                var prefabAsset=AssetSystem.FindByPath(bundle.PrefabPath);
                for(var wait=0;wait<50&&string.IsNullOrEmpty(prefabAsset.GetCompiledFile(true));wait++)
                {await Task.Delay(100,token);await MainThread();}
                var prefab=prefabAsset.LoadResource<PrefabFile>();
                if(prefab is null||prefab.IsError||SceneUtility.GetPrefabScene(prefab) is null)throw new InvalidOperationException("Weapon prefab could not be loaded: "+bundle.PrefabPath);
            }
            if(!await CompileAsync(absolute,token)) return false;
            await MainThread(); var model=Model.Load(outputModel);
            // The compiled file can arrive before an already-loaded model refreshes
            // its sequence list. Re-exporting changed motion adds a preserved-name
            // suffix, so wait for those new sequences before validating the bake.
            var modelDeadline=DateTime.UtcNow.AddSeconds(15);
            while(model is not null&&!model.IsError&&!prepared.Animations.All(a=>model.AnimationNames.Contains(a.SequenceName))
                &&DateTime.UtcNow<modelDeadline)
            {
                await Task.Delay(50,token);await MainThread();model=Model.Load(outputModel);
            }
            if(model is null || model.IsError || !prepared.Animations.All(a=>model.AnimationNames.Contains(a.SequenceName)))return false;
            if(prepared.GeneratedGraphPath is { } graphPath){var graph=AnimationGraph.Load(graphPath);if(graph is null||graph.IsError)return false;}

            var compiledRig=ReadSkeleton(model);
            using var sampler=new ModelSampler(model,compiledRig);
            var checkedBones=target.Mapping.Hands.SelectMany(h=>new int?[]{h.Clavicle,h.UpperArm,h.Forearm,h.Wrist}.Where(i=>i.HasValue).Select(i=>i!.Value).Concat(h.Digits.SelectMany(d=>d.Bones))).Distinct().ToArray();
            for(var i=0;i<clips.Count;i++)
            {
                var expectedClip=clips[i].Baked;var actualClip=sampler.Sample(prepared.Animations[i].SequenceName,expectedClip.Fps,token);
                if(expectedClip.FrameCount>1&&actualClip.FrameCount!=expectedClip.FrameCount)return false;
                foreach(var frame in new[]{0,expectedClip.FrameCount/2,expectedClip.FrameCount-1}.Distinct())
                {
                    var expectedWorld=new Pose(expectedClip.Frames[frame]).ToWorld(target.Skeleton);
                    var actualWorld=new Pose(actualClip.Frames[frame]).ToWorld(compiledRig);
                    foreach(var bone in checkedBones)
                    {
                        var index=compiledRig.IndexOf(target.Skeleton[bone].Name);
                        if(index<0 || Vec.Distance(expectedWorld[bone].Pos,actualWorld[index].Pos)>.1f
                            || MathQ.AngleBetween(expectedWorld[bone].Rot,actualWorld[index].Rot)>.02f)
                            throw new InvalidOperationException($"Compiled sequence '{expectedClip.Name}' differs from the preview at '{target.Skeleton[bone].Name}', frame {frame}. The export was rolled back.");
                    }
                }
            }
            return true;
        },backup,cancel,generatedAssets);
        return new(outputModel,prepared.Changes.Concat(bundles.Select(b=>b.LiveCalibration is not null
            ? $"Preserved complete source graph {b.GraphPath} with live custom-hand retargeting: {b.PrefabPath}"
            : $"Created weapon graph ({string.Join(", ",b.Actions.Select(a=>a.Action))}) and synchronized hands/weapon prefab: {b.PrefabPath}")).ToArray(),committed.BackupPath,bundles.Select(b=>b.PrefabPath).ToArray());
    }

    private static float ExportPositionFactor(string text)
    {
        float scale=1;
        void Walk(KvValue v) { if(v is KvObject o) { if(o.GetString("_class")=="ModelModifier_ScaleAndMirror" && o.GetOrNull("scale") is KvDouble d) scale*=(float)d.Value; foreach(var key in o.Keys) Walk(o[key]); } else if(v is KvArray a) foreach(var item in a.Items) Walk(item); }
        Walk(Kv3.Parse(text).Root);
        if(!float.IsFinite(scale)||scale<=0) throw new InvalidOperationException("Target model has an unsupported scale modifier.");
        return 1f/(2.54f*scale);
    }

    private static Quat ExportRootRotation(string text)
    {
        var turn=false;
        void Walk(KvValue value)
        {
            if(value is KvObject node)
            {
                if(node.GetString("_class")=="ModelModifier_ScaleAndMirror"
                    &&node.GetOrNull("mirror_x") is KvBool {Value:true}
                    &&node.GetOrNull("mirror_y") is KvBool {Value:true}
                    &&node.GetOrNull("mirror_z") is not KvBool {Value:true})turn=!turn;
                foreach(var key in node.Keys)Walk(node[key]);
            }
            else if(value is KvArray array)foreach(var child in array.Items)Walk(child);
        }
        Walk(Kv3.Parse(text).Root);
        return turn?Quat.CreateFromAxisAngle(Vec.UnitZ,MathF.PI):Quat.Identity;
    }

    private static async Task<string> PrepareMeshAsync(string file, CancellationToken cancel,List<string> importNotes)
    {
        var bytes=File.ReadAllBytes(file);
        var materials=await Task.Run(()=>FbxMaterialAssets.Inspect(file,bytes),cancel);
        importNotes.AddRange(materials.Notes);
        var hash=materials.Signature;
        var folder="models/hand_retargeter/targets/"+SafeName(System.IO.Path.GetFileNameWithoutExtension(file))+"_"+hash+"_rig9";
        var mesh=folder+"/hands.fbx"; var model=folder+"/hands.vmdl";
        var scene=await Task.Run(()=>FbxImporter.Import(bytes,new(){SampleFps=(float)FbxScene.Build(FbxTokenizer.Parse(bytes)).FrameRate}),cancel);
        var embeddedClips=scene.Clips;
        var repaired=await Task.Run(()=>
        {
            var result=FbxBindPoseFixer.TryFix(bytes,out var report);
            return (Bytes:result,Report:report);
        },cancel);
        if(repaired.Bytes is {} repairedBytes)
        {
            var repairedScene=await Task.Run(()=>FbxImporter.Import(repairedBytes),cancel);
            if(!scene.Skeleton.Bones.Select(b=>(b.Name,b.ParentIndex)).SequenceEqual(repairedScene.Skeleton.Bones.Select(b=>(b.Name,b.ParentIndex))))
                throw new InvalidOperationException("Bind-pose repair changed the target hierarchy; the original FBX was not modified.");
            scene=repairedScene;bytes=repairedBytes;
            importNotes.Add("Repaired the imported copy from its authored bind pose: "+repaired.Report);
        }
        var meshNames=FbxSkinnedMeshes.ReadNames(bytes);
        await MainThread();
        var meshAbs=System.IO.Path.Combine(Assets,mesh); Directory.CreateDirectory(System.IO.Path.GetDirectoryName(meshAbs)!);
        if(!File.Exists(meshAbs)) File.WriteAllBytes(meshAbs,bytes);
        var modelAbs=System.IO.Path.Combine(Assets,model);
        var remaps=HandMaterialImport.Write(materials,folder);
        var isNew=!File.Exists(modelAbs);
        if(isNew)
        {
            // Keep the full authored hierarchy before ModelDoc can cull unweighted
            // ancestors. The audited importer preview uses this same bind-source technique.
            string EngineName(string name){var suffix=name.LastIndexOf('#');return suffix<0?name:name[..suffix]+"_duplicate";}
            var rig=Skel.Create(scene.Skeleton.Bones.Select(b=>new BoneDefinition(EngineName(b.Name),b.ParentIndex<0?null:EngineName(scene.Skeleton[b.ParentIndex].Name),b.RestLocal)).ToArray());
            var bindPath=folder+"/mesh_bind.dmx";
            File.WriteAllText(System.IO.Path.Combine(Assets,bindPath),DmxWriter.Write(rig,new Clip("bindPose",30,false,new(){Pose.Rest(rig).Locals}),new(){Name="bindPose",UpAxisY=scene.UpAxis==1}));
            var entries=new List<HandAnimationEntry>();
            var names=new HashSet<string>(StringComparer.OrdinalIgnoreCase){"bindPose"};
            for(var i=0;i<embeddedClips.Count;i++)
            {
                cancel.ThrowIfCancellationRequested();
                var clip=embeddedClips[i];
                var stem=SafeName(clip.Name).Replace('-','_');
                var name=stem;var suffix=2;
                while(!names.Add(name))name=stem+"_"+suffix++;
                var path=folder+"/embedded_"+i+".dmx";
                // Imported locals are already centimeters, matching mesh_bind and the
                // model's final unit conversion. Preserve authored takes without IK.
                File.WriteAllText(System.IO.Path.Combine(Assets,path),DmxWriter.Write(rig,clip,new(){Name=name,UpAxisY=scene.UpAxis==1}));
                AssetSystem.RegisterFile(System.IO.Path.Combine(Assets,path));
                entries.Add(new(name,path,clip.Looping));
            }
            var prepared=VmdlSetupService.Prepare(HandModelFactory.Create(mesh:mesh,meshUnitScaleCm:scene.UnitScaleCm,materialRemaps:remaps,meshNames:meshNames),entries,new(){ModelPath=model,BindPoseSource=bindPath,AutoConfigureAnimGraph=false});
            File.WriteAllText(modelAbs,prepared.VmdlText);
            AssetSystem.RegisterFile(System.IO.Path.Combine(Assets,bindPath));
        }
        AssetSystem.RegisterFile(meshAbs);
        if(!await CompileAsync(modelAbs,cancel)) throw new InvalidOperationException("The target FBX could not be compiled into a skinned model.");
        if(isNew)
        {
            await MainThread();
            var compiledRig=ReadSkeleton(Model.Load(model));
            var fit=HandTargetFit.Analyze(compiledRig,HandRigDetector.Detect(compiledRig));
            if(fit.Scale!=1||fit.TurnAround)
            {
                File.WriteAllText(modelAbs,fit.Apply(File.ReadAllText(modelAbs)));
                if(!await CompileAsync(modelAbs,cancel))throw new InvalidOperationException("The fitted FPS target could not be compiled.");
                // Resource compilation finishes before the loaded Model's bone cache
                // refreshes. Never hand an old-size skeleton to a newly scaled mesh.
                var deadline=DateTime.UtcNow.AddSeconds(15);
                while(true)
                {
                    await Task.Delay(50,cancel);await MainThread();
                    var loaded=ReadSkeleton(Model.Load(model));
                    var ready=compiledRig.Bones.All(b=>
                    {
                        var index=loaded.IndexOf(b.Name);if(index<0)return false;
                        var expected=compiledRig.RestWorld[b.Index].Pos*fit.Scale;
                        if(fit.TurnAround)expected=new Vec(-expected.X,-expected.Y,expected.Z);
                        return Vec.Distance(loaded.RestWorld[index].Pos,expected)<.02f;
                    });
                    if(ready)break;
                    if(DateTime.UtcNow>=deadline)throw new InvalidOperationException("The fitted model is still reloading. Select the FBX again after asset compilation finishes.");
                }
                if(fit.Scale!=1)importNotes.Add($"Applied FPS import scale {fit.Scale:G} to the mesh and all embedded animations to correct the oversized or undersized export.");
                if(fit.TurnAround)importNotes.Add("Turned the imported target 180 degrees to align its left and right shoulders with FPS weapon coordinates.");
            }
        }
        await MainThread(); return model;
    }

    public static async Task<bool> CompileAsync(string absolute, CancellationToken cancel)
    {
        await MainThread(); var asset=AssetSystem.RegisterFile(absolute);
        var compiled=absolute+"_c"; var before=File.Exists(compiled)?File.GetLastWriteTimeUtc(compiled):DateTime.MinValue;
        asset.Compile(full:true);
        var deadline=DateTime.UtcNow.AddSeconds(90);
        while(DateTime.UtcNow<deadline)
        {
            cancel.ThrowIfCancellationRequested(); await MainThread();
            if(asset.IsCompileFailed) return false;
            if(File.Exists(compiled) && File.GetLastWriteTimeUtc(compiled)>before) return true;
            await Task.Delay(100,cancel);
        }
        return false;
    }

    public static bool HasMissingMaterials(Model model)
    {
        var materials=model.Materials.Concat(Enumerable.Range(0,model.MaterialGroupCount).SelectMany(group=>model.GetMaterials(group))).Distinct();
        foreach(var material in materials)
        {
            if(material is null||material.IsError||string.Equals(material.ResourcePath,"materials/error.vmat",StringComparison.OrdinalIgnoreCase))return true;
            foreach(var parameter in new[]{"g_tColor","g_tNormal","g_tRoughness","g_tMetalness","g_tAmbientOcclusion","g_tSelfIllumMask","g_tTranslucency","TextureColor","TextureNormal","TextureRoughness","TextureMetalness","TextureAmbientOcclusion","TextureSelfIllumMask","TextureTranslucency"})
                if(material.GetTexture(parameter) is {IsError:true})return true;
            if(material.FirstTexture is {IsError:true})return true;
        }
        return false;
    }

    public static void ApplyPreviewMaterialFallback(SceneModel model)
    {
        // Incomplete visual dependencies must not prevent skeletal retargeting.
        // Override this preview instance only; authored assets remain editable.
        if(HasMissingMaterials(model.Model))model.SetMaterialOverride(Material.Load("materials/dev/reflectivity_50.vmat"));
    }
    public static string ModelPath(string path)
    {
        if(path.EndsWith(".vmdl_c",StringComparison.OrdinalIgnoreCase))path=path[..^2];
        if(System.IO.Path.IsPathRooted(path)) path=System.IO.Path.GetRelativePath(Assets,path);
        return VmdlSetupService.NormalizeAssetPath(path,".vmdl");
    }
    public static string SafeName(string name) => string.Concat(name.Select(c=>char.IsLetterOrDigit(c)||c=='_'||c=='-'?c:'_')).Trim('_') is {Length:>0} result ? result : "animation";
    internal static string ContentKey(string text)=>Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))).ToLowerInvariant()[..16];
    internal static string SourceKey(HandSource source)=>SafeName(System.IO.Path.GetFileNameWithoutExtension(source.Path))+"_"+ContentKey(source.Path.Replace('\\','/').ToLowerInvariant())[..8];

    private sealed class ModelSampler : IDisposable
    {
        readonly Scene metadataScene = Scene.CreateEditorScene(); readonly SkinnedModelRenderer metadata;
        readonly SceneWorld world=new(); readonly Model asset; readonly Skel skeleton; readonly int[] bones;
        public ModelSampler(Model asset, Skel rig) { using(metadataScene.Push()) { metadata=new GameObject(true,"sequence metadata").GetOrAddComponent<SkinnedModelRenderer>(); metadata.Model=asset; metadata.UseAnimGraph=false; } this.asset=asset; skeleton=rig; bones=rig.Bones.Select(b=>asset.Bones.GetBone(b.Name).Index).ToArray(); }
        public Clip Sample(string name,float fps,CancellationToken cancel=default)
        {
            var model=new SceneModel(world,asset,Transform.Zero){UseAnimGraph=false};
            try {
            model.SetAnimGraph("");model.UseAnimGraph=false;
            model.CurrentSequence.Name=name; metadata.Sequence.Name=name;
            var duration=model.CurrentSequence.Duration;
            var count=Math.Max(1,(int)MathF.Round(duration*fps)+1);
            if(count>100000) throw new InvalidOperationException($"Sequence '{name}' is too long to sample.");
            var frames=new List<XForm[]>(count);
            for(var f=0;f<count;f++) {
                cancel.ThrowIfCancellationRequested();
                model.CurrentSequence.Time=MathF.Min(f/fps,duration); model.Update(0);
                var worlds=bones.Select(b=>FromEngine(model.GetBoneWorldTransform(b))).ToArray();
                frames.Add(skeleton.Bones.Select(b=>b.ParentIndex<0?worlds[b.Index]:XForm.ToLocal(worlds[b.ParentIndex],worlds[b.Index])).ToArray());
            }
            return new(name,fps,metadata.Sequence.Looping,frames);
            } finally {model.Delete();}
        }
        public Clip? SampleFire(float duration,float fps,CancellationToken cancel)
        {
            var model=new SceneModel(world,asset,Transform.Zero){UseAnimGraph=true};
            try
            {
                if(model.AnimationGraph is null||model.AnimationGraph.IsError)return null;
                model.SetAnimParameter("skeleton",0);model.SetAnimParameter("b_deploy_skip",true);
                for(var i=0;i<120;i++){cancel.ThrowIfCancellationRequested();model.Update(1f/60);}
                model.SetAnimParameter("b_deploy_skip",false);
                var frames=new List<XForm[]>();
                for(var f=0;f<=Math.Max(1,(int)MathF.Ceiling(duration*fps));f++)
                {
                    cancel.ThrowIfCancellationRequested();model.SetAnimParameter("b_attack",f==0);model.Update(f==0?0:1f/fps);
                    var worlds=bones.Select(b=>FromEngine(model.GetBoneWorldTransform(b))).ToArray();
                    frames.Add(skeleton.Bones.Select(b=>b.ParentIndex<0?worlds[b.Index]:XForm.ToLocal(worlds[b.ParentIndex],worlds[b.Index])).ToArray());
                }
                return new Clip("Fire",fps,false,frames);
            }
            finally{model.Delete();}
        }
        public void Dispose() {world.Delete();metadataScene.Destroy();}
    }
}