Code/TileAtlas.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace SpriteTools;

/// <summary>
/// A class that re-packs a tileset with 1px borders to avoid bleeding.
/// </summary>
public class TileAtlas
{
	Texture Texture;
	Vector2 OriginalTileSize;
	Vector2Int TileSize;
	Vector2Int TileCounts;
	Dictionary<Vector2Int, Texture> TileCache = new();


	public static Dictionary<TilesetResource, TileAtlas> Cache = new();

	public Vector2 GetTiling ()
	{
		return (Vector2)OriginalTileSize / Texture.Size;
	}

	public Vector2 GetOffset ( Vector2Int cellPosition )
	{
		return new Vector2( cellPosition.x * TileSize.x + 1, cellPosition.y * TileSize.y + 1 ) / Texture.Size;
	}

	public static TileAtlas FromTileset ( TilesetResource tilesetResource )
	{
		if ( tilesetResource is null ) return null;

		if ( Cache?.ContainsKey( tilesetResource ) ?? false )
		{
			return Cache[tilesetResource];
		}

		if ( tilesetResource.Tiles.Count() == 0 )
		{
			return null;
		}

		if ( tilesetResource.Tiles.Any( x => x?.Tileset is null ) )
		{
			return null;
		}

		var path = tilesetResource.FilePath;
		if ( !FileSystem.Mounted.FileExists( path ) )
		{
			Log.Error( $"Tileset texture file {path} does not exist." );
			return null;
		}
		var texture = Texture.LoadFromFileSystem( path, FileSystem.Mounted );
		var atlas = new TileAtlas();

		var tileSize = tilesetResource.TileSize;
		atlas.TileSize = tileSize + Vector2Int.One * 2;
		atlas.OriginalTileSize = tileSize;

		var hTiles = tilesetResource.Tiles.Max( x => x.Position.x + x.Size.x );
		var vTiles = tilesetResource.Tiles.Max( x => x.Position.y + x.Size.y );
		atlas.TileCounts = new Vector2Int( hTiles, vTiles );

		var textureSize = new Vector2Int( hTiles * ( tileSize.x + 2 ), vTiles * ( tileSize.y + 2 ) );

		byte[] textureData = new byte[textureSize.x * textureSize.y * 4];
		for ( int i = 0; i < textureSize.x; i++ )
		{
			for ( int j = 0; j < textureSize.y; j++ )
			{
				var ind = ( j * textureSize.x + i ) * 4;
				textureData[ind] = 0;
				textureData[ind + 1] = 0;
				textureData[ind + 2] = 0;
				textureData[ind + 3] = 0;
			}
		}

		var pixels = texture.GetPixels();

		foreach ( var tile in tilesetResource.Tiles )
		{
			for ( int n = 0; n < tile.Size.x; n++ )
			{
				for ( int m = 0; m < tile.Size.y; m++ )
				{
					var cellPos = tile.Position + new Vector2Int( n, m );

					var tSize = tileSize * tile.Size;
					var tPos = cellPos * atlas.TileSize + Vector2Int.One;
					var sampleX = cellPos.x * tileSize.x;
					var sampleY = cellPos.y * tileSize.y;
					for ( int i = -1; i <= tSize.x; i++ )
					{
						for ( int j = -1; j <= tSize.y; j++ )
						{
							var sampleInd = (int)( ( sampleY + Math.Clamp( j, 0, tSize.y - 1 ) ) * texture.Size.x + sampleX + Math.Clamp( i, 0, tSize.x - 1 ) );
							var color = pixels[sampleInd];
							var ind = ( ( tPos.y + j ) * textureSize.x + tPos.x + i ) * 4;
							if ( ind < 0 || ind >= textureData.Length ) continue;
							textureData[ind + 0] = color.r;
							textureData[ind + 1] = color.g;
							textureData[ind + 2] = color.b;
							textureData[ind + 3] = color.a;
						}
					}


				}
			}

		}

		var builder = Texture.Create( textureSize.x, textureSize.y );
		builder.WithData( textureData );
		builder.WithMips( 0 );
		atlas.Texture = builder.Finish();

		Cache[tilesetResource] = atlas;

		return atlas;
	}

	public Texture GetTextureFromCell ( Vector2Int cellPosition )
	{
		if ( TileCache.ContainsKey( cellPosition ) )
		{
			return TileCache[cellPosition];
		}

		int x = cellPosition.x * TileSize.x + 1;
		int y = cellPosition.y * TileSize.y + 1;
		int outputSizeX = TileSize.x - 2;
		int outputSizeY = TileSize.y - 2;
		byte[] textureData = new byte[outputSizeX * outputSizeY * 4];
		var pixels = Texture.GetPixels();
		for ( int i = 0; i < outputSizeX; i++ )
		{
			for ( int j = 0; j < outputSizeY; j++ )
			{
				int ind = ( i + j * outputSizeX ) * 4;
				int sampleIndex = (int)( x + i + ( y + j ) * Texture.Size.x );
				var color = pixels[sampleIndex];
				textureData[ind + 0] = color.r;
				textureData[ind + 1] = color.g;
				textureData[ind + 2] = color.b;
				textureData[ind + 3] = color.a;
			}
		}

		var builder = Texture.Create( outputSizeX, outputSizeY );
		builder.WithData( textureData );
		builder.WithMips( 0 );
		var texture = builder.Finish();
		TileCache[cellPosition] = texture;
		return texture;
	}

	// Cast to texture
	public static implicit operator Texture ( TileAtlas atlas )
	{
		return atlas?.Texture ?? null;
	}

	public static void ClearCache ( string path = "" )
	{
		if ( path.StartsWith( "/" ) ) path = path.Substring( 1 );
		if ( string.IsNullOrEmpty( path ) )
		{
			Cache.Clear();
		}
		else
		{
			Cache = Cache.Where( x => x.Key.FilePath != path ).ToDictionary( x => x.Key, x => x.Value );
		}
	}

	public static void ClearCache ( TilesetResource tileset )
	{
		Cache.Remove( tileset );
	}
}