diff --git a/engine/Mounting/Sandbox.Mounting/MountedGame/BaseGameMount.cs b/engine/Mounting/Sandbox.Mounting/MountedGame/BaseGameMount.cs index 93eb1d11..8adf01a5 100644 --- a/engine/Mounting/Sandbox.Mounting/MountedGame/BaseGameMount.cs +++ b/engine/Mounting/Sandbox.Mounting/MountedGame/BaseGameMount.cs @@ -98,13 +98,21 @@ public abstract class BaseGameMount } - readonly Dictionary _entries = []; + readonly Dictionary _entries = new Dictionary( StringComparer.OrdinalIgnoreCase ); /// /// All of the resources in this game /// public IReadOnlyCollection Resources => _entries.Values; + /// + /// Retrieves the resource loader associated with the specified path, if it exists. + /// + public ResourceLoader GetByPath( string path ) + { + return _entries.TryGetValue( path, out var entry ) ? entry : default; + } + public ResourceFolder RootFolder { get; internal set; } internal void RegisterFileInternal( ResourceLoader entry ) diff --git a/engine/Sandbox.Engine/Core/Context/IToolsDll.cs b/engine/Sandbox.Engine/Core/Context/IToolsDll.cs index 1d5597ff..de05d53a 100644 --- a/engine/Sandbox.Engine/Core/Context/IToolsDll.cs +++ b/engine/Sandbox.Engine/Core/Context/IToolsDll.cs @@ -45,4 +45,9 @@ internal unsafe interface IToolsDll /// Called after the host network system is initialised, used to add additional package references etc. to dev servers /// public Task OnInitializeHost(); + + /// + /// Get a thumbnail for the specified asset.Can return null if not immediately available. + /// + Bitmap GetThumbnail( string filename ); } diff --git a/engine/Sandbox.Engine/Game/Avatar/Clothing.cs b/engine/Sandbox.Engine/Game/Avatar/Clothing.cs index 4529eda0..4bcffcd1 100644 --- a/engine/Sandbox.Engine/Game/Avatar/Clothing.cs +++ b/engine/Sandbox.Engine/Game/Avatar/Clothing.cs @@ -5,7 +5,7 @@ namespace Sandbox; /// /// Describes an item of clothing and implicitly which other items it can be worn with /// -[AssetType( Name = "Clothing Definition", Extension = "clothing", Category = "citizen" )] +[AssetType( Name = "Clothing Definition", Extension = "clothing", Category = "citizen", Flags = AssetTypeFlags.IncludeThumbnails )] public sealed partial class Clothing : GameResource { diff --git a/engine/Sandbox.Engine/Game/Mount/Directory.cs b/engine/Sandbox.Engine/Game/Mount/Directory.cs index eb022221..0546ba40 100644 --- a/engine/Sandbox.Engine/Game/Mount/Directory.cs +++ b/engine/Sandbox.Engine/Game/Mount/Directory.cs @@ -54,7 +54,7 @@ public static class Directory } /// - /// Get information about all the current mounts + /// Get a specific mount by name /// public static BaseGameMount Get( string name ) { diff --git a/engine/Sandbox.Engine/Game/Mount/MountUtility.cs b/engine/Sandbox.Engine/Game/Mount/MountUtility.cs index 6bbf476c..4e8fdf3e 100644 --- a/engine/Sandbox.Engine/Game/Mount/MountUtility.cs +++ b/engine/Sandbox.Engine/Game/Mount/MountUtility.cs @@ -6,6 +6,33 @@ public static class MountUtility static readonly List _jobs = new(); static readonly HashSet _activeJobs = new(); + /// + /// Find a ResourceLoader by its mount path. + /// + public static ResourceLoader FindLoader( string loaderPath ) + { + if ( !loaderPath.StartsWith( "mount://" ) ) return null; + + var partIndex = loaderPath.IndexOf( '/', 8 ); + if ( partIndex <= 8 ) return null; + var mountName = loaderPath[8..partIndex]; + + var mount = Directory.Get( mountName ); + if ( mount is null ) return null; + + return mount.GetByPath( loaderPath ); + } + + /// + /// Create a preview texture for the given resource loader. + /// + public static Texture GetPreviewTexture( string loaderPath ) + { + var loader = FindLoader( loaderPath ); + if ( loader is null ) return null; + return GetPreviewTexture( loader ); + } + /// /// Create a preview texture for the given resource loader. /// diff --git a/engine/Sandbox.Engine/Protocol.cs b/engine/Sandbox.Engine/Protocol.cs index 8876ef39..4c016647 100644 --- a/engine/Sandbox.Engine/Protocol.cs +++ b/engine/Sandbox.Engine/Protocol.cs @@ -8,7 +8,7 @@ public static class Protocol /// /// We cannot play packages with an Api version higher than this. /// - public static int Api => 22; + public static int Api => 23; /// /// We cannot talk to servers or clients with a network protocol different to this. diff --git a/engine/Sandbox.Engine/Resources/GameResourceAttribute.cs b/engine/Sandbox.Engine/Resources/GameResourceAttribute.cs index 2c43c9e5..a5be82b3 100644 --- a/engine/Sandbox.Engine/Resources/GameResourceAttribute.cs +++ b/engine/Sandbox.Engine/Resources/GameResourceAttribute.cs @@ -48,6 +48,11 @@ public enum AssetTypeFlags /// it can only really exist as an asset file on disk, not inside another asset. /// NoEmbedding = 1 << 0, + + /// + /// Include thumbnails when publishing as part of another package + /// + IncludeThumbnails = 1 << 1, } [Obsolete( "Use AssetType instead" )] diff --git a/engine/Sandbox.Engine/Resources/ResourceLibrary.cs b/engine/Sandbox.Engine/Resources/ResourceLibrary.cs index 4f3b2345..6d29ef4e 100644 --- a/engine/Sandbox.Engine/Resources/ResourceLibrary.cs +++ b/engine/Sandbox.Engine/Resources/ResourceLibrary.cs @@ -434,6 +434,18 @@ public static class ResourceLibrary return default; } + /// + /// Render a thumbnail for this resource + /// + public static async Task GetThumbnail( string path, int width = 256, int height = 256 ) + { + var resource = await ResourceLibrary.LoadAsync( path ); + if ( resource is null ) return default; + + // try to render it + return resource.RenderThumbnail( new() { Width = width, Height = height } ); + } + public interface IEventListener { /// diff --git a/engine/Sandbox.Engine/Resources/Textures/Loader/AvatarLoader.cs b/engine/Sandbox.Engine/Resources/Textures/Loader/AvatarLoader.cs index e1aff262..1dbef74c 100644 --- a/engine/Sandbox.Engine/Resources/Textures/Loader/AvatarLoader.cs +++ b/engine/Sandbox.Engine/Resources/Textures/Loader/AvatarLoader.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Caching.Memory; using NativeEngine; using Steamworks; using Steamworks.Data; @@ -9,6 +10,11 @@ namespace Sandbox.TextureLoader; /// internal static class Avatar { + /// + /// Entries are cached on a sliding window, they will be released if not used for 10 minutes + /// + static readonly MemoryCache _cache = new( new MemoryCacheOptions() ); + internal static bool IsAppropriate( string url ) { return url.StartsWith( "avatar:" ) || url.StartsWith( "avatarbig:" ) || url.StartsWith( "avatarsmall:" ); @@ -18,15 +24,17 @@ internal static class Avatar { try { - // - // Create a 1x1 placeholder texture - // - var placeholder = Texture.Create( 1, 1 ).WithName( "avatar" ).WithData( new byte[4] { 0, 0, 0, 0 } ).Finish(); - placeholder.IsLoaded = false; + return _cache.GetOrCreate( filename, entry => + { + entry.SetSlidingExpiration( TimeSpan.FromMinutes( 10 ) ); - _ = LoadIntoTexture( filename, placeholder ); + var placeholder = Texture.Create( 1, 1 ).WithName( "avatar" ).WithData( new byte[4] { 0, 0, 0, 0 } ).Finish(); + placeholder.IsLoaded = false; - return placeholder; + _ = LoadIntoTexture( filename, placeholder ); + + return placeholder; + } ); } catch ( System.Exception e ) { diff --git a/engine/Sandbox.Engine/Resources/Textures/Loader/HttpImageLoader.cs b/engine/Sandbox.Engine/Resources/Textures/Loader/HttpImageLoader.cs index 634c6c1f..4ac9759b 100644 --- a/engine/Sandbox.Engine/Resources/Textures/Loader/HttpImageLoader.cs +++ b/engine/Sandbox.Engine/Resources/Textures/Loader/HttpImageLoader.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Caching.Memory; using System.IO; using System.Net.Http; @@ -6,9 +7,9 @@ namespace Sandbox.TextureLoader; internal static class ImageUrl { /// - /// For textures loaded from the web we want to keep them around a bit longer + /// Entries are cached on a sliding window, they will be released if not used for 10 minutes /// - static CaseInsensitiveDictionary Loaded = new(); + static readonly MemoryCache _cache = new( new MemoryCacheOptions() ); internal static bool IsAppropriate( string url ) { @@ -17,22 +18,19 @@ internal static class ImageUrl internal static Texture Load( string filename, bool warnOnMissing ) { - if ( Loaded.TryGetValue( filename, out var cachedTexture ) ) - { - return cachedTexture; - } - try { - // - // Create a 1x1 placeholder texture - // - var placeholder = Texture.Create( 1, 1 ).WithName( "httpimg-placeholder" ).WithData( new byte[4] { 0, 0, 0, 0 } ).Finish(); - _ = placeholder.ReplacementAsync( LoadFromUrl( filename ) ); + return _cache.GetOrCreate( filename, entry => + { + // + // Create a 1x1 placeholder texture + // + var placeholder = Texture.Create( 1, 1 ).WithName( "httpimg-placeholder" ).WithData( new byte[4] { 0, 0, 0, 0 } ).Finish(); + _ = placeholder.ReplacementAsync( LoadFromUrl( filename ) ); - Loaded[filename] = placeholder; - - return placeholder; + entry.SlidingExpiration = TimeSpan.FromMinutes( 10 ); + return placeholder; + } ); } catch ( System.Exception e ) { diff --git a/engine/Sandbox.Engine/Resources/Textures/Loader/ThumbLoader.cs b/engine/Sandbox.Engine/Resources/Textures/Loader/ThumbLoader.cs new file mode 100644 index 00000000..d46d34ff --- /dev/null +++ b/engine/Sandbox.Engine/Resources/Textures/Loader/ThumbLoader.cs @@ -0,0 +1,128 @@ +using Microsoft.Extensions.Caching.Memory; +using Sandbox.Engine; +using Sandbox.Mounting; + +namespace Sandbox.TextureLoader; + +/// +/// Loads a thumbnail of an entity or something +/// +internal static class ThumbLoader +{ + /// + /// Entries are cached on a sliding window, they will be released if not used for 10 minutes + /// + static readonly MemoryCache _cache = new( new MemoryCacheOptions() ); + + internal static bool IsAppropriate( string url ) + { + return url.StartsWith( "thumb:" ); + } + + internal static Texture Load( string filename ) + { + try + { + return _cache.GetOrCreate( filename, entry => + { + entry.SetSlidingExpiration( TimeSpan.FromMinutes( 10 ) ); + + var placeholder = Texture.Create( 1, 1 ) + .WithName( "thumb" ) + .WithData( new byte[4] { 0, 0, 0, 0 } ) + .Finish(); + + placeholder.IsLoaded = false; + _ = LoadIntoTexture( filename, placeholder ); + + return placeholder; + } ); + } + catch ( System.Exception e ) + { + Log.Warning( $"Couldn't Load Thumb {filename} ({e.Message})" ); + return null; + } + } + + internal static async Task LoadIntoTexture( string url, Texture placeholder ) + { + try + { + var filename = url[(url.IndexOf( ':' ) + 1)..]; + + // One day we'll support things like ?width=512 and ?mode=wide ?mode=tall + + + // + // if it's from a mount then get it from the mount system + // + if ( filename.StartsWith( "mount:" ) ) + { + var t = MountUtility.GetPreviewTexture( filename ); + placeholder.CopyFrom( t ); + return; + } + + // + // if it looks like a package, try to load the thumb from there + // + if ( filename.Count( x => x == '/' || x == '\\' ) == 0 && filename.Count( '.' ) == 1 && Package.TryParseIdent( filename, out var ident ) ) + { + var packageInfo = await Package.FetchAsync( $"{ident.org}.{ident.package}", true ); + if ( packageInfo == null ) return; + + var thumb = await ImageUrl.LoadFromUrl( packageInfo.Thumb ); + if ( thumb == null ) return; + + placeholder.CopyFrom( thumb ); + return; + } + + // + // if it's a resource, it can generate itself + // + { + // Load it from disk, if it exists! + { + var fn = filename.EndsWith( "_c" ) ? filename : $"{filename}_c"; // needs to end in _c + string imageFile = $"{fn}.t.png"; + + if ( FileSystem.Mounted.FileExists( imageFile ) ) + { + using var bitmap = Bitmap.CreateFromBytes( await FileSystem.Mounted.ReadAllBytesAsync( imageFile ) ); + placeholder.CopyFrom( bitmap.ToTexture() ); + return; + } + } + + { + var bitmap = IToolsDll.Current?.GetThumbnail( filename ); + if ( bitmap != null ) + { + using var downscaled = bitmap.Resize( 256, 256, true ); + var t = downscaled.ToTexture(); + placeholder.CopyFrom( t ); + return; + } + } + + // last resort - generate it! + { + using var bitmap = await ResourceLibrary.GetThumbnail( filename, 512, 512 ); + if ( bitmap != null ) + { + using var downscaled = bitmap.Resize( 256, 256, true ); + var t = downscaled.ToTexture(); + placeholder.CopyFrom( t ); + return; + } + } + } + } + finally + { + placeholder.IsLoaded = true; + } + } +} diff --git a/engine/Sandbox.Engine/Resources/Textures/Texture.Load.cs b/engine/Sandbox.Engine/Resources/Textures/Texture.Load.cs index 53443012..229f6acc 100644 --- a/engine/Sandbox.Engine/Resources/Textures/Texture.Load.cs +++ b/engine/Sandbox.Engine/Resources/Textures/Texture.Load.cs @@ -191,6 +191,14 @@ public partial class Texture return TextureLoader.Avatar.Load( filepath ); } + // + // Thumb loader + // + if ( TextureLoader.ThumbLoader.IsAppropriate( filepath ) ) + { + return TextureLoader.ThumbLoader.Load( filepath ); + } + //Precache.Add( filename ); // diff --git a/engine/Sandbox.Engine/Scene/Components/Collider/ModelPhysics.PhysicsCreate.cs b/engine/Sandbox.Engine/Scene/Components/Collider/ModelPhysics.PhysicsCreate.cs index 4d7bee5a..e122a71b 100644 --- a/engine/Sandbox.Engine/Scene/Components/Collider/ModelPhysics.PhysicsCreate.cs +++ b/engine/Sandbox.Engine/Scene/Components/Collider/ModelPhysics.PhysicsCreate.cs @@ -157,6 +157,10 @@ public sealed partial class ModelPhysics foreach ( var part in physics.Parts ) { var bone = bones.GetBone( part.BoneName ); + + if ( bone is null ) + continue; + if ( !boneObjects.TryGetValue( bone, out var go ) ) continue; diff --git a/engine/Sandbox.Tools/Assets/AssetThumbnail.cs b/engine/Sandbox.Tools/Assets/AssetThumbnail.cs index 7b469977..a784874f 100644 --- a/engine/Sandbox.Tools/Assets/AssetThumbnail.cs +++ b/engine/Sandbox.Tools/Assets/AssetThumbnail.cs @@ -130,11 +130,7 @@ static class AssetThumbnail var fullPath = GetThumbnailFile( asset, true ); - await Task.Run( () => - { - if ( pix.HasAlpha ) pix.SavePng( fullPath ); - else pix.SaveJpg( fullPath ); - } ); + await Task.Run( () => pix.SavePng( fullPath ) ); EditorEvent.RunInterface( x => x.OnAssetThumbGenerated( asset ) ); } diff --git a/engine/Sandbox.Tools/Assets/AssetType.cs b/engine/Sandbox.Tools/Assets/AssetType.cs index 7a5aa0ec..3411f20b 100644 --- a/engine/Sandbox.Tools/Assets/AssetType.cs +++ b/engine/Sandbox.Tools/Assets/AssetType.cs @@ -8,6 +8,9 @@ public class AssetType { internal static Dictionary AssetTypeCache = new Dictionary(); + TypeDescription _typeDescription; + AssetTypeAttribute _assetTypeAttribute; + /// /// All currently registered asset types, including the base types such as models, etc. /// @@ -143,6 +146,11 @@ public class AssetType /// public Color Color { get; internal set; } = Color.Magenta; + /// + /// Flags for this asset type + /// + public AssetTypeFlags Flags => _assetTypeAttribute?.Flags ?? default; + public override string ToString() => FriendlyName; internal string IconPathSmall { get; set; } @@ -241,17 +249,20 @@ public class AssetType Icon64 = Icon128.Resize( 64, 64 ); } - private void Init( TypeDescription type, AssetTypeAttribute gr ) + private void Init( TypeDescription type, AssetTypeAttribute attribute ) { IsGameResource = true; + _typeDescription = type; + _assetTypeAttribute = attribute; + ResourceType = type.TargetType; - Category = gr.Category; - FriendlyName = gr.Name; - FileExtension = gr.Extension; + Category = attribute.Category; + FriendlyName = attribute.Name; + FileExtension = attribute.Extension; - GenerateGlyphs( gr ); + GenerateGlyphs( attribute ); // For game resources, use the background color specified in the attribute Color = "#67ac5c"; diff --git a/engine/Sandbox.Tools/Assets/TerrainMaterialCompiler.cs b/engine/Sandbox.Tools/Assets/TerrainMaterialCompiler.cs index 611e432d..79bb51cb 100644 --- a/engine/Sandbox.Tools/Assets/TerrainMaterialCompiler.cs +++ b/engine/Sandbox.Tools/Assets/TerrainMaterialCompiler.cs @@ -13,8 +13,6 @@ internal class TerrainMaterialCompiler : ResourceCompiler var filename = Context.AbsolutePath; var jsonString = File.ReadAllText( filename ); - Log.Info( $"tryna compile {filename}" ); - var docOptions = new JsonDocumentOptions(); docOptions.MaxDepth = 512; diff --git a/engine/Sandbox.Tools/ToolsDll.cs b/engine/Sandbox.Tools/ToolsDll.cs index b22e1482..c46dd9cd 100644 --- a/engine/Sandbox.Tools/ToolsDll.cs +++ b/engine/Sandbox.Tools/ToolsDll.cs @@ -305,4 +305,17 @@ internal class ToolsDll : IToolsDll } } + + public Bitmap GetThumbnail( string filename ) + { + var asset = AssetSystem.FindByPath( filename ); + if ( asset is null ) return null; + + var thumb = asset.GetAssetThumb( true ); + + // Sorry - we have no fast GetPixels + var pixels = thumb.GetPng(); + return Bitmap.CreateFromBytes( pixels ); + } + } diff --git a/engine/Sandbox.Tools/Utility/ProjectPublisher/PackageManifest.cs b/engine/Sandbox.Tools/Utility/ProjectPublisher/PackageManifest.cs index bac8d4e4..bc7261ff 100644 --- a/engine/Sandbox.Tools/Utility/ProjectPublisher/PackageManifest.cs +++ b/engine/Sandbox.Tools/Utility/ProjectPublisher/PackageManifest.cs @@ -323,11 +323,37 @@ public partial class ProjectPublisher // Add this file await AddFile( abs, rel ); + + // Should we add the thumbnail? + await TryAddThumbnail( asset ); } return true; } + async Task TryAddThumbnail( Asset asset ) + { + if ( asset.AssetType == null ) return; + + // don't do thumbs for built in assets, except models + if ( !asset.AssetType.IsGameResource && (asset.AssetType != AssetType.Model) ) + return; + + // they should explicitly opt into this + if ( asset.AssetType.IsGameResource && !asset.AssetType.Flags.Contains( AssetTypeFlags.IncludeThumbnails ) ) + return; + + var rel = asset.GetCompiledFile( false ); + var thumb = asset.GetAssetThumb( true ); + + if ( thumb is null ) return; + + var thumbName = $"{rel}.t.png"; + + var png = thumb.GetPng(); + await AddFile( png, thumbName ); + } + private async Task AddFile( string absPath, string relativePath ) { if ( !System.IO.File.Exists( absPath ) ) @@ -353,7 +379,7 @@ public partial class ProjectPublisher AbsolutePath = absPath }; - // run in a thread to make it super fast + // run in a thread to make it happen in the background await Task.Run( async () => { using ( var stream = info.OpenRead() ) @@ -400,10 +426,7 @@ public partial class ProjectPublisher /// This really exists only to dissallow dangerous extensions like .exe etc. /// So feel free to add anything non dangerous to this list. /// - public static string[] DissallowedExtensions = new string[] - { - ".dll", ".exe", ".csproj", ".sln", ".user", ".slnx" - }; + public static string[] DissallowedExtensions = [".dll", ".exe", ".csproj", ".sln", ".user", ".slnx", ".pdb"]; public static bool LooseFileAllowed( string file, bool allowSourceFiles ) {