Editor/Tileset/TilesetResourcePreview.cs

Editor asset preview for .tileset resources. It loads a tileset texture, creates preview and tile-0 textures, and builds a small scene with sprites and text to render as the asset browser thumbnail/inspector preview.

File AccessNative Interop
using Editor.Assets;
using Saandy.Tilemapper;
using Sandbox;
using Sandbox.Rendering;
using System;
using System.Threading.Tasks;

namespace Saandy.Editor.Tilemapper;

/// <summary>
/// Asset browser thumbnail / inspector preview for .tileset resources.
///
/// This follows the same sprite path as the built-in image preview:
/// Texture -> Sprite.Frame.Texture -> SpriteRenderer.Sprite.
/// Do not assign SpriteRenderer.Texture directly.
/// </summary>
[AssetPreview( "tileset" )]
public sealed class TilesetResourcePreview : AssetPreview
{
	private const float PreviewSize = 16.0f;

	// Main atlas preview size.
	private const float MaxImageWidth = 15.5f;
	private const float MaxImageHeight = 15.5f;
	private const float ImageBottomPadding = -5f;

	// Big tile 0 preview in the top-right corner.
	private const float Tile0PreviewSize = 10f;
	private const float Tile0PreviewPadding = 0.45f;
	private const float Tile0PreviewBorder = 0.45f;

	private TilesetResource _tileset;
	private Texture _sourceTexture;
	private Texture _previewTexture;
	private bool _ownsPreviewTexture;

	private Texture _tile0Texture;
	private bool _ownsTile0Texture;

	private int _sourceTextureWidth = 1;
	private int _sourceTextureHeight = 1;

	private string _brushText = "Manual";

	private SpriteRenderer _imageSprite;
	private SpriteRenderer _tile0Sprite;

	public override bool IsAnimatedPreview => false;
	public override bool UsePixelEvaluatorForThumbs => true;

	public TilesetResourcePreview( Asset asset ) : base( asset )
	{
	}

	public override Task InitializeAsset()
	{
		_tileset = Asset.LoadResource<TilesetResource>();

		_brushText = _tileset != null
			? GetBrushDisplayName( _tileset.BrushType )
			: "No TilesetResource";

		_sourceTexture = LoadTilesetTexture( _tileset );
		CacheSourceTextureSize( _sourceTexture );

		_previewTexture = CreateSafePreviewTexture( _sourceTexture );
		_tile0Texture = CreateTilePreviewTexture( _sourceTexture, 0 );

		using ( Scene.Push() )
		{
			CreateBackground();
			CreateImage();
			CreateTile0Preview();

			CreateText(
				"Brush",
				_brushText,
				new Vector3( -12.0f, 0.0f, -6f ),
				18.0f,
				0.125f,
				Color.White
			);

			SceneCenter = Vector3.Zero;
			SceneSize = new Vector3( PreviewSize, PreviewSize, PreviewSize );
		}

		return Task.CompletedTask;
	}

	public override void UpdateScene( float cycle, float timeStep )
	{
		base.UpdateScene( cycle, timeStep );

		if ( Camera == null )
			return;

		Camera.Orthographic = true;
		Camera.OrthographicHeight = PreviewSize;
		Camera.WorldPosition = Vector3.Forward * -200.0f;
		Camera.WorldRotation = Rotation.LookAt( Vector3.Forward );
	}

	public override void Dispose()
	{
		if ( _ownsPreviewTexture )
		{
			_previewTexture?.Dispose();
			_previewTexture = null;
			_ownsPreviewTexture = false;
		}

		if ( _ownsTile0Texture )
		{
			_tile0Texture?.Dispose();
			_tile0Texture = null;
			_ownsTile0Texture = false;
		}

		base.Dispose();
	}

	private void CreateBackground()
	{
		var backgroundObject = new GameObject( true, "Tileset Preview Background" );
		backgroundObject.WorldPosition = Vector3.Forward;
		backgroundObject.Flags = backgroundObject.Flags.WithFlag( GameObjectFlags.EditorOnly, true );

		var background = AddSprite(
			backgroundObject,
			GetCheckerboardTexture(),
			new Vector2( PreviewSize, PreviewSize )
		);

		background.Color = Color.White;
	}

	private void CreateImage()
	{
		PrimaryObject = new GameObject( true, "Tileset Preview Image" );
		PrimaryObject.Flags = PrimaryObject.Flags.WithFlag( GameObjectFlags.EditorOnly, true );

		if ( _previewTexture == null || !_previewTexture.IsValid )
		{
			var missingSize = new Vector2( MaxImageWidth, MaxImageHeight );
			PrimaryObject.WorldPosition = GetBottomAlignedImagePosition( missingSize );

			_imageSprite = AddSprite( PrimaryObject, Texture.White, missingSize );
			_imageSprite.Color = Color.FromBytes( 60, 24, 30 );

			CreateText(
				"Missing Image",
				"NO IMAGE",
				new Vector3( -8.0f, 0.0f, 0.0f ),
				20.0f,
				0.008f,
				Color.FromBytes( 255, 130, 130 )
			);

			return;
		}

		var imageSize = GetImageWorldSize();
		PrimaryObject.WorldPosition = GetBottomAlignedImagePosition( imageSize );

		_imageSprite = AddSprite( PrimaryObject, _previewTexture, imageSize );
		_imageSprite.Color = Color.White;
	}

	private Vector3 GetBottomAlignedImagePosition( Vector2 imageSize )
	{
		float bottom = -(PreviewSize * 0.5f) + ImageBottomPadding;
		float centerZ = bottom + imageSize.y * 0.5f;

		return new Vector3( 0.0f, 0.0f, centerZ );
	}

	private void CreateTile0Preview()
	{
		if ( _tile0Texture == null || !_tile0Texture.IsValid )
			return;

		float half = PreviewSize * 0.5f;

		float y = half - (Tile0PreviewSize * 0.5f) - Tile0PreviewPadding;
		float z = half - (Tile0PreviewSize * 0.5f) - Tile0PreviewPadding;

		var backgroundObject = new GameObject( true, "Tileset Preview Tile 0 Background" );
		backgroundObject.WorldPosition = new Vector3( -6.5f, y, z );
		backgroundObject.Flags = backgroundObject.Flags.WithFlag( GameObjectFlags.EditorOnly, true );

		var background = AddSprite(
			backgroundObject,
			Texture.White,
			new Vector2( Tile0PreviewSize + Tile0PreviewBorder, Tile0PreviewSize + Tile0PreviewBorder )
		);

		background.Color = Color.Black.WithAlpha( 0.78f );

		var tileObject = new GameObject( true, "Tileset Preview Tile 0" );
		tileObject.WorldPosition = new Vector3( -7.0f, y, z );
		tileObject.Flags = tileObject.Flags.WithFlag( GameObjectFlags.EditorOnly, true );

		_tile0Sprite = AddSprite( tileObject, _tile0Texture, GetTile0WorldSize() );
		_tile0Sprite.Color = Color.White;
	}

	private static SpriteRenderer AddSprite( GameObject gameObject, Texture texture, Vector2 size )
	{
		var sprite = gameObject.AddComponent<SpriteRenderer>();
		sprite.Sprite = MakeSprite( texture );
		sprite.Size = size;
		sprite.IsSorted = true;
		sprite.TextureFilter = FilterMode.Point;
		return sprite;
	}

	private static Sprite MakeSprite( Texture texture ) => new()
	{
		Animations =
		[
			new Sprite.Animation
			{
				Name = "Default",
				Frames =
				[
					new Sprite.Frame
					{
						Texture = texture
					}
				]
			}
		]
	};

	private Texture LoadTilesetTexture( TilesetResource tileset )
	{
		if ( tileset == null )
			return null;

		Texture texture = tileset.GetTexture();

		if ( texture != null && texture.IsValid )
			return texture;

		if ( string.IsNullOrWhiteSpace( tileset.FilePath ) )
			return null;

		texture = Texture.Load( tileset.FilePath );

		if ( texture != null && texture.IsValid )
			return texture;

		return null;
	}

	private void CacheSourceTextureSize( Texture texture )
	{
		_sourceTextureWidth = 1;
		_sourceTextureHeight = 1;

		if ( texture == null || !texture.IsValid )
			return;

		_sourceTextureWidth = Math.Max( 1, texture.Width );
		_sourceTextureHeight = Math.Max( 1, texture.Height );
	}

	private Texture CreateSafePreviewTexture( Texture texture )
	{
		_ownsPreviewTexture = false;

		if ( texture == null || !texture.IsValid )
			return null;

		try
		{
			using ( EditorUtility.DisableTextureStreaming() )
			{
				texture.MarkUsed();

				using var bitmap = texture.GetBitmap( 0 );

				if ( bitmap != null )
				{
					var preview = bitmap.ToTexture( false );

					if ( preview != null && preview.IsValid )
					{
						_ownsPreviewTexture = true;
						return preview;
					}
				}
			}
		}
		catch
		{
			// Fall back to the original texture if the editor cannot read a bitmap from it.
		}

		return texture;
	}

	private Texture CreateTilePreviewTexture( Texture texture, ushort tileId )
	{
		_ownsTile0Texture = false;

		if ( texture == null || !texture.IsValid )
			return null;

		Rect sourceRect = GetTileSourceRect( tileId );

		if ( sourceRect.Width <= 0 || sourceRect.Height <= 0 )
			return null;

		try
		{
			using ( EditorUtility.DisableTextureStreaming() )
			{
				texture.MarkUsed();

				using var bitmap = texture.GetBitmap( 0 );

				if ( bitmap == null )
					return null;

				int sourceWidth = Math.Max( 1, texture.Width );
				int sourceHeight = Math.Max( 1, texture.Height );

				var pixels = bitmap.GetPixels();

				int left = Math.Clamp( (int)MathF.Floor( sourceRect.Left ), 0, sourceWidth - 1 );
				int top = Math.Clamp( (int)MathF.Floor( sourceRect.Top ), 0, sourceHeight - 1 );
				int right = Math.Clamp( (int)MathF.Ceiling( sourceRect.Right ), left + 1, sourceWidth );
				int bottom = Math.Clamp( (int)MathF.Ceiling( sourceRect.Bottom ), top + 1, sourceHeight );

				int width = Math.Max( 1, right - left );
				int height = Math.Max( 1, bottom - top );

				byte[] data = new byte[width * height * 4];

				for ( int y = 0; y < height; y++ )
				{
					for ( int x = 0; x < width; x++ )
					{
						int sourceX = left + x;
						int sourceY = top + y;

						int sourceIndex = sourceY * sourceWidth + sourceX;
						int targetIndex = (y * width + x) * 4;

						if ( sourceIndex < 0 || sourceIndex >= pixels.Length )
							continue;

						Color color = pixels[sourceIndex];

						data[targetIndex + 0] = FloatToByte( color.r );
						data[targetIndex + 1] = FloatToByte( color.g );
						data[targetIndex + 2] = FloatToByte( color.b );
						data[targetIndex + 3] = FloatToByte( color.a );
					}
				}

				var tileTexture = Texture.Create( width, height )
					.WithData( data )
					.WithName( $"tileset_resource_preview_tile_{tileId}" )
					.Finish();

				if ( tileTexture != null && tileTexture.IsValid )
				{
					_ownsTile0Texture = true;
					return tileTexture;
				}
			}
		}
		catch
		{
		}

		return null;
	}

	private Rect GetTileSourceRect( ushort tileId )
	{
		if ( _tileset?.Tiles != null && tileId < _tileset.Tiles.Count )
		{
			var tile = _tileset.Tiles[tileId];

			if ( tile != null && tile.SourceRect.Width > 0 && tile.SourceRect.Height > 0 )
				return tile.SourceRect;
		}

		int tileW = Math.Max( 1, _tileset?.TileSize.x ?? 16 );
		int tileH = Math.Max( 1, _tileset?.TileSize.y ?? 16 );

		return new Rect( 0, 0, tileW, tileH );
	}

	private Vector2 GetImageWorldSize()
	{
		// Keep the readable large atlas-preview behavior.
		// The tile 0 overlay gives the real per-tile preview.
		float width = 100.0f;
		float height = 100.0f;

		float scale = Math.Min(
			MaxImageWidth / width,
			MaxImageHeight / height
		);

		if ( float.IsNaN( scale ) || float.IsInfinity( scale ) || scale <= 0.0f )
			return new Vector2( MaxImageWidth, MaxImageHeight );

		return new Vector2(
			Math.Max( 0.1f, width * scale ),
			Math.Max( 0.1f, height * scale )
		);
	}

	private Vector2 GetTile0WorldSize()
	{
		if ( _tile0Texture == null || !_tile0Texture.IsValid )
			return new Vector2( Tile0PreviewSize, Tile0PreviewSize );

		float width = Math.Max( 1.0f, _tile0Texture.Width );
		float height = Math.Max( 1.0f, _tile0Texture.Height );

		float scale = Math.Min(
			Tile0PreviewSize / width,
			Tile0PreviewSize / height
		);

		if ( float.IsNaN( scale ) || float.IsInfinity( scale ) || scale <= 0.0f )
			return new Vector2( Tile0PreviewSize, Tile0PreviewSize );

		return new Vector2(
			Math.Max( 0.1f, width * scale ),
			Math.Max( 0.1f, height * scale )
		);
	}

	private void CreateText( string objectName, string textValue, Vector3 worldPosition, float fontSize, float scale, Color color )
	{
		if ( string.IsNullOrWhiteSpace( textValue ) )
			return;

		var textObject = new GameObject( true, $"Tileset Preview {objectName}" );
		textObject.WorldPosition = worldPosition;
		textObject.Flags = textObject.Flags.WithFlag( GameObjectFlags.EditorOnly, true );

		var text = textObject.AddComponent<TextRenderer>();
		text.Text = textValue;
		text.Color = color;
		text.FontSize = fontSize;
		text.Scale = scale;
		text.Billboard = TextRenderer.BillboardMode.Always;
		text.HorizontalAlignment = TextRenderer.HAlignment.Center;
		text.VerticalAlignment = TextRenderer.VAlignment.Center;
		text.FogStrength = 0.0f;

		text.TextScope = new TextRendering.Scope()
		{
			Text = text.Text,
			FontSize = text.FontSize,
			TextColor = text.Color,
			FontWeight = 400,
			LineHeight = 100,

			Outline = new TextRendering.Outline()
			{
				Enabled = true,
				Color = Color.Black,
				Size = 4f
			}
		};
	}

	private static byte FloatToByte( float value )
	{
		return (byte)Math.Clamp( MathF.Round( value * 255.0f ), 0.0f, 255.0f );
	}

	private static string GetBrushDisplayName( BrushType brushType )
	{
		return brushType.ToString() switch
		{
			"Manual" => "Manual",
			"Edge2x2" => "2x2 Edge",
			"Bitmask2x2Edge" => "2x2 Edge",
			"Complex3x3" => "3x3 Complex",
			"Bitmask3x3Complex" => "3x3 Complex",
			_ => brushType.ToString()
		};
	}

	private static Texture _checkerboardTexture;

	private static Texture GetCheckerboardTexture()
	{
		if ( _checkerboardTexture != null && _checkerboardTexture.IsValid )
			return _checkerboardTexture;

		const int cells = 16;
		const int cellSize = 8;
		const int size = cells * cellSize;

		var light = new Color32( 82, 82, 82 );
		var dark = new Color32( 52, 52, 52 );

		var data = new byte[size * size * 4];

		for ( int y = 0; y < size; y++ )
		{
			for ( int x = 0; x < size; x++ )
			{
				var color = ((x / cellSize) + (y / cellSize)) % 2 == 0 ? light : dark;
				int index = (y * size + x) * 4;

				data[index + 0] = color.r;
				data[index + 1] = color.g;
				data[index + 2] = color.b;
				data[index + 3] = 255;
			}
		}

		_checkerboardTexture = Texture.Create( size, size )
			.WithData( data )
			.WithName( "tileset_resource_preview_checkerboard" )
			.Finish();

		return _checkerboardTexture;
	}
}