Code/Components/GaussianSplatVolume.cs
using System.Runtime.InteropServices;

namespace Sandbox;

/// <summary>
/// Controls whether splats inside this volume are included or excluded from rendering.
/// </summary>
public enum SplatVolumeMode
{
	/// <summary>
	/// When any Add volume exists, ONLY splats inside Add volumes are rendered.
	/// Multiple Add volumes are unioned together.
	/// </summary>
	Add,

	/// <summary>
	/// Splats inside this volume are hidden, even if inside an Add volume.
	/// Use for cutting holes in splat clouds.
	/// </summary>
	Subtract,

	/// <summary>
	/// Splats inside this volume are tinted with a specified color.
	/// The tint is applied before lighting so it interacts naturally with scene lights.
	/// </summary>
	Color
}

/// <summary>
/// Shape used for the volume boundary test.
/// </summary>
public enum SplatVolumeShape
{
	Box,
	Sphere
}

/// <summary>
/// A boolean volume that controls Gaussian splat visibility.
/// Add volumes restrict rendering to only splats within their bounds.
/// Subtract volumes carve holes, hiding splats even if inside an Add volume.
/// Supports smooth falloff for soft transitions.
/// </summary>
[Title( "Gaussian Splat Volume" )]
[Category( "Rendering" )]
[Icon( "crop_free" )]
public sealed class GaussianSplatVolume : Component, Component.ExecuteInEditor
{
	private SceneGaussianSplatSystem _system;

	/// <summary>
	/// Whether this volume adds or subtracts splat visibility.
	/// </summary>
	[Property, Order( -10 )]
	public SplatVolumeMode Mode { get; set; } = SplatVolumeMode.Subtract;

	/// <summary>
	/// Shape of the volume boundary.
	/// </summary>
	[Property, Order( -9 )]
	public SplatVolumeShape Shape { get; set; } = SplatVolumeShape.Box;

	/// <summary>
	/// Size of the box volume in world units (inches). Only used when Shape is Box.
	/// </summary>
	[Property, Group( "Shape" ), ShowIf( nameof( Shape ), SplatVolumeShape.Box )]
	public Vector3 Scale { get; set; } = new Vector3( 100, 100, 100 );

	/// <summary>
	/// Radius of the sphere volume in world units (inches). Only used when Shape is Sphere.
	/// </summary>
	[Property, Group( "Shape" ), ShowIf( nameof( Shape ), SplatVolumeShape.Sphere ), Range( 1f, 10000f )]
	public float Radius { get; set; } = 50f;

	/// <summary>
	/// Strength of the volume effect on splat opacity.
	/// At 1.0, splats are fully shown (Add) or fully hidden (Subtract).
	/// Lower values partially affect opacity for subtle blending.
	/// </summary>
	[Property, Group( "Effect" ), Range( 0f, 1f )]
	public float Intensity { get; set; } = 1.0f;

	/// <summary>
	/// Distance in world units over which the volume effect fades at the boundary.
	/// 0 = hard edge, higher values = smoother transition. Useful for soft holes.
	/// </summary>
	[Property, Group( "Effect" ), Range( 0f, 500f )]
	public float Falloff { get; set; } = 0f;

	/// <summary>
	/// Tint color applied to splats inside this volume. Only used when Mode is Color.
	/// Applied before lighting so it interacts naturally with scene lights.
	/// </summary>
	[Property, Group( "Effect" ), ShowIf( nameof( Mode ), SplatVolumeMode.Color )]
	public Color TintColor { get; set; } = Color.Red;

	/// <summary>
	/// When set, this volume only affects splat renderers that have at least one of these tags.
	/// Leave empty to affect all splat renderers.
	/// </summary>
	[Property, Group( "Tags" ), Title( "Include Tags" )]
	public TagSet IncludeTags { get; set; }

	/// <summary>
	/// Splat renderers with any of these tags will be excluded from this volume's effect.
	/// Takes priority over IncludeTags.
	/// </summary>
	[Property, Group( "Tags" ), Title( "Exclude Tags" )]
	public TagSet ExcludeTags { get; set; }

	protected override void OnEnabled()
	{
		_system = SceneGaussianSplatSystem.GetOrCreate( Scene.SceneWorld );
		_system.RegisterVolume( this );
	}

	protected override void OnDisabled()
	{
		_system?.UnregisterVolume( this );
		_system = null;
	}

	/// <summary>
	/// Returns true if this volume should affect a splat object with the given tags.
	/// ExcludeTags take priority over IncludeTags.
	/// </summary>
	internal bool AppliesToObject( TagSet objectTags )
	{
		// Exclude takes priority — if any exclude tag matches, skip this volume
		if ( ExcludeTags is not null && ExcludeTags.Count() > 0 && objectTags.HasAny( ExcludeTags ) )
			return false;

		// If include tags are set, require at least one match
		if ( IncludeTags is not null && IncludeTags.Count() > 0 )
			return objectTags.HasAny( IncludeTags );

		// No tag filters — applies to everything
		return true;
	}

	/// <summary>
	/// Build the GPU-ready volume data struct for this volume's current state.
	/// </summary>
	internal VolumeGpuData BuildGpuData()
	{
		var data = new VolumeGpuData();

		// Mode encoding: 0=Add, 1=Subtract, 2=Color
		float modeValue = Mode switch
		{
			SplatVolumeMode.Add => 0f,
			SplatVolumeMode.Subtract => 1f,
			SplatVolumeMode.Color => 2f,
			_ => 0f
		};

		data.Params = new Vector4(
			Intensity,
			Falloff,
			modeValue,
			Shape == SplatVolumeShape.Box ? 0f : 1f
		);

		data.TintColor = new Vector4( TintColor.r, TintColor.g, TintColor.b, TintColor.a );

		if ( Shape == SplatVolumeShape.Box )
		{
			// WorldToLocal: strips position and rotation, leaving the point in the
			// box's local frame where the origin is center and axes are aligned.
			// No scale normalization — distances remain in world units.
			var invRot = WorldRotation.Inverse;
			var worldToLocal = Matrix.CreateTranslation( -WorldPosition )
				* Matrix.CreateRotation( invRot );

			// Transpose for shader column-vector convention: mul(M, v)
			data.WorldToLocal = worldToLocal.Transpose();
			data.ShapeData = new Vector4( Scale.x * 0.5f, Scale.y * 0.5f, Scale.z * 0.5f, 0f );
		}
		else
		{
			// Sphere: store center and radius directly
			data.WorldToLocal = Matrix.Identity;
			data.ShapeData = new Vector4( WorldPosition.x, WorldPosition.y, WorldPosition.z, Radius );
		}

		return data;
	}

	protected override void DrawGizmos()
	{
		if ( !Gizmo.IsSelected && !Gizmo.IsHovered )
			return;

		var alpha = Gizmo.IsSelected ? 0.3f : 0.1f;
		Gizmo.Draw.Color = Mode switch
		{
			SplatVolumeMode.Add => Gizmo.Colors.Green.WithAlpha( alpha ),
			SplatVolumeMode.Subtract => Gizmo.Colors.Red.WithAlpha( alpha ),
			SplatVolumeMode.Color => TintColor.WithAlpha( alpha ),
			_ => Gizmo.Colors.Green.WithAlpha( alpha )
		};

		// Draw in local space (Gizmo system handles the transform)
		if ( Shape == SplatVolumeShape.Box )
		{
			var box = new BBox( -Scale * 0.5f, Scale * 0.5f );
			Gizmo.Draw.LineBBox( box );

			// Draw falloff inner boundary
			if ( Falloff > 0f )
			{
				Gizmo.Draw.Color = Gizmo.Draw.Color.WithAlpha( alpha * 0.5f );
				var innerHalf = new Vector3(
					MathF.Max( 0, Scale.x * 0.5f - Falloff ),
					MathF.Max( 0, Scale.y * 0.5f - Falloff ),
					MathF.Max( 0, Scale.z * 0.5f - Falloff )
				);
				Gizmo.Draw.LineBBox( new BBox( -innerHalf, innerHalf ) );
			}
		}
		else
		{
			Gizmo.Draw.LineSphere( Vector3.Zero, Radius );

			if ( Falloff > 0f )
			{
				Gizmo.Draw.Color = Gizmo.Draw.Color.WithAlpha( alpha * 0.5f );
				Gizmo.Draw.LineSphere( Vector3.Zero, MathF.Max( 0, Radius - Falloff ) );
			}
		}
	}

	/// <summary>
	/// GPU-side volume descriptor. Must match the HLSL VolumeData struct exactly.
	/// </summary>
	[StructLayout( LayoutKind.Sequential )]
	internal struct VolumeGpuData
	{
		public Matrix WorldToLocal;   // 64 bytes
		public Vector4 Params;        // 16 bytes: x=intensity, y=falloff, z=mode(0=add,1=sub,2=color), w=shape(0=box,1=sphere)
		public Vector4 ShapeData;     // 16 bytes: box=xyz half-extents; sphere=xyz center, w=radius
		public Vector4 TintColor;     // 16 bytes: rgba tint for Color mode
		/// <summary>
		/// Tag group filter bitmasks packed as floats via BitConverter.
		/// x = IncludeGroupBits (uint as float), y = ExcludeGroupBits (uint as float).
		/// GPU reads with asuint(). Set by the system during volume upload.
		/// </summary>
		public Vector4 GroupFilter;   // 16 bytes: x=includeGroupBits, y=excludeGroupBits, zw=unused
	}
}