Editor/WeaponImporter/Tool/Engine/WeaponBaker.Generate.cs
using SkiaSharp;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Generation;
using WeaponImporter.Core.Geometry;
using WeaponImporter.Core.Setup;

namespace WeaponImporter.Tool;

public static partial class WeaponBaker
{
    /// <summary>Model DMX, clip DMX and materials for the canonical weapon (runs off the main thread).</summary>
    private static ExportedWeapon Generate( ImportSession session, string folder, string generated, string name, string assetsRoot )
    {
        var result = new ExportedWeapon();
        var analysis = session.Analysis;
        var setup = session.Setup;

        var keep = new List<string>();
        if ( setup.Muzzle is not null ) keep.Add( setup.Muzzle.Bone );
        if ( setup.Eject is not null ) keep.Add( setup.Eject.Bone );
        keep.AddRange( setup.Parts.Select( p => p.Bone ) );
        keep.AddRange( setup.Contacts.Values.SelectMany( c => c.Keys ).Select( k => k.Follow ) );
        var rig = ExportRig.Build( analysis, keep );
        result.Rig = rig;

        // Mesh + materials.
        var dmx = ModelDmxWriter.WriteWithMaterials( rig.Asset, rig.Triangles, DmxSpace.SourceZUpInches, name );
        result.MeshFile = $"{generated}/{name}.dmx";
        result.TextFiles.Add( (result.MeshFile, dmx.Text) );

        var materialsFolder = $"{folder}/materials";
        void Materials( ModelDmxResult model, List<(string From, string To)> remaps )
        {
            foreach ( var (dmxMaterial, material) in model.Materials )
            {
                var output = MaterialWriter.Write( material, materialsFolder, dmxMaterial );
                var pending = MaterialWriter.WriteFiles( output, assetsRoot );
                foreach ( var (absolute, source, channel) in pending )
                {
                    try
                    {
                        SplitChannel( source, channel, absolute );
                    }
                    catch ( Exception e )
                    {
                        result.Notes.Add( $"Couldn't extract the {channel} channel of {source.Name}: {e.Message}" );
                    }
                }
                result.WrittenFiles.Add( output.VmatPath );
                result.WrittenFiles.AddRange( output.Textures.Select( t => t.RelativePath ) );
                if ( setup.MaxTextureSize > 0 )
                    foreach ( var texture in output.Textures )
                    {
                        var written = System.IO.Path.GetFullPath( System.IO.Path.Combine( assetsRoot, texture.RelativePath.Replace( '/', System.IO.Path.DirectorySeparatorChar ) ) );
                        // Only the bake's own copies: an image already at the output path is the user's source.
                        if ( texture.Source.FilePath is { } src && string.Equals( System.IO.Path.GetFullPath( src ), written, StringComparison.OrdinalIgnoreCase ) )
                            continue;
                        LimitSize( written, setup.MaxTextureSize, result.Notes );
                    }
                // ModelDoc names DMX materials after the faceSet material; point them at the generated vmat.
                remaps.Add( ($"{dmxMaterial}.vmat", output.VmatPath) );
            }
        }
        Materials( dmx, result.MaterialRemaps );

        // Clips the setup uses (each only once).
        var used = setup.WeaponAnimations.Values.Select( b => b.Clip ).Where( c => !string.IsNullOrEmpty( c ) ).Distinct().ToList();
        foreach ( var clipName in used )
        {
            var clip = rig.Asset.FindClip( clipName );
            if ( clip is null || clip.FrameCount == 0 )
            {
                result.Notes.Add( $"Clip '{clipName}' is missing and was skipped." );
                continue;
            }
            var sequence = EngineNames.Sequence( clipName );
            var file = $"{generated}/anim_{sequence}.dmx";
            result.TextFiles.Add( (file, AnimationDmxWriter.Write( rig.Asset.Skeleton, clip, DmxSpace.SourceZUpInches, sequence )) );
            result.Clips.Add( new ExportedClip( clipName, sequence, file, clip.FrameCount, clip.Fps ) );
        }

        // First-person viewmodel (files with their own arms): same clips, everything as authored.
        if ( setup.ExportFirstPerson && FirstPersonRig.Build( analysis, keep ) is { } fp )
        {
            result.FirstPerson = fp;
            var fpName = $"{name}_fp";
            var fpDmx = ModelDmxWriter.WriteWithMaterials( fp.Asset, fp.Triangles, DmxSpace.SourceZUpInches, fpName );
            result.FirstPersonMeshFile = $"{generated}/{fpName}.dmx";
            result.TextFiles.Add( (result.FirstPersonMeshFile, fpDmx.Text) );
            Materials( fpDmx, result.FirstPersonMaterialRemaps );
            foreach ( var clipName in used )
            {
                if ( fp.Asset.FindClip( clipName ) is not { FrameCount: > 0 } clip )
                    continue;
                var sequence = EngineNames.Sequence( clipName );
                var file = $"{generated}/fp_anim_{sequence}.dmx";
                result.TextFiles.Add( (file, AnimationDmxWriter.Write( fp.Asset.Skeleton, clip, DmxSpace.SourceZUpInches, sequence )) );
                result.FirstPersonClips.Add( new ExportedClip( clipName, sequence, file, clip.FrameCount, clip.Fps ) );
            }
        }
        return result;
    }

    /// <summary>
    /// Scales an exported texture down so its longest side is at most <paramref name="max"/>
    /// (same file, same format). TGA files stay as they are (Skia can't write them).
    /// </summary>
    private static void LimitSize( string path, int max, List<string> notes )
    {
        if ( !System.IO.File.Exists( path ) )
            return;
        var ext = System.IO.Path.GetExtension( path ).ToLowerInvariant();
        var format = ext switch { ".png" => SKEncodedImageFormat.Png, ".jpg" or ".jpeg" => SKEncodedImageFormat.Jpeg, _ => (SKEncodedImageFormat?)null };
        if ( format is null )
            return;
        try
        {
            using var codec = SKCodec.Create( path );
            if ( codec is null || Math.Max( codec.Info.Width, codec.Info.Height ) <= max )
                return;
            using var source = SKBitmap.Decode( codec );
            var scale = (float)max / Math.Max( source.Width, source.Height );
            var info = new SKImageInfo( Math.Max( 1, (int)(source.Width * scale) ), Math.Max( 1, (int)(source.Height * scale) ), source.ColorType, source.AlphaType );
            using var resized = source.Resize( info, SKFilterQuality.High );
            if ( resized is null )
                return;
            using var image = SKImage.FromBitmap( resized );
            using var data = image.Encode( format.Value, 95 );
            codec.Dispose();
            System.IO.File.WriteAllBytes( path, data.ToArray() );
        }
        catch ( Exception e ) when ( e is System.IO.IOException or UnauthorizedAccessException or ArgumentException or InvalidOperationException )
        {
            notes.Add( $"{System.IO.Path.GetFileName( path )} kept at full size ({e.Message})." );
        }
    }

    /// <summary>Writes one channel of an image as a greyscale PNG (packed glTF roughness/metalness maps).</summary>
    private static void SplitChannel( TextureRef source, TextureChannel channel, string destination )
    {
        var bytes = source.ReadBytes();
        using var bitmap = SKBitmap.Decode( bytes ) ?? throw new InvalidOperationException( "unreadable image" );
        // Plain RGBA: the engine's texture compiler doesn't read 8-bit greyscale PNGs.
        using var output = new SKBitmap( bitmap.Width, bitmap.Height, SKColorType.Rgba8888, SKAlphaType.Opaque );
        var pixels = bitmap.Pixels;
        var result = new SKColor[pixels.Length];
        for ( var i = 0; i < pixels.Length; i++ )
        {
            var c = pixels[i];
            var v = channel switch { TextureChannel.R => c.Red, TextureChannel.G => c.Green, TextureChannel.B => c.Blue, TextureChannel.A => c.Alpha, _ => c.Red };
            result[i] = new SKColor( v, v, v, 255 );
        }
        output.Pixels = result;
        System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( destination )! );
        using var image = SKImage.FromBitmap( output );
        using var data = image.Encode( SKEncodedImageFormat.Png, 100 );
        System.IO.File.WriteAllBytes( destination, data.ToArray() );
    }
}