Voxel/VoxelWorld.cs
namespace Monolith;

/// <summary>
/// The monolith itself: a dense grid of chunks. Pure data and math, no scene or network
/// awareness, so it can be unit reasoned about and driven identically on host and clients.
/// </summary>
public sealed class VoxelWorld
{
	public Vector3Int Size { get; private set; }
	public Vector3Int ChunkCounts { get; private set; }

	/// <summary>World-space position of voxel (0,0,0)'s minimum corner.</summary>
	public Vector3 Origin { get; private set; }

	public long SolidCount { get; private set; }
	public long InitialCount { get; private set; }

	private VoxelChunk[] chunks;

	/// <summary>Chunks whose geometry needs rebuilding. Consumed by the renderer on a budget.</summary>
	public readonly HashSet<int> DirtyChunks = new();

	public float Progress => InitialCount == 0 ? 1f : 1f - (float)((double)SolidCount / InitialCount);

	public VoxelWorld( Vector3Int size )
	{
		Size = size;

		ChunkCounts = new Vector3Int(
			CeilDiv( size.x, VoxelChunk.Size ),
			CeilDiv( size.y, VoxelChunk.Size ),
			CeilDiv( size.z, VoxelChunk.Size ) );

		chunks = new VoxelChunk[ChunkCounts.x * ChunkCounts.y * ChunkCounts.z];

		for ( int z = 0; z < ChunkCounts.z; z++ )
		for ( int y = 0; y < ChunkCounts.y; y++ )
		for ( int x = 0; x < ChunkCounts.x; x++ )
			chunks[ChunkIndex( x, y, z )] = new VoxelChunk( new Vector3Int( x, y, z ) );

		// Centre the monolith on the scene origin so it reads as floating in the void.
		Origin = new Vector3( size.x, size.y, size.z ) * -0.5f * Tuning.VoxelSize;
	}

	private static int CeilDiv( int a, int b ) => (a + b - 1) / b;

	public int ChunkCount => chunks.Length;
	public VoxelChunk GetChunkByIndex( int i ) => chunks[i];

	public int ChunkIndex( int cx, int cy, int cz )
		=> cx + cy * ChunkCounts.x + cz * ChunkCounts.x * ChunkCounts.y;

	public Vector3Int ChunkCoordFromIndex( int i )
	{
		int x = i % ChunkCounts.x;
		int y = (i / ChunkCounts.x) % ChunkCounts.y;
		int z = i / (ChunkCounts.x * ChunkCounts.y);
		return new Vector3Int( x, y, z );
	}

	// ---------------------------------------------------------------- generation

	/// <summary>Fills the whole box. Voxels outside <see cref="Size"/> but inside the padded
	/// chunk grid are left empty so the monolith has exact dimensions.</summary>
	public void FillSolid() => FillShape( ShapeKind.Box );

	/// <summary>
	/// Fills according to a shape predicate. Deterministic and position-only, so the host and
	/// every client build an identical world without transferring anything.
	/// </summary>
	public void FillShape( ShapeKind shape )
	{
		foreach ( var chunk in chunks )
			chunk.ClearAll();

		long solid = 0;

		for ( int z = 0; z < Size.z; z++ )
		for ( int y = 0; y < Size.y; y++ )
		for ( int x = 0; x < Size.x; x++ )
		{
			if ( !Stages.IsSolid( shape, Size, x, y, z ) )
				continue;

			SetSolidRaw( x, y, z );
			solid++;
		}

		SolidCount = solid;
		InitialCount = solid;

		MarkAllDirty();
	}

	public void MarkAllDirty()
	{
		DirtyChunks.Clear();
		for ( int i = 0; i < chunks.Length; i++ )
		{
			chunks[i].MeshDirty = true;
			DirtyChunks.Add( i );
		}
	}

	private void SetSolidRaw( int x, int y, int z )
	{
		var chunk = chunks[ChunkIndex( x / VoxelChunk.Size, y / VoxelChunk.Size, z / VoxelChunk.Size )];
		chunk.Set( x % VoxelChunk.Size, y % VoxelChunk.Size, z % VoxelChunk.Size );
	}

	/// <summary>
	/// Clients learn the original cube count from the host's snapshot header rather than
	/// deriving it, because by the time they join the monolith is already partly mined.
	/// </summary>
	public void SetInitialCount( long count ) => InitialCount = count;

	/// <summary>Recomputes SolidCount from the chunks. Use after applying a snapshot.</summary>
	public void RecountSolid()
	{
		long n = 0;
		foreach ( var chunk in chunks )
			n += chunk.SolidCount;

		SolidCount = n;
		if ( InitialCount == 0 )
			InitialCount = (long)Size.x * Size.y * Size.z;
	}

	// ---------------------------------------------------------------- queries

	public bool InBounds( int x, int y, int z )
		=> x >= 0 && y >= 0 && z >= 0 && x < Size.x && y < Size.y && z < Size.z;

	public bool IsSolid( int x, int y, int z )
	{
		if ( !InBounds( x, y, z ) )
			return false;

		var chunk = chunks[ChunkIndex( x / VoxelChunk.Size, y / VoxelChunk.Size, z / VoxelChunk.Size )];
		if ( chunk.IsEmpty ) return false;
		if ( chunk.IsFull ) return true;

		return chunk.Get( x % VoxelChunk.Size, y % VoxelChunk.Size, z % VoxelChunk.Size );
	}

	public Vector3 VoxelToWorld( int x, int y, int z )
		=> Origin + new Vector3( x + 0.5f, y + 0.5f, z + 0.5f ) * Tuning.VoxelSize;

	public Vector3Int WorldToVoxel( Vector3 world )
	{
		var local = (world - Origin) / Tuning.VoxelSize;
		return new Vector3Int(
			(int)MathF.Floor( local.x ),
			(int)MathF.Floor( local.y ),
			(int)MathF.Floor( local.z ) );
	}

	public BBox WorldBounds => new(
		Origin,
		Origin + new Vector3( Size.x, Size.y, Size.z ) * Tuning.VoxelSize );

	// ---------------------------------------------------------------- removal

	/// <summary>
	/// Removes every solid voxel within <paramref name="radius"/> voxels of the centre.
	/// Returns how many were actually removed. Deterministic, which is what lets us broadcast
	/// the operation rather than the resulting voxel list.
	/// </summary>
	public int RemoveSphere( Vector3Int centre, float radius )
	{
		if ( radius < 0.5f )
			return RemoveSingle( centre );

		int r = (int)MathF.Ceiling( radius );
		float rsq = radius * radius;
		int removed = 0;

		int minX = Math.Max( 0, centre.x - r ), maxX = Math.Min( Size.x - 1, centre.x + r );
		int minY = Math.Max( 0, centre.y - r ), maxY = Math.Min( Size.y - 1, centre.y + r );
		int minZ = Math.Max( 0, centre.z - r ), maxZ = Math.Min( Size.z - 1, centre.z + r );

		for ( int z = minZ; z <= maxZ; z++ )
		{
			int dz = z - centre.z;
			for ( int y = minY; y <= maxY; y++ )
			{
				int dy = y - centre.y;
				int dzy = dz * dz + dy * dy;
				if ( dzy > rsq ) continue;

				for ( int x = minX; x <= maxX; x++ )
				{
					int dx = x - centre.x;
					if ( dx * dx + dzy > rsq ) continue;

					if ( ClearVoxel( x, y, z ) )
						removed++;
				}
			}
		}

		return removed;
	}

	public int RemoveSingle( Vector3Int v )
		=> ClearVoxel( v.x, v.y, v.z ) ? 1 : 0;

	/// <summary>
	/// Shatters a fraction of what remains, chosen by a hash of position and a seed.
	///
	/// Deterministic on purpose: every client runs the same function over the same coordinates
	/// and reaches the same result, so this travels over the network as a seed and a fraction
	/// rather than as a list of several million voxels.
	/// </summary>
	public int Fracture( float fraction, int seed )
	{
		fraction = Math.Clamp( fraction, 0f, 1f );
		if ( fraction <= 0f ) return 0;

		uint threshold = (uint)(fraction * 1000f);
		int removed = 0;

		for ( int z = 0; z < Size.z; z++ )
		for ( int y = 0; y < Size.y; y++ )
		for ( int x = 0; x < Size.x; x++ )
		{
			if ( !IsSolid( x, y, z ) )
				continue;

			uint h = (uint)(x * 73856093) ^ (uint)(y * 19349663) ^ (uint)(z * 83492791) ^ (uint)seed;
			h ^= h >> 13;
			h *= 0x5bd1e995;
			h ^= h >> 15;

			if ( h % 1000u >= threshold )
				continue;

			if ( ClearVoxel( x, y, z ) )
				removed++;
		}

		return removed;
	}

	/// <summary>Clears one voxel and marks the owning chunk (and any touched neighbour) dirty.</summary>
	public bool ClearVoxel( int x, int y, int z )
	{
		if ( !InBounds( x, y, z ) )
			return false;

		int cx = x / VoxelChunk.Size, cy = y / VoxelChunk.Size, cz = z / VoxelChunk.Size;
		int lx = x % VoxelChunk.Size, ly = y % VoxelChunk.Size, lz = z % VoxelChunk.Size;

		var index = ChunkIndex( cx, cy, cz );
		if ( !chunks[index].Clear( lx, ly, lz ) )
			return false;

		SolidCount--;
		DirtyChunks.Add( index );

		// A voxel on a chunk face exposes geometry in the neighbouring chunk too.
		if ( lx == 0 ) TouchChunk( cx - 1, cy, cz );
		else if ( lx == VoxelChunk.Size - 1 ) TouchChunk( cx + 1, cy, cz );
		if ( ly == 0 ) TouchChunk( cx, cy - 1, cz );
		else if ( ly == VoxelChunk.Size - 1 ) TouchChunk( cx, cy + 1, cz );
		if ( lz == 0 ) TouchChunk( cx, cy, cz - 1 );
		else if ( lz == VoxelChunk.Size - 1 ) TouchChunk( cx, cy, cz + 1 );

		return true;
	}

	private void TouchChunk( int cx, int cy, int cz )
	{
		if ( cx < 0 || cy < 0 || cz < 0 ) return;
		if ( cx >= ChunkCounts.x || cy >= ChunkCounts.y || cz >= ChunkCounts.z ) return;

		var i = ChunkIndex( cx, cy, cz );
		chunks[i].MeshDirty = true;
		DirtyChunks.Add( i );
	}

	// ---------------------------------------------------------------- raycast

	/// <summary>
	/// Marches the voxel grid directly (Amanatides and Woo) instead of using physics. Exact,
	/// allocation free, and it means chunks never need collision meshes.
	/// </summary>
	public bool TraceRay( Vector3 start, Vector3 direction, float maxDistance, out VoxelHit hit )
	{
		hit = default;

		direction = direction.Normal;
		if ( direction.Length < 0.5f )
			return false;

		var bounds = WorldBounds;
		float tEnter = 0f;

		// If we start outside the monolith, skip ahead to where the ray enters its box.
		if ( !bounds.Contains( start ) )
		{
			if ( !RayBoxEntry( start, direction, bounds, maxDistance, out tEnter ) )
				return false;
		}

		// Nudge inside so the starting voxel is unambiguous on a face-exact entry.
		var p = start + direction * (tEnter + 0.001f);
		var v = WorldToVoxel( p );

		int stepX = direction.x > 0 ? 1 : -1;
		int stepY = direction.y > 0 ? 1 : -1;
		int stepZ = direction.z > 0 ? 1 : -1;

		float tDeltaX = MathF.Abs( direction.x ) < 1e-6f ? float.MaxValue : MathF.Abs( Tuning.VoxelSize / direction.x );
		float tDeltaY = MathF.Abs( direction.y ) < 1e-6f ? float.MaxValue : MathF.Abs( Tuning.VoxelSize / direction.y );
		float tDeltaZ = MathF.Abs( direction.z ) < 1e-6f ? float.MaxValue : MathF.Abs( Tuning.VoxelSize / direction.z );

		float tMaxX = NextBoundary( p.x, Origin.x, v.x, stepX, direction.x );
		float tMaxY = NextBoundary( p.y, Origin.y, v.y, stepY, direction.y );
		float tMaxZ = NextBoundary( p.z, Origin.z, v.z, stepZ, direction.z );

		var normal = Vector3Int.Zero;
		float travelled = tEnter;

		// The grid diagonal is a hard upper bound on how many voxels a ray can cross.
		int guard = (Size.x + Size.y + Size.z) * 2 + 8;

		while ( guard-- > 0 && travelled <= maxDistance )
		{
			if ( IsSolid( v.x, v.y, v.z ) )
			{
				hit = new VoxelHit
				{
					Voxel = v,
					Normal = normal,
					Distance = travelled,
					Position = start + direction * travelled,
					Hit = true
				};
				return true;
			}

			// Once we are past the far side of the box on any axis we can never come back.
			if ( (stepX > 0 && v.x >= Size.x) || (stepX < 0 && v.x < 0) ) return false;
			if ( (stepY > 0 && v.y >= Size.y) || (stepY < 0 && v.y < 0) ) return false;
			if ( (stepZ > 0 && v.z >= Size.z) || (stepZ < 0 && v.z < 0) ) return false;

			if ( tMaxX < tMaxY && tMaxX < tMaxZ )
			{
				v.x += stepX;
				travelled = tMaxX;
				tMaxX += tDeltaX;
				normal = new Vector3Int( -stepX, 0, 0 );
			}
			else if ( tMaxY < tMaxZ )
			{
				v.y += stepY;
				travelled = tMaxY;
				tMaxY += tDeltaY;
				normal = new Vector3Int( 0, -stepY, 0 );
			}
			else
			{
				v.z += stepZ;
				travelled = tMaxZ;
				tMaxZ += tDeltaZ;
				normal = new Vector3Int( 0, 0, -stepZ );
			}
		}

		return false;
	}

	private static float NextBoundary( float pos, float origin, int voxel, int step, float dir )
	{
		if ( MathF.Abs( dir ) < 1e-6f )
			return float.MaxValue;

		float boundary = origin + (voxel + (step > 0 ? 1 : 0)) * Tuning.VoxelSize;
		return (boundary - pos) / dir;
	}

	private static bool RayBoxEntry( Vector3 start, Vector3 dir, BBox box, float maxDistance, out float tEnter )
	{
		tEnter = 0f;
		float tMin = 0f;
		float tMax = maxDistance;

		for ( int axis = 0; axis < 3; axis++ )
		{
			float o = axis == 0 ? start.x : axis == 1 ? start.y : start.z;
			float d = axis == 0 ? dir.x : axis == 1 ? dir.y : dir.z;
			float lo = axis == 0 ? box.Mins.x : axis == 1 ? box.Mins.y : box.Mins.z;
			float hi = axis == 0 ? box.Maxs.x : axis == 1 ? box.Maxs.y : box.Maxs.z;

			if ( MathF.Abs( d ) < 1e-6f )
			{
				if ( o < lo || o > hi ) return false;
				continue;
			}

			float t1 = (lo - o) / d;
			float t2 = (hi - o) / d;
			if ( t1 > t2 ) (t1, t2) = (t2, t1);

			tMin = MathF.Max( tMin, t1 );
			tMax = MathF.Min( tMax, t2 );

			if ( tMin > tMax ) return false;
		}

		tEnter = tMin;
		return true;
	}
}

public struct VoxelHit
{
	public bool Hit;
	public Vector3Int Voxel;
	public Vector3Int Normal;
	public Vector3 Position;
	public float Distance;
}