Editor/Effigy/Features/BooleanFeature.cs
using System.Collections.Generic;
using System.Linq;

namespace Effigy;

/// <summary>
/// Combine two solids: Union, Subtract, Intersect.
///
/// WHY THIS DID NOT EXIST FOR SO LONG, given the machinery did. MeshBoolean has been installed and
/// working since the engine provider landed, and two features already used it — Extrude's Remove
/// and Hole both call Subtract. But both of them build their own tool solid and cut with it, so
/// the boolean was only ever reachable by drawing a profile or drilling a hole. There was no way
/// to point at two bodies you already had and say "these are one thing now", which is the first
/// thing anybody tries in a solid modeller and the one thing the tool bar did not offer.
///
/// TARGETS AND TOOLS, NOT A AND B. Subtract is not symmetric and Union is, so a single flat list
/// of bodies cannot express all three operations — "subtract these" has to say which side the hole
/// goes in. Union ignores the split and folds everything named on either side into one body, which
/// is the honest reading rather than a special case: with a symmetric operation there is nothing
/// for the distinction to mean.
///
/// THE TOOL IS CONSUMED BY DEFAULT, which is what Onshape does and what the word means — a cutting
/// tool that survives the cut leaves you with the offcut sitting inside the hole it just made, and
/// the next feature has to know to ignore it. KeepTools is there because a tool reused for six
/// cuts is a real pattern, and because a body that vanishes with no way to get it back is worse
/// than one you have to tidy up.
///
/// THE TARGET KEEPS ITS ID. Everything downstream is holding it — a sketch on one of its faces, a
/// later feature's body selection — and a boolean must not invalidate that. This is the same rule
/// SeparatePieces follows for the offcuts of a cut, and for the same reason.
/// </summary>
public sealed class BooleanFeature : Feature
{
	public override string TypeName => "Boolean";

	public override GeometryKind Accepts => GeometryKind.Body;

	public readonly ChoiceParam Operation = new( "Operation", new[] { "Union", "Subtract", "Intersect" } );

	/// <summary>What is being cut, or the first of the things being merged.</summary>
	public readonly BodySelectionParam Targets = new( "Target" );

	/// <summary>What is doing the cutting. Must be named explicitly — see Execute.</summary>
	public readonly BodySelectionParam Tools = new( "Tool" );

	public readonly BoolParam KeepTools = new( "Keep tool bodies", false );

	public override IReadOnlyList<IParam> Parameters =>
		Operation.Index == 0
			? new IParam[] { Operation, Targets, Tools }
			: new IParam[] { Operation, Targets, Tools, KeepTools };

	BooleanOp Op => Operation.Index switch
	{
		1 => BooleanOp.Subtract,
		2 => BooleanOp.Intersect,
		_ => BooleanOp.Union,
	};

	protected override void Execute( FeatureContext ctx )
	{
		if ( ctx.Bodies.Count < 2 )
		{
			Fail(
				"A boolean needs two solids",
				ctx.Bodies.Count == 0
					? "This studio has no bodies yet, so there is nothing to combine."
					: "This studio has one body, and a boolean combines two.",
				"Add a Primitive, or extrude a sketch, so there are two solids to combine" );
		}

		// TOOLS MUST BE NAMED, and this is the one place a BodySelectionParam's "empty means all"
		// default is actively wrong. Everywhere else that default is the sane reading of an
		// unmade choice — Shell every body, subdivide the whole part. Here it would mean "cut
		// this with everything, itself included", which is not a thing anybody meant, and the
		// operation is destructive enough that guessing is the wrong instinct.
		if ( Tools.BodyIds.Count == 0 )
		{
			FailOn( Tools.Label,
				"No tool body is chosen",
				Op == BooleanOp.Union
					? "A union needs to know which bodies to merge, and none have been picked."
					: $"A {OpName( Op )} needs to know which body is doing the cutting, and none has been picked.",
				"Click a body in the Parts list, or in the viewport, to use it as the tool" );
		}

		var tools = ctx.Bodies.Where( Tools.Matches ).ToList();

		if ( tools.Count == 0 )
		{
			FailOn( Tools.Label,
				"The tool body is gone",
				"The body chosen as the tool is no longer in the studio — a feature above this one "
					+ "may have deleted, renamed or consumed it.",
				"Pick a tool body that still exists" );
		}

		// TARGETS DEFAULT TO EVERYTHING THAT IS NOT A TOOL, rather than to everything. The plain
		// reading of "subtract this peg" in a studio holding a block and a peg is "from the
		// block", and taking the default literally would ask the peg to be subtracted from itself.
		var targets = Targets.BodyIds.Count > 0
			? ctx.Bodies.Where( Targets.Matches ).ToList()
			: ctx.Bodies.Where( b => !Tools.Matches( b ) ).ToList();

		var overlap = targets.Where( t => tools.Contains( t ) ).ToList();

		if ( overlap.Count > 0 )
		{
			FailOn( Targets.Label,
				"A body cannot be its own tool",
				$"{Describe( overlap )} is picked as both the target and the tool, and a solid "
					+ "cut by itself leaves nothing behind.",
				"Pick a different body as the tool" );
		}

		if ( targets.Count == 0 )
		{
			FailOn( Targets.Label,
				"Nothing is left to act on",
				"Every body in the studio is picked as a tool, so there is no target for the "
					+ "operation to change.",
				"Leave at least one body out of the tool selection" );
		}

		if ( Op == BooleanOp.Union )
			Unite( ctx, targets, tools );
		else
			CutOrKeep( ctx, targets, tools );
	}

	/// <summary>
	/// Fold every named body into the first of them.
	///
	/// The FIRST IN DOCUMENT ORDER, not the first in the selection, so a union of the same three
	/// bodies produces the same surviving id however they happened to be clicked. A rebuild that
	/// renamed its own output depending on click order would be a downstream reference that breaks
	/// when you reselect.
	/// </summary>
	void Unite( FeatureContext ctx, List<Body> targets, List<Body> tools )
	{
		var all = ctx.Bodies.Where( b => targets.Contains( b ) || tools.Contains( b ) ).ToList();

		if ( all.Count < 2 )
		{
			Fail(
				"A union needs two solids",
				"Only one body is named on either side, and merging a body with itself does nothing.",
				"Pick a second body to merge with" );
		}

		var kept = all[0];

		foreach ( var other in all.Skip( 1 ) )
		{
			kept.Mesh = MeshBoolean.Apply( BooleanOp.Union, kept.Mesh, other.Mesh );
			ctx.Bodies.Remove( other );
		}

		// A union of solids that do not touch is a legal boolean and produces a mesh in two
		// pieces. That is not an error — a handle and a lid really are one part in some designs —
		// but the Parts list has to be told, or the studio quietly holds one body that looks like
		// two and every later face pick is ambiguous about which piece it meant.
		if ( MeshSplit.PieceCount( kept.Mesh ) > 1 )
		{
			Warn(
				"The merged bodies do not touch",
				$"{kept.Name} is now one body in {MeshSplit.PieceCount( kept.Mesh )} separate pieces, "
					+ "because the solids that were merged do not overlap.",
				"Move them so they intersect, if they were meant to become one solid" );
		}
	}

	/// <summary>Subtract or intersect: every tool applied to every target, in order.</summary>
	void CutOrKeep( FeatureContext ctx, List<Body> targets, List<Body> tools )
	{
		var op = Op;
		var emptied = new List<string>();
		var separated = 0;

		foreach ( var target in targets )
		{
			foreach ( var tool in tools )
				target.Mesh = MeshBoolean.Apply( op, target.Mesh, tool.Mesh );

			// AN EMPTY RESULT IS A SUCCESSFUL BOOLEAN AND A USELESS PART. Subtracting a block from
			// a peg inside it, or intersecting two solids that never touch, both come back as a
			// mesh with nothing in it — the operation did exactly what was asked. Left alone it
			// shows up as a part that silently disappeared from the viewport, which reads as a
			// crash rather than as an answer.
			if ( target.Mesh is null || target.Mesh.FaceCount == 0 )
			{
				emptied.Add( target.Name );
				continue;
			}

			separated += SeparatePieces( ctx, target );
		}

		if ( emptied.Count > 0 )
		{
			Warn(
				emptied.Count == 1 ? $"{emptied[0]} has nothing left" : $"{emptied.Count} bodies have nothing left",
				op == BooleanOp.Subtract
					? $"{Describe( emptied )} was entirely inside the tool, so subtracting it removed everything."
					: $"{Describe( emptied )} does not overlap the tool, so there is no shared volume to keep.",
				op == BooleanOp.Subtract
					? "Make the tool smaller, or move it so it only covers part of the target"
					: "Move the solids so they overlap" );
		}

		if ( separated > 0 )
		{
			Warn(
				separated == 1 ? "The cut separated a piece" : $"The cut separated {separated} pieces",
				"The tool went right through the target, so what was one solid is now several. Each "
					+ "piece is its own body in the Parts list.",
				"Make the tool shallower if the part was meant to stay in one piece" );
		}

		if ( !KeepTools.Value )
		{
			foreach ( var tool in tools )
				ctx.Bodies.Remove( tool );
		}
	}

	static string OpName( BooleanOp op ) => op switch
	{
		BooleanOp.Subtract => "subtract",
		BooleanOp.Intersect => "intersect",
		_ => "union",
	};

	static string Describe( IEnumerable<Body> bodies ) => Describe( bodies.Select( b => b.Name ).ToList() );

	static string Describe( IReadOnlyList<string> names ) => names.Count switch
	{
		0 => "Nothing",
		1 => names[0],
		2 => $"{names[0]} and {names[1]}",
		_ => $"{string.Join( ", ", names.Take( names.Count - 1 ) )} and {names[^1]}",
	};
}