Effigy/FaceMove.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy;

/// <summary>
/// Move faces of a solid that already exists, and let the walls around them follow.
///
/// THE OPERATION THAT MAKES A PART EDITABLE. Everything else in this kernel builds forwards: a
/// sketch becomes a prism, a prism gets a fillet. Nothing could take a face of a finished part and
/// simply move it, which is the single most reached-for edit in any CAD tool and the one po hit
/// first — select a face, push it, watch the solid grow.
///
/// TWO MODES, ONE SOLVE, and that is the whole design.
///
/// - **Offset** moves each chosen face along its OWN normal by the same distance. On a facing pair
///   that makes the wall between them thicker or thinner.
/// - **Translate** moves the chosen faces together along one direction. On a facing pair that
///   slides the wall and KEEPS its thickness — material added on one side and taken from the other,
///   which is the thing you actually want when a wall is in the wrong place.
///
/// They differ only in the number handed to each face: offset gives every face `t_f = distance`,
/// translate gives it `t_f = dot(n_f, v)`. That is it. Writing it this way means one tested path
/// rather than two that drift, and it is why the projection is written out below rather than
/// branching on the mode inside the solve.
///
/// WHERE MOVED FACES MEET FACES THAT STAY, which is the only hard part. A rim vertex has to land
/// where the moved face's plane AT ITS NEW OFFSET meets its unmoved neighbours' planes UNCHANGED.
/// That is exactly what PlaneOffset solves, in closed form, and it is the same machinery shell and
/// bevel already stand on — with one generalisation: a distance per plane rather than one distance
/// for all of them, because the moved face asks for `t` and the neighbours ask for zero.
///
/// The pay-off is that slanted neighbours come out right for free. Slide a wall past a chamfered
/// edge and the chamfer keeps its angle, because the chamfer's plane is one of the constraints and
/// nothing here ever assumed a vertex travels along a normal.
///
/// WHAT IS REFUSED RATHER THAN APPROXIMATED, all of it deliberate and none of it a TODO:
///
/// - A face that is not planar. "Along the normal" of a face that has no single normal is not a
///   small error, it is not an operation. Same standing as Draft's refusal to taper a face that
///   looks along the pull.
/// - A move that contradicts itself — a face moving while a COPLANAR neighbour stays put asks one
///   vertex to be in two places, and PlaneOffset reports it rather than fitting something plausible.
/// - A solve with no exact answer, which is anti-parallel neighbours.
/// - A face that turns itself inside out on the way, which is the local signature of the surface
///   having been pushed through itself. Same check ShellOperation makes, for the same reason.
///
/// WHAT IS NOT CHECKED, said out loud so it is not discovered as a bug: a face pushed far enough to
/// meet geometry it does not touch. That needs a genuine boolean, and faking it would produce a
/// self-intersecting mesh that passes every validator in this repo. The local checks below catch
/// every case where something goes wrong AT the moved face; two distant walls closing on each other
/// is the case they cannot see.
/// </summary>
public static class FaceMove
{
	/// <summary>How far off its own plane a face's corners may sit, relative to the face's own size,
	/// before it stops being a plane you can move. Loose enough to accept a face that arithmetic has
	/// nudged, tight enough to refuse anything actually curved.</summary>
	public const float PlanarTolerance = 1e-3f;

	/// <summary>
	/// Move each face along its own normal by <paramref name="distance"/>. Positive is outward.
	///
	/// On a facing pair this is the mode that changes a wall's THICKNESS: each face travels along
	/// its own outward normal, so the two move apart.
	/// </summary>
	public static PolyMesh Offset( PolyMesh mesh, IReadOnlyCollection<int> faces, float distance ) =>
		Move( mesh, faces, distance, direction: null );

	/// <summary>
	/// Move every chosen face together, by one displacement.
	///
	/// On a facing pair this is the mode that SLIDES a wall and keeps its thickness — the two faces
	/// travel the same way, so the gap between them is unchanged and the material is added on one
	/// side and taken from the other.
	///
	/// A face whose normal is perpendicular to the travel gets `t_f = 0` and so does not move at
	/// all, which is correct rather than a special case: sliding a wall sideways does not move the
	/// faces at its ends, it stretches them.
	/// </summary>
	public static PolyMesh Translate( PolyMesh mesh, IReadOnlyCollection<int> faces, Vec3 displacement ) =>
		Move( mesh, faces, distance: 0f, direction: displacement );

	/// <summary>
	/// The shared solve. <paramref name="direction"/> null means offset mode; otherwise it is the
	/// translation and <paramref name="distance"/> is ignored.
	/// </summary>
	static PolyMesh Move( PolyMesh mesh, IReadOnlyCollection<int> faces, float distance, Vec3? direction )
	{
		if ( mesh is null )
			throw new ArgumentNullException( nameof( mesh ) );

		if ( faces is null || faces.Count == 0 )
			throw new InvalidOperationException( "No faces picked — click the faces you want to move." );

		var moved = new HashSet<int>();

		foreach ( var index in faces )
		{
			if ( index < 0 || index >= mesh.Faces.Count )
			{
				throw new InvalidOperationException(
					$"Face {index} is not on this part any more — it has {mesh.Faces.Count} faces." );
			}

			moved.Add( index );
		}

		var normals = new Vec3[mesh.Faces.Count];

		for ( var fi = 0; fi < mesh.Faces.Count; fi++ )
			normals[fi] = mesh.FaceNormal( mesh.Faces[fi] );

		// --- how far each moved face is asked to travel ----------------------------------------
		//
		// The one line where the two modes differ. Offset hands every face the same number; translate
		// PROJECTS the displacement onto each face's normal, which is what makes a facing pair slide
		// rather than fatten.
		var target = new Dictionary<int, float>( moved.Count );

		foreach ( var fi in moved )
		{
			RefuseIfNotPlanar( mesh, fi, normals[fi] );

			target[fi] = direction is { } v ? Vec3.Dot( normals[fi], v ) : distance;
		}

		if ( target.Values.All( t => MathF.Abs( t ) < 1e-9f ) )
			return mesh.Clone();

		// --- every vertex a moved face touches --------------------------------------------------
		var vertexFaces = mesh.BuildVertexFaces();
		var positions = new List<Vec3>( mesh.Positions );

		for ( var vi = 0; vi < mesh.VertexCount; vi++ )
		{
			var incident = vertexFaces[vi];

			if ( !incident.Any( moved.Contains ) )
				continue;

			var (planes, distances) = Constraints( mesh, vi, incident, normals, target );

			if ( !PlaneOffset.TrySolve( planes, distances, out var displacement ) )
			{
				throw new InvalidOperationException(
					"That move has no exact answer where the faces meet — a corner here is asked to sit "
					+ "against neighbours that face away from each other. Move fewer faces at once, or "
					+ "move them a shorter way." );
			}

			positions[vi] = mesh.Positions[vi] + displacement;
		}

		var result = mesh.Clone();
		result.Positions = positions;

		RefuseIfFolded( mesh, result, normals );

		return result;
	}

	/// <summary>
	/// The planes meeting at one vertex and the offset each is asked for: the moved faces' own
	/// targets, and ZERO for everything else, because a face that is not moving must end up exactly
	/// where it already is.
	///
	/// Near-duplicate normals collapse to one constraint, the way they do for shell — one flat
	/// surface split into several polygons is one plane, and feeding a least-squares fit the same
	/// constraint five times biases it toward whichever surface happens to be cut up the most. But
	/// duplicates that DISAGREE about their offset are not duplicates at all: that is a face being
	/// moved while a coplanar neighbour stays put, which asks this vertex to be in two places. It is
	/// refused here, where the two faces can be named, rather than surfacing as an inexact solve.
	/// </summary>
	static (List<Vec3> Planes, List<float> Distances) Constraints( PolyMesh mesh, int vertex,
		List<int> incident, Vec3[] normals, Dictionary<int, float> target )
	{
		var planes = new List<Vec3>( 4 );
		var distances = new List<float>( 4 );

		foreach ( var fi in incident )
		{
			var normal = normals[fi];
			var t = target.TryGetValue( fi, out var wanted ) ? wanted : 0f;
			var duplicate = -1;

			for ( var i = 0; i < planes.Count; i++ )
			{
				if ( 1.0 - Vec3.Dot( planes[i], normal ) < PlaneOffset.DistinctTolerance )
				{
					duplicate = i;
					break;
				}
			}

			if ( duplicate < 0 )
			{
				planes.Add( normal );
				distances.Add( t );
				continue;
			}

			if ( MathF.Abs( distances[duplicate] - t ) > 1e-6f * MathF.Max( 1f, MathF.Abs( t ) ) )
			{
				throw new InvalidOperationException(
					"One of these faces sits in the same plane as a face that is staying put, so the "
					+ "corner between them would have to be in two places at once. Select the whole "
					+ "flat surface, not part of it." );
			}
		}

		return (planes, distances);
	}

	/// <summary>
	/// A face whose corners do not lie in one plane has no single normal, so "move it along its
	/// normal" has no meaning — and the answer that comes out of pretending otherwise is a
	/// plausible-looking mesh that is wrong by an amount nobody can predict.
	///
	/// Measured against the face's OWN SIZE rather than the model's, so the rule is the same on a
	/// rivet and on a wall.
	/// </summary>
	static void RefuseIfNotPlanar( PolyMesh mesh, int index, Vec3 normal )
	{
		var face = mesh.Faces[index];
		var centroid = mesh.FaceCentroid( face );
		var size = 0f;
		var worst = 0f;

		foreach ( var i in face.Indices )
		{
			var offset = mesh.Positions[i] - centroid;

			size = MathF.Max( size, offset.Length );
			worst = MathF.Max( worst, MathF.Abs( Vec3.Dot( normal, offset ) ) );
		}

		if ( size > 0f && worst > PlanarTolerance * size )
		{
			throw new InvalidOperationException(
				$"That face is not flat — its corners sit {worst:0.####} off a single plane, so it has "
				+ "no one direction to move along. Moving a curved surface is not this tool." );
		}
	}

	/// <summary>
	/// Refuse a move that pushed a face through itself.
	///
	/// A face that has TURNED OVER relative to where it started has been driven past its own
	/// neighbours — its normal reverses and its area passes through zero on the way. That is a local
	/// signature nothing can hide, it costs one normal per touched face, and it is the same check
	/// ShellOperation makes for the same reason. A face that collapsed to nothing is caught too: its
	/// normal is undefined by then, so the area is what says so.
	///
	/// Only faces that actually moved are examined. The rest of the part is untouched by
	/// construction and checking it would be checking the input.
	/// </summary>
	static void RefuseIfFolded( PolyMesh before, PolyMesh after, Vec3[] normals )
	{
		for ( var fi = 0; fi < after.Faces.Count; fi++ )
		{
			var face = after.Faces[fi];
			var stayed = true;

			foreach ( var i in face.Indices )
			{
				if ( (after.Positions[i] - before.Positions[i]).LengthSquared > 0f )
				{
					stayed = false;
					break;
				}
			}

			if ( stayed )
				continue;

			// A FACE THAT ARRIVED DEGENERATE IS NOT THIS MOVE'S FAULT. Its normal is already zero, so
			// every comparison against it reads as a fold and the move gets refused for damage it did
			// not do. Real meshes carry these: chamfering a single edge of a box leaves a zero-area
			// sliver on the side face it ran into, and pulling the top of that box is a perfectly
			// ordinary thing to want afterwards.
			if ( normals[fi].LengthSquared < 1e-12f )
				continue;

			var area = after.FaceArea( face );

			if ( area <= 1e-12f )
			{
				throw new InvalidOperationException(
					"That move collapses a face of the part to nothing. Move it a shorter way." );
			}

			if ( Vec3.Dot( after.FaceNormal( face ), normals[fi] ) <= 0f )
			{
				throw new InvalidOperationException(
					"That move pushes the part through itself — a face ends up facing the other way. "
					+ "Move it a shorter way, or move the faces around it too." );
			}
		}
	}
}