Editor/Effigy/CollisionBuilder.cs
using System;
using System.Collections.Generic;

namespace Effigy;

public enum CollisionKind
{
	Box,
	Sphere,
	Cylinder,
	Hull,
}

/// <summary>One convex piece of a part's physics representation.</summary>
public sealed class CollisionShape
{
	public CollisionKind Kind;

	/// <summary>Centre of the shape, in model space.</summary>
	public Vec3 Position;

	/// <summary>Box: half-extents. Sphere: radius in x. Cylinder: radius in x, half-height in z.</summary>
	public Vec3 Size;

	/// <summary>Hull only: the points on it.</summary>
	public List<Vec3> Points;

	/// <summary>Which body this came from, so a caller can group them.</summary>
	public string BodyId;

	public override string ToString() => Kind switch
	{
		CollisionKind.Box => $"box {Size.x * 2:0.##} x {Size.y * 2:0.##} x {Size.z * 2:0.##}",
		CollisionKind.Sphere => $"sphere r{Size.x:0.##}",
		CollisionKind.Cylinder => $"cylinder r{Size.x:0.##} h{Size.z * 2:0.##}",
		_ => $"hull of {Points?.Count ?? 0} points",
	};
}

/// <summary>What a collision build produced, and how it got there.</summary>
public sealed class CollisionReport
{
	public readonly List<CollisionShape> Shapes;
	public readonly bool FromHistory;
	public readonly string Reason;

	public CollisionReport( List<CollisionShape> shapes, bool fromHistory, string reason )
	{
		Shapes = shapes;
		FromHistory = fromHistory;
		Reason = reason;
	}

	public override string ToString() =>
		$"{Shapes.Count} shape(s), {(FromHistory ? "from the feature history" : "as hulls")}"
		+ (Reason is null ? "" : $" - {Reason}");
}

/// <summary>
/// A part's physics representation, taken from the feature history rather than from the mesh.
///
/// A MODEL KNOWN TO BE A UNION OF N CONVEX PRIMITIVES IS ALREADY ITS OWN COLLISION, so this is
/// bookkeeping rather than geometry. Walking the finished mesh instead would throw away the one
/// thing that makes the answer exact: that somebody typed "box, 2 by 2 by 2" and nothing since has
/// been allowed to disturb it.
///
/// WHAT SPOILS IT is anything that changes a body's shape in a way a primitive cannot describe — an
/// extrude, a revolve, a boolean, a shell, a fillet, a subdivide. The moment one of those is in the
/// tree the history stops being a description of the shape and the fallback takes over: one convex
/// hull per body. Bigger than the part wherever the part is concave, never smaller, always convex,
/// and honest about being an approximation.
///
/// Deliberately all-or-nothing rather than per body. Working out WHICH bodies a boolean touched
/// means tracking body identity through operations that create and destroy them, and a physics
/// representation that is exact for three props and quietly wrong for the fourth is worse than one
/// that is approximate for all four and says so.
/// </summary>
public static class CollisionBuilder
{
	public static CollisionReport Build( PartStudio studio )
	{
		if ( studio is null )
			throw new ArgumentNullException( nameof( studio ) );

		if ( TryFromHistory( studio, out var shapes, out var spoiler ) )
			return new CollisionReport( shapes, true, null );

		return new CollisionReport( Hulls( studio ), false,
			spoiler is null ? "there is nothing in the history to read" : $"{spoiler} is in the tree" );
	}

	static bool TryFromHistory( PartStudio studio, out List<CollisionShape> shapes, out string spoiler )
	{
		shapes = new List<CollisionShape>();
		spoiler = null;

		var count = Math.Min( studio.RollbackIndex, studio.Features.Count );

		for ( var i = 0; i < count; i++ )
		{
			var feature = studio.Features[i];

			if ( feature.Suppressed )
				continue;

			switch ( feature )
			{
				case PrimitiveFeature primitive:
					if ( FromPrimitive( primitive ) is { } shape )
						shapes.Add( shape );
					else
						spoiler = primitive.Shape.Value;
					break;

				// A sketch produces no solid, so it cannot spoil anything.
				case SketchFeature:
					continue;

				// These copy what is already there, which is exactly what a shape list can do too.
				case MirrorFeature mirror:
					Mirror( shapes, mirror );
					break;

				case TransformFeature transform:
					// A MOVE CAN BE FOLLOWED; A TURN OR A STRETCH CANNOT. A CollisionShape carries a
					// position and a size, not an orientation, so a rotated box has nowhere to record
					// that it is rotated - and a collision hull sitting square while the part it
					// belongs to is at forty degrees is the kind of wrong that only shows up when
					// something bounces off thin air. So this spoils the decomposition rather than
					// quietly dropping the rotation, and the hulls that replace it are correct.
					if ( MathF.Abs( transform.RotationAngle.Value ) > 1e-4f
						|| (transform.Scale.Value - Vec3.One).LengthSquared > 1e-8f )
					{
						spoiler ??= "a rotated or scaled Transform";
						break;
					}

					Move( shapes, transform );
					break;

				default:
					spoiler ??= feature.TypeName;
					break;
			}

			if ( spoiler is not null )
				return false;
		}

		return shapes.Count > 0;
	}

	static CollisionShape FromPrimitive( PrimitiveFeature primitive )
	{
		var scale = primitive.Scale.Value;
		var position = primitive.Position.Value;

		switch ( primitive.Shape.Value )
		{
			case "Box":
			case "Plane":
				return new CollisionShape
				{
					Kind = CollisionKind.Box,
					Position = position,
					Size = new Vec3(
						primitive.SizeX.Clamped * 0.5f * scale.x,
						primitive.SizeY.Clamped * 0.5f * scale.y,
						primitive.SizeZ.Clamped * 0.5f * scale.z ),
				};

			case "Sphere":
				// A sphere scaled unevenly is an ellipsoid, and there is no ellipsoid collision
				// shape - so it falls back rather than pretending the smallest axis is the radius.
				if ( MathF.Abs( scale.x - scale.y ) > 1e-4f || MathF.Abs( scale.x - scale.z ) > 1e-4f )
					return null;

				return new CollisionShape
				{
					Kind = CollisionKind.Sphere,
					Position = position,
					Size = new Vec3( primitive.Radius.Clamped * scale.x, 0f, 0f ),
				};

			case "Cylinder":
				if ( MathF.Abs( scale.x - scale.y ) > 1e-4f )
					return null;

				return new CollisionShape
				{
					Kind = CollisionKind.Cylinder,
					Position = position,
					Size = new Vec3( primitive.Radius.Clamped * scale.x, 0f,
						primitive.SizeZ.Clamped * 0.5f * scale.z ),
				};

			// A wedge is a prism and a tube has a hole down the middle. Neither is one convex
			// primitive, and a hull of a tube fills its bore in - so they go to the fallback, where
			// at least the approximation is stated.
			default:
				return null;
		}
	}

	static void Mirror( List<CollisionShape> shapes, MirrorFeature mirror )
	{
		var normal = mirror.PlaneNormal.Value;

		if ( normal.LengthSquared < 1e-12f )
			return;

		normal = normal.Normal;

		var origin = mirror.PlanePoint.Value;
		var copies = new List<CollisionShape>( shapes.Count );

		foreach ( var shape in shapes )
		{
			var offset = shape.Position - origin;
			var reflected = origin + offset - normal * (2f * Vec3.Dot( offset, normal ));

			copies.Add( new CollisionShape
			{
				Kind = shape.Kind,
				Position = reflected,
				Size = shape.Size,
				Points = shape.Points,
				BodyId = shape.BodyId,
			} );
		}

		// Keep original off means the mirror REPLACES what it reflected, so the shapes have to go
		// with it. Appending regardless would leave collision on a half of the part that is not there.
		if ( !mirror.KeepOriginal.Value )
			shapes.Clear();

		shapes.AddRange( copies );
	}

	static void Move( List<CollisionShape> shapes, TransformFeature transform )
	{
		var offset = transform.Translate.Value;

		foreach ( var shape in shapes )
			shape.Position += offset;
	}

	/// <summary>One hull per body, which is what an undecomposable model gets.</summary>
	static List<CollisionShape> Hulls( PartStudio studio )
	{
		var shapes = new List<CollisionShape>();

		foreach ( var body in studio.Bodies )
		{
			if ( body.Mesh is not { VertexCount: >= 4 } mesh )
				continue;

			var hull = ConvexHull.Build( mesh.Positions );

			if ( hull is not { } built )
			{
				// Flat, collinear, or a single point: no volume to enclose. A box round it is the
				// only convex answer left and it is at least never smaller than the part.
				shapes.Add( BoxAround( mesh, body.Id ) );
				continue;
			}

			shapes.Add( new CollisionShape
			{
				Kind = CollisionKind.Hull,
				Position = Vec3.Zero,
				Points = built.Points,
				BodyId = body.Id,
			} );
		}

		return shapes;
	}

	static CollisionShape BoxAround( PolyMesh mesh, string bodyId )
	{
		var min = mesh.Positions[0];
		var max = mesh.Positions[0];

		foreach ( var p in mesh.Positions )
		{
			min = new Vec3( MathF.Min( min.x, p.x ), MathF.Min( min.y, p.y ), MathF.Min( min.z, p.z ) );
			max = new Vec3( MathF.Max( max.x, p.x ), MathF.Max( max.y, p.y ), MathF.Max( max.z, p.z ) );
		}

		return new CollisionShape
		{
			Kind = CollisionKind.Box,
			Position = (min + max) * 0.5f,
			Size = (max - min) * 0.5f,
			BodyId = bodyId,
		};
	}
}