Code/FloraStorage.cs
using System;
using System.Collections.Generic;
using Sandbox;

namespace RedSnail.FloraTool;

/// <summary>
/// Painted flora coverage, stored as a sparse chunked grid of density samples rather than one
/// transform per tree. Instances are regenerated from this plus a seed, so a forest of a hundred
/// thousand trees costs a few megabytes instead of tens - which matters because the scene sidecar
/// has to survive being committed to a repository.
///
/// The trade is that positions are derived, not authored: painting decides where flora *can* grow
/// and how densely, and the seed decides exactly where each trunk lands.
/// </summary>
public sealed class FloraStorage : BlobData
{
	public override int Version => 1;

	/// <summary>Cells along one edge of a chunk.</summary>
	public const int ChunkResolution = 32;

	/// <summary>
	/// World size of one density cell. Roughly a tree's footprint - each cell holds at most a
	/// handful of instances, so this is what bounds how tightly flora can pack.
	/// Changing it invalidates every painted scene, so it is a constant rather than a setting.
	/// </summary>
	public const float CellSize = 256.0f;

	public const float ChunkSize = ChunkResolution * CellSize;

	public const int CellsPerChunk = ChunkResolution * ChunkResolution;

	/// <summary>
	/// One coverage sample. Height and normal are baked at paint time so flora sits on whatever
	/// geometry was there, without the renderer having to trace anything at load.
	/// </summary>
	public struct Cell
	{
		public float Height;

		/// <summary>density (0-7) | normal.x (8-15) | normal.y (16-23) | entry index (24-31)</summary>
		public uint Packed;

		public readonly float Density => (Packed & 0xFF) / 255.0f;

		/// <summary>Index into the definition's entry list. 0xFF means "pick one by weight".</summary>
		public readonly int EntryIndex => (int)((Packed >> 24) & 0xFF);

		public readonly Vector3 Normal
		{
			get
			{
				var x = ((Packed >> 8) & 0xFF) / 127.5f - 1.0f;
				var y = ((Packed >> 16) & 0xFF) / 127.5f - 1.0f;
				var z = MathF.Sqrt( Math.Clamp( 1.0f - x * x - y * y, 0.0f, 1.0f ) );
				return new Vector3( x, y, z );
			}
		}

		public static uint Pack( float density, Vector3 normal, int entryIndex )
		{
			var d = (uint)Math.Clamp( density * 255.0f + 0.5f, 0.0f, 255.0f );
			var nx = (uint)Math.Clamp( (normal.x + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );
			var ny = (uint)Math.Clamp( (normal.y + 1.0f) * 127.5f + 0.5f, 0.0f, 255.0f );
			var e = (uint)Math.Clamp( entryIndex, 0, 255 );

			return d | (nx << 8) | (ny << 16) | (e << 24);
		}
	}

	public readonly record struct ChunkCoord( int X, int Y );

	private readonly Dictionary<ChunkCoord, Cell[]> _chunks = [];

	/// <summary>Bumped on every mutation so the renderer knows to regenerate.</summary>
	public int Revision { get; private set; }

	public int ChunkCount => _chunks.Count;

	public IReadOnlyDictionary<ChunkCoord, Cell[]> Chunks => _chunks;

	public static ChunkCoord WorldToChunk( Vector3 world ) => new(
		(int)MathF.Floor( world.x / ChunkSize ),
		(int)MathF.Floor( world.y / ChunkSize ) );

	public static Vector2 ChunkOrigin( ChunkCoord coord ) => new( coord.X * ChunkSize, coord.Y * ChunkSize );

	public static Vector3 ChunkCenter( ChunkCoord coord, float height = 0.0f )
	{
		var origin = ChunkOrigin( coord );
		return new Vector3( origin.x + ChunkSize * 0.5f, origin.y + ChunkSize * 0.5f, height );
	}

	private static int WorldToCell( float world ) => (int)MathF.Floor( world / CellSize );

	private static int FloorDiv( int a, int b ) => a >= 0 ? a / b : ~(~a / b);

	private static int Mod( int a, int b )
	{
		var r = a % b;
		return r < 0 ? r + b : r;
	}

	/// <summary>
	/// Writes a coverage sample, baking the surface height and normal alongside it. Density of zero
	/// frees the sample.
	/// </summary>
	public void SetCell( float worldX, float worldY, float density, float height, Vector3 normal, int entryIndex )
	{
		var cellX = WorldToCell( worldX );
		var cellY = WorldToCell( worldY );
		var coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );

		if ( !_chunks.TryGetValue( coord, out var cells ) )
		{
			if ( density <= 0.0f ) return;

			cells = new Cell[CellsPerChunk];
			_chunks[coord] = cells;
		}

		var index = Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution );
		cells[index] = new Cell { Height = height, Packed = Cell.Pack( density, normal, entryIndex ) };

		Revision++;
	}

	public Cell GetCell( float worldX, float worldY )
	{
		var cellX = WorldToCell( worldX );
		var cellY = WorldToCell( worldY );
		var coord = new ChunkCoord( FloorDiv( cellX, ChunkResolution ), FloorDiv( cellY, ChunkResolution ) );

		if ( !_chunks.TryGetValue( coord, out var cells ) )
			return default;

		return cells[Mod( cellY, ChunkResolution ) * ChunkResolution + Mod( cellX, ChunkResolution )];
	}

	/// <summary>Reduces coverage in a radius, removing samples that reach zero.</summary>
	public void Erase( Vector3 center, float radius, float strength )
	{
		var radiusSquared = radius * radius;

		var minCellX = WorldToCell( center.x - radius );
		var maxCellX = WorldToCell( center.x + radius );
		var minCellY = WorldToCell( center.y - radius );
		var maxCellY = WorldToCell( center.y + radius );

		var changed = false;

		for ( var cy = minCellY; cy <= maxCellY; cy++ )
		{
			for ( var cx = minCellX; cx <= maxCellX; cx++ )
			{
				var coord = new ChunkCoord( FloorDiv( cx, ChunkResolution ), FloorDiv( cy, ChunkResolution ) );
				if ( !_chunks.TryGetValue( coord, out var cells ) )
					continue;

				var wx = (cx + 0.5f) * CellSize;
				var wy = (cy + 0.5f) * CellSize;
				var dx = wx - center.x;
				var dy = wy - center.y;

				if ( dx * dx + dy * dy > radiusSquared )
					continue;

				var index = Mod( cy, ChunkResolution ) * ChunkResolution + Mod( cx, ChunkResolution );
				ref var cell = ref cells[index];

				if ( (cell.Packed & 0xFF) == 0 )
					continue;

				var density = Math.Max( cell.Density - strength, 0.0f );
				cell.Packed = density <= 0.0f
					? 0u
					: Cell.Pack( density, cell.Normal, cell.EntryIndex );

				changed = true;
			}
		}

		if ( !changed )
			return;

		PruneEmptyChunks();
		Revision++;
	}

	public void ClearAll()
	{
		if ( _chunks.Count == 0 ) return;

		_chunks.Clear();
		Revision++;
	}

	private void PruneEmptyChunks()
	{
		List<ChunkCoord> empty = null;

		foreach ( var (coord, cells) in _chunks )
		{
			var used = false;
			for ( var i = 0; i < cells.Length; i++ )
			{
				if ( (cells[i].Packed & 0xFF) != 0 ) { used = true; break; }
			}

			if ( !used )
			{
				empty ??= [];
				empty.Add( coord );
			}
		}

		if ( empty is null ) return;

		foreach ( var coord in empty )
			_chunks.Remove( coord );
	}

	/// <summary>
	/// Writes only the painted cells. Storing them densely cost 8KB per chunk however little of it
	/// was painted, and a brush stroke across a landscape touches a lot of chunks.
	///
	/// Each painted cell costs 2 bytes more than it did dense (its index), so a chunk past about 80%
	/// coverage is cheaper stored densely. Both layouts are written and each chunk says which it used.
	/// </summary>
	public override void Serialize( ref Writer writer )
	{
		writer.Stream.Write( _chunks.Count );

		foreach ( var (coord, cells) in _chunks )
		{
			writer.Stream.Write( coord.X );
			writer.Stream.Write( coord.Y );

			var painted = 0;
			for ( var i = 0; i < CellsPerChunk; i++ )
			{
				if ( (cells[i].Packed & 0xFF) != 0 ) painted++;
			}

			var sparse = painted * 10 < CellsPerChunk * 8;
			writer.Stream.Write( sparse );

			if ( !sparse )
			{
				for ( var i = 0; i < CellsPerChunk; i++ )
				{
					writer.Stream.Write( cells[i].Height );
					writer.Stream.Write( cells[i].Packed );
				}

				continue;
			}

			writer.Stream.Write( painted );

			for ( var i = 0; i < CellsPerChunk; i++ )
			{
				if ( (cells[i].Packed & 0xFF) == 0 )
					continue;

				writer.Stream.Write( (ushort)i );
				writer.Stream.Write( cells[i].Height );
				writer.Stream.Write( cells[i].Packed );
			}
		}
	}

	public override void Deserialize( ref Reader reader )
	{
		_chunks.Clear();

		var chunkCount = reader.Stream.Read<int>();

		for ( var c = 0; c < chunkCount; c++ )
		{
			var coord = new ChunkCoord( reader.Stream.Read<int>(), reader.Stream.Read<int>() );
			var cells = new Cell[CellsPerChunk];

			if ( reader.Stream.Read<bool>() )
			{
				var painted = reader.Stream.Read<int>();

				for ( var p = 0; p < painted; p++ )
				{
					var index = reader.Stream.Read<ushort>();
					var height = reader.Stream.Read<float>();
					var packed = reader.Stream.Read<uint>();

					if ( index < CellsPerChunk )
					{
						cells[index].Height = height;
						cells[index].Packed = packed;
					}
				}
			}
			else
			{
				for ( var i = 0; i < CellsPerChunk; i++ )
				{
					cells[i].Height = reader.Stream.Read<float>();
					cells[i].Packed = reader.Stream.Read<uint>();
				}
			}

			_chunks[coord] = cells;
		}

		Revision++;
	}
}