Editor/Effigy/MeshBoolean.cs
using System;

namespace Effigy;

/// <summary>What a boolean does to the two meshes.</summary>
public enum BooleanOp
{
	/// <summary>Everything inside either one, with the shared interface cut away. Different from
	/// MeshTransform.Append, which combines the meshes and leaves the interface in place.</summary>
	Union,

	/// <summary>The target with the tool's volume taken out of it. This is a cut.</summary>
	Subtract,

	/// <summary>Only the volume both share.</summary>
	Intersect,
}

/// <summary>
/// Something that can actually perform a mesh boolean.
///
/// AN INTERFACE RATHER THAN AN IMPLEMENTATION, and that is a decision this repo made a while ago
/// and wrote down: robust mesh CSG is a decades-old problem — coplanar faces, floating-point
/// robustness, self-intersection — and a half-working one is worse than none, because it fails on
/// the interesting cases and does so by producing a mesh rather than an error. s&box ships
/// PolygonMesh.PerformBoolean, so the plan of record is an engine-backed implementation there, and
/// a portable one only if it is ever genuinely needed.
///
/// The kernel therefore knows what a boolean IS without knowing how to do one. That keeps the
/// engine-free promise intact — nothing in here references an engine type — while letting a cut
/// work wherever a provider has been installed.
/// </summary>
public interface IMeshBoolean
{
	/// <summary>
	/// Apply the operation, or explain why not.
	///
	/// Returning false with a reason rather than throwing, because "this pair of meshes cannot be
	/// booleaned" is an ordinary outcome — two solids that do not touch, a cut that would remove
	/// everything — and the feature turns the reason into its own error message. A provider that
	/// throws is caught and treated the same way, since an engine call failing is not something a
	/// rebuild should die on.
	/// </summary>
	bool TryApply( BooleanOp op, PolyMesh target, PolyMesh tool, out PolyMesh result, out string error );
}

/// <summary>
/// Where the boolean provider is installed, and the one place features go through to use it.
///
/// A static slot rather than something threaded through FeatureContext: there is exactly one
/// answer per process — the engine's, or none — and the alternative is every feature signature
/// carrying a parameter that is the same value every time.
/// </summary>
public static class MeshBoolean
{
	/// <summary>The installed provider, or null where there is none — a bare console runner, or
	/// the test project. Set once at startup by whatever host knows how to do a boolean.</summary>
	public static IMeshBoolean Provider { get; set; }

	public static bool Available => Provider is not null;

	/// <summary>
	/// What a host wants said when there is no provider, if it knows something more useful than the
	/// kernel does.
	///
	/// The kernel's own message can only say that no boolean is installed, because that is all it
	/// knows. A host knows more — the editor knows the engine HAS one and what is needed to reach
	/// it — and the difference between "unavailable" and "unavailable, here is the next step" is
	/// the difference between a dead end and a task.
	/// </summary>
	public static string UnavailableReason { get; set; }

	/// <summary>
	/// Apply a boolean, throwing with something a user can act on if it cannot be done.
	///
	/// Every failure here ends up as one feature's Error, which is what the dialog turns red over
	/// and what the user reads. So each message says what could not be done AND what to do about it
	/// — "unavailable" on its own leaves someone wondering whether they broke something.
	/// </summary>
	public static PolyMesh Apply( BooleanOp op, PolyMesh target, PolyMesh tool )
	{
		if ( target is null || tool is null )
		{
			throw new FeatureException( new FeatureDiagnostic(
				DiagnosticSeverity.Error,
				"A boolean needs two solids",
				"One of the inputs was missing, so there is nothing to combine.",
				remedies: new[] { "Make sure both the target body and the tool solid exist" } ) );
		}

		if ( Provider is null )
		{
			var problem = UnavailableReason is { Length: > 0 } reason
				? $"{Name( op )} needs a mesh boolean. {reason}"
				: $"{Name( op )} needs a mesh boolean, and none is installed in this build. The kernel does "
					+ "not carry its own — see MeshBoolean for why.";

			throw new FeatureException( new FeatureDiagnostic(
				DiagnosticSeverity.Error,
				problem,
				"This build has no boolean provider, so cuts and unions cannot run.",
				remedies: new[] { "Run this in the s&box editor, where the engine boolean is installed" } ) );
		}

		bool ok;
		PolyMesh result;
		string error;

		try
		{
			ok = Provider.TryApply( op, target, tool, out result, out error );
		}
		catch ( Exception e )
		{
			// An engine call throwing is a failed boolean, not a failed rebuild. Everything else in
			// the tree should still build and still be on screen while this one feature complains.
			throw DiagnoseBoolean( op, target, tool, e.Message, engineThrew: true );
		}

		if ( !ok )
			throw DiagnoseBoolean( op, target, tool, error, engineThrew: false );

		if ( result is null || result.FaceCount == 0 )
		{
			// An empty result is a real answer to some inputs — cutting a solid with something that
			// swallows it whole — and it is never a useful one, because a body with no faces is
			// indistinguishable from a broken feature everywhere downstream.
			throw new FeatureException( new FeatureDiagnostic(
				DiagnosticSeverity.Error,
				$"{Name( op )} left nothing behind. The cut probably covers the whole part — check the profile and the distance.",
				"The boolean returned a mesh with no faces, which would blank every feature below this one.",
				remedies: new[] { "Reduce the distance so the cut does not swallow the part", "Check the profile sits inside the body" } ) );
		}

		return result;
	}

	/// <summary>
	/// The engine's refusal is a dead end on its own. Before handing it up, say whether the two
	/// bodies even overlap, and whether either is open — those are things we can see without
	/// asking the engine, and they are the usual reasons it says no.
	/// </summary>
	static FeatureException DiagnoseBoolean( BooleanOp op, PolyMesh target, PolyMesh tool, string error, bool engineThrew )
	{
		var targetCheck = MeshValidator.Validate( target );
		var toolCheck = MeshValidator.Validate( tool );
		var targetOpen = !targetCheck.IsClosed;
		var toolOpen = !toolCheck.IsClosed;

		if ( targetOpen || toolOpen )
		{
			var which = targetOpen && toolOpen ? "both solids are open"
				: targetOpen ? $"the target has {targetCheck.BoundaryEdges} boundary edge(s)"
				: $"the tool has {toolCheck.BoundaryEdges} boundary edge(s)";

			return new FeatureException( new FeatureDiagnostic(
				DiagnosticSeverity.Error,
				$"{Name( op )} failed: one of the solids is not closed",
				$"{which}, so there is no inside to {Name( op ).ToLowerInvariant()}. {error ?? ""}".Trim(),
				remedies: new[] { "Close the open mesh first", "Avoid cutting with a surface that has a boundary" } ) );
		}

		if ( BoundsGap( target, tool, out var axis, out var gap ) )
		{
			var how = gap > 1e-6f
				? $"they miss each other by {gap:0.###} along {axis}"
				: $"they only touch along {axis}, enclosing no common volume";

			return new FeatureException( new FeatureDiagnostic(
				DiagnosticSeverity.Error,
				$"{Name( op )} failed: the two solids do not overlap",
				$"{how}. {error ?? ""}".Trim(),
				remedies: new[] { "Move the tool so it cuts into the part", "Increase the distance", "Check Flip direction" } ) );
		}

		return new FeatureException( new FeatureDiagnostic(
			DiagnosticSeverity.Error,
			$"{Name( op )} failed: {error ?? "the solids could not be combined"}",
			engineThrew
				? "The boolean engine threw rather than returning a mesh."
				: "The solids overlap and are closed, so the refusal is in the geometry — self-intersection, or a case the engine cannot cut.",
			remedies: new[] { "Check neither solid self-intersects", "Try a slightly different position or distance" } ) );
	}

	static bool BoundsGap( PolyMesh a, PolyMesh b, out string axis, out float gap )
	{
		axis = "X";
		gap = 0f;

		if ( a.VertexCount == 0 || b.VertexCount == 0 )
			return false;

		Extent( a, out var aMin, out var aMax );
		Extent( b, out var bMin, out var bMax );

		var gapX = SpanGap( aMin.x, aMax.x, bMin.x, bMax.x );
		var gapY = SpanGap( aMin.y, aMax.y, bMin.y, bMax.y );
		var gapZ = SpanGap( aMin.z, aMax.z, bMin.z, bMax.z );
		var worst = MathF.Max( gapX, MathF.Max( gapY, gapZ ) );

		if ( worst < -1e-6f )
			return false;

		axis = worst == gapX ? "X" : worst == gapY ? "Y" : "Z";
		gap = worst;
		return true;
	}

	static float SpanGap( float aMin, float aMax, float bMin, float bMax ) =>
		MathF.Max( bMin - aMax, aMin - bMax );

	static void Extent( PolyMesh mesh, out Vec3 min, out Vec3 max )
	{
		min = new Vec3( float.MaxValue, float.MaxValue, float.MaxValue );
		max = new Vec3( float.MinValue, float.MinValue, float.MinValue );

		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 ) );
		}
	}

	static string Name( BooleanOp op ) => op switch
	{
		BooleanOp.Union => "Union",
		BooleanOp.Subtract => "Remove",
		BooleanOp.Intersect => "Intersect",
		_ => "Boolean"
	};
}