EmittersShapes/ConchplexBoxEmitter.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

namespace ConchplexEmitters;

/// <summary>
/// Emits particles within a box min/max bounds
/// </summary>
[Title("Conchplex Box Emitter"), Category("Particles"), Icon("border_outer")]
public sealed class ConchplexBoxEmitter : ConchplexEmitter
{
	// Emitter shape


	/// <summary>
	/// 1 = spawns on edge, 0 = spawns in centre
	/// </summary>
	[Property, Group("Spawn Bias"), Order(0)]
	[Range(0f, 1f), Step(0.01f)]
	public float DistanceBias { get; set; } = 0.5f;

	/// <summary>
	/// Direction + intensity to bias particles to spawn towards
	/// </summary>
	[Property, Group("Spawn Bias"), Order(0)]
	[Range(-1, 1)]
	public Vector3 PositionBias { get; set; } = 0f;

	[Property, Group("Spawn Bias"), Order(0)]
	[Range(-1, 1)]
	public Vector3 AxisBias { get; set; } = 0f;
	/// <summary>
	/// Particles only spawn on the axes marked
	/// </summary>
	[Property, Group("Spawn Bias"), Order(0)]
	public AxisClampFlags AxisClamp { get; set; }

	[Property]
	public Vector3 Size { get; set; } = 50f;

	[Property]
	[Range(0f, 1f), Step(0.01f)]
	public float Thickness { get; set; } = 0f;

	[Property, Group("Initial Velocity")]
	public float VelocityFromSquare { get; set; } = 0f;


	protected override void DrawGizmos()
	{
		if (Gizmo.IsSelected)
		{
			var point = Vector3.Zero;
			Gizmo.Draw.Color = Color.White.WithAlpha(0.5f);
			Gizmo.GizmoDraw draw = Gizmo.Draw;

			// Draw max bounds
			BBox box = BBox.FromPositionAndSize(point, Size);
			draw.LineBBox(box);

			// Draw min bounds
			BBox boxMin = BBox.FromPositionAndSize(point, Size * Thickness);
			draw.LineBBox(boxMin);

			// Draw Distance Bias
			Gizmo.Draw.Color = Color.Red.WithAlpha(0.5f);
			float biasX = MathX.Lerp(Size.x, Size.x * Thickness, 1 - DistanceBias);
			float biasY = MathX.Lerp(Size.y, Size.y * Thickness, 1 - DistanceBias);
			float biasZ = MathX.Lerp(Size.z, Size.z * Thickness, 1 - DistanceBias);
			BBox biasBox = BBox.FromPositionAndSize(point, new Vector3(biasX, biasY, biasZ) + point);
			draw.LineBBox(biasBox);

			if (PositionBias.IsNearlyZero()) return;
			Gizmo.Draw.Color = Color.Red.WithAlpha(0.8f);
			Vector3 biasClamped = Vector3.Clamp(PositionBias * 300f, new Vector3(-Size * 0.5f), new Vector3(Size * 0.5f));
			draw.Arrow(point, biasClamped, biasClamped.Length * 0.1f, biasClamped.Length * 0.1f);
		}
	}

	public override Particle Emit(ParticleEffect target)
	{
		Vector3 random = Random.Shared.VectorInCube(0.5f);
		Vector3 sizeMin = Size * Thickness;
		Vector3 sizeMax = Size;
		Vector3 targetSize = Vector3.Lerp(sizeMin, sizeMax, DistanceBias);
		Vector3 surfaceDirrection = Vector3.Zero;

		// POSITION BIAS
		var posBias = Vector3.Clamp(PositionBias, new Vector3(-1f), new Vector3(1f));
		float biasStrength = posBias.Length;
		if (biasStrength > 0f)
		{
			float randomFlipChance = Random.Shared.Float();
			if (MathF.Abs(posBias.x) > randomFlipChance && MathF.Sign(posBias.x) != MathF.Sign(random.x))
				random.x = -random.x;
			if (MathF.Abs(posBias.y) > randomFlipChance && MathF.Sign(posBias.y) != MathF.Sign(random.y))
				random.y = -random.y;
			if (MathF.Abs(posBias.z) > randomFlipChance && MathF.Sign(posBias.z) != MathF.Sign(random.z))
				random.z = -random.z;

			random = Vector3.Lerp(random, random + posBias * 0.5f, biasStrength);
		}

		// Figure out Min / Max Distance bias
		var maxEdgePos = RandomPositionOnBoxFace(random, Size, posBias, out surfaceDirrection);
		var minEdgePos = maxEdgePos * Thickness;
		// lol this is really stupid and nasty but it works!
		var fuckedUpRandom = Random.Shared.Float();
		var randomBetweenPos = Vector3.Lerp(maxEdgePos, minEdgePos, fuckedUpRandom * fuckedUpRandom * fuckedUpRandom);
		// Apply Distance Bias

		if (DistanceBias <= 0.5f)
		{
			float scaledBias = DistanceBias / 0.5f;
			random = Vector3.Lerp(minEdgePos, randomBetweenPos, scaledBias);
		}
		else
		{
			float scaledBias = (DistanceBias - 0.5f) / 0.5f;
			random = Vector3.Lerp(randomBetweenPos, maxEdgePos, scaledBias);
		}

		var clampedParticlePoint = ProcessClampPosition(random, surfaceDirrection, AxisClamp);
		var pos = (clampedParticlePoint.Position * LocalScale * LocalRotation) + WorldPosition;
		var velocity = clampedParticlePoint.Velocity * LocalRotation;

		Particle particle = target.Emit(pos, base.Delta);
		particle.Velocity += velocity * VelocityFromSquare;

		return particle;
	}

	// this ensure particles are placed evenly on faces + corrects any overshooting
	Vector3 RandomPositionOnBoxFace(Vector3 randomPos, Vector3 boxSize, Vector3 positionBias, out Vector3 surfaceDirrection)
	{
		// Scale the random position to the box size
		randomPos = Vector3.Clamp(randomPos, new Vector3(-0.5f), new Vector3(0.5f));

		var scaledPos = randomPos * boxSize;
		// Determine valid faces for selection
		List<(int axis, bool positiveSide)> validFaces = new List<(int, bool)>();

		for (int i = 0; i < 3; i++) // Iterate over X, Y, Z axes
		{
			if (positionBias[i] >= 0) // Allow positive face if bias is >= 0
				validFaces.Add((i, true));
			if (positionBias[i] <= 0) // Allow negative face if bias is <= 0
				validFaces.Add((i, false));
		}

		if (validFaces.Count == 0)
			throw new InvalidOperationException("No valid faces available due to position bias.");

		// Randomly select a valid face
		var selectedFace = validFaces[Random.Shared.Next(validFaces.Count)];
		int fixedAxis = selectedFace.axis;
		bool positiveSide = selectedFace.positiveSide;

		// Push the position to the selected face
		scaledPos[fixedAxis] = positiveSide ? (boxSize[fixedAxis] * 0.5f) : -(boxSize[fixedAxis] * 0.5f);
		surfaceDirrection = Vector3.Zero;
		surfaceDirrection[fixedAxis] = positiveSide ? 1f : -1f;

		return scaledPos;
	}
}