Effigy.Tests/FaceSketchTests.cs
using System;
using System.Linq;
using Effigy;

namespace Effigy.Tests;

/// <summary>
/// Sketching on the face of an existing body, and building on top of it.
///
/// This is the "boss on top of the block I just made" workflow, and it needs no boolean at all -
/// which is the useful finding from reading Solvespace and FreeCAD. Neither treats it as a
/// sketching mode; it is a derived plane, and the sketcher is untouched.
///
/// The half that matters most here is the REFERENCE surviving a rebuild. FreeCAD stores "Face6"
/// and that ordering moves when anything upstream changes; a FaceRef is a point and a normal, so
/// it is re-found geometrically and either matches or says it cannot.
/// </summary>
public static class FaceSketchTests
{
	public static void Run()
	{
		Report.Section( "face sketching: the derived plane itself" );
		TestPlaneFromNormal();

		Report.Section( "face sketching: a boss built on top of a box" );
		TestBossOnTopOfBox();

		Report.Section( "face sketching: the reference survives the box changing under it" );
		TestReferenceSurvivesUpstreamEdit();

		Report.Section( "face sketching: a reference that no longer matches anything" );
		TestLostFace();

		Report.Section( "face sketching: the face's own outline, to draw and to snap to" );
		TestReferenceOutline();

		Report.Section( "face sketching: the outline follows the face it came from" );
		TestReferenceOutlineFollowsFace();

		Report.Section( "face sketching: snapping onto the face's corners and edges" );
		TestSnapToReference();

		Report.Section( "face sketching: Use - the outline becomes real sketch geometry" );
		TestUseReference();
	}

	static void TestPlaneFromNormal()
	{
		foreach ( var n in new[]
		{
			new Vec3( 0, 0, 1 ), new Vec3( 0, 0, -1 ), new Vec3( 1, 0, 0 ),
			new Vec3( 0, 1, 0 ), new Vec3( 1, 1, 1 ),
		} )
		{
			var plane = FacePlane.FromPointAndNormal( new Vec3( 1, 2, 3 ), n );

			Report.Check( $"plane from normal {n} has that normal",
				plane.Normal.AlmostEquals( n.Normal ), plane.Normal.ToString() );

			// Axes must be a proper orthonormal frame or sketch coordinates skew.
			var ortho = MathF.Abs( Vec3.Dot( plane.XAxis, plane.YAxis ) ) < 1e-4f
				&& MathF.Abs( plane.XAxis.Length - 1f ) < 1e-4f
				&& MathF.Abs( plane.YAxis.Length - 1f ) < 1e-4f;

			Report.Check( $"plane from normal {n} has an orthonormal frame", ortho );

			// Same input, same axes - a sketch must not spin on its own plane between rebuilds.
			var again = FacePlane.FromPointAndNormal( new Vec3( 1, 2, 3 ), n );

			Report.Check( $"plane from normal {n} is deterministic",
				again.XAxis.AlmostEquals( plane.XAxis ) && again.YAxis.AlmostEquals( plane.YAxis ) );
		}
	}

	/// <summary>Find the top face of the first body the way a click would: the face pointing up.</summary>
	static FaceRef TopFaceOf( PartStudio studio )
	{
		var mesh = studio.Bodies[0].Mesh;

		var top = mesh.Faces
			.Select( f => (Face: f, Normal: mesh.FaceNormal( f ), Centroid: mesh.FaceCentroid( f )) )
			.Where( t => t.Normal.z > 0.99f )
			.OrderByDescending( t => t.Centroid.z )
			.First();

		return new FaceRef( studio.Bodies[0].Id, top.Centroid, top.Normal );
	}

	static void TestBossOnTopOfBox()
	{
		var studio = new PartStudio();

		var box = studio.Add( new PrimitiveFeature() );
		box.SizeX.Value = 4f;
		box.SizeY.Value = 4f;
		box.SizeZ.Value = 2f;
		studio.Rebuild();

		var boxTopZ = studio.Bodies[0].Mesh.Positions.Max( p => p.z );

		// Sketch on the top face and extrude a smaller square up from it.
		var sketch = studio.Add( new SketchFeature() );
		sketch.Face = TopFaceOf( studio );
		sketch.Sketch.AddRectangle( new Vec2( -0.5f, -0.5f ), new Vec2( 0.5f, 0.5f ) );

		var boss = studio.Add( new ExtrudeFeature() );
		boss.Distance.Value = 1f;

		// Kept as its own body ON PURPOSE. The default now merges a face-attached extrude into the
		// body it grows from, which is what anyone building a part wants — but it also means the
		// boss stops being separately measurable, and what these tests are about is where the
		// SKETCH landed. Merging has its own tests; this one wants the boss on its own.
		boss.Result.Index = 1;

		var report = studio.Rebuild();

		Report.Check( "it builds", !report.HasErrors, report.ToString() );
		Report.Check( "there are two bodies - the box and the boss", studio.Bodies.Count == 2,
			$"{studio.Bodies.Count}" );

		if ( studio.Bodies.Count != 2 )
			return;

		var bossMesh = studio.Bodies[1].Mesh;
		var bossLow = bossMesh.Positions.Min( p => p.z );
		var bossHigh = bossMesh.Positions.Max( p => p.z );

		Report.Check( "the boss starts exactly on the box's top face",
			MathF.Abs( bossLow - boxTopZ ) < 1e-3f, $"boss starts at {bossLow}, box top is {boxTopZ}" );

		Report.Check( "and stands 1 unit proud of it",
			MathF.Abs( bossHigh - boxTopZ - 1f ) < 1e-3f, $"{bossHigh - boxTopZ}" );
	}

	/// <summary>
	/// The point of storing geometry rather than an index: make the box taller and the sketch
	/// should follow the face up, without being re-picked.
	/// </summary>
	static void TestReferenceSurvivesUpstreamEdit()
	{
		var studio = new PartStudio();

		var box = studio.Add( new PrimitiveFeature() );
		box.SizeX.Value = 4f;
		box.SizeY.Value = 4f;
		box.SizeZ.Value = 2f;
		studio.Rebuild();

		var sketch = studio.Add( new SketchFeature() );
		sketch.Face = TopFaceOf( studio );
		sketch.Sketch.AddRectangle( new Vec2( -0.5f, -0.5f ), new Vec2( 0.5f, 0.5f ) );

		var riding = studio.Add( new ExtrudeFeature() );
		riding.Distance.Value = 1f;
		riding.Result.Index = 1; // separate body, so the boss can be measured on its own
		studio.Rebuild();

		var firstBossLow = studio.Bodies[1].Mesh.Positions.Min( p => p.z );

		// Grow the box. Its top face moves; the sketch must move with it.
		box.SizeZ.Value = 6f;
		studio.MarkDirty( box );
		var report = studio.Rebuild();

		Report.Check( "it still builds after the box changed", !report.HasErrors, report.ToString() );

		if ( report.HasErrors || studio.Bodies.Count < 2 )
			return;

		var newBoxTop = studio.Bodies[0].Mesh.Positions.Max( p => p.z );
		var newBossLow = studio.Bodies[1].Mesh.Positions.Min( p => p.z );

		Report.Check( "the box did get taller", newBoxTop > firstBossLow + 0.5f,
			$"top now {newBoxTop}, boss used to sit at {firstBossLow}" );

		Report.Check( "and the boss moved up with the face it was drawn on",
			MathF.Abs( newBossLow - newBoxTop ) < 1e-3f,
			$"boss at {newBossLow}, face at {newBoxTop}" );
	}

	static void TestLostFace()
	{
		var studio = new PartStudio();
		studio.Add( new PrimitiveFeature() );
		studio.Rebuild();

		var sketch = studio.Add( new SketchFeature() );

		// A reference into a body that does not exist. Scoping the reference to its body is what
		// makes this detectable at all - an unscoped point-and-normal would happily resolve onto
		// whatever else happened to be facing that way.
		sketch.Face = new FaceRef( "body_that_never_existed", new Vec3( 0, 0, 1 ), new Vec3( 0, 0, 1 ) );
		sketch.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 1, 1 ) );

		var report = studio.Rebuild();

		Report.Check( "a reference matching nothing is a clear error, not a silent fallback",
			report.HasErrors && sketch.Error is not null && sketch.Error.Contains( "gone" ),
			sketch.Error ?? "no error" );
	}

	// --- the face's outline, as reference geometry ---------------------------------------------

	/// <summary>A box with a sketch on its top face, rebuilt and ready to be asked what is
	/// underneath that sketch.</summary>
	static (PartStudio Studio, SketchFeature Sketch) BoxWithFaceSketch( float sizeX, float sizeY, float sizeZ )
	{
		var studio = new PartStudio();

		var box = studio.Add( new PrimitiveFeature() );
		box.SizeX.Value = sizeX;
		box.SizeY.Value = sizeY;
		box.SizeZ.Value = sizeZ;
		studio.Rebuild();

		var sketch = studio.Add( new SketchFeature() );
		sketch.Face = TopFaceOf( studio );
		studio.Rebuild();

		return (studio, sketch);
	}

	static (float Width, float Height) Extent( SketchReference reference )
	{
		var minX = reference.Points.Min( p => p.x );
		var maxX = reference.Points.Max( p => p.x );
		var minY = reference.Points.Min( p => p.y );
		var maxY = reference.Points.Max( p => p.y );

		return (maxX - minX, maxY - minY);
	}

	static void TestReferenceOutline()
	{
		var (studio, sketch) = BoxWithFaceSketch( 4f, 6f, 2f );
		var reference = SketchReference.FromFace( studio.Bodies, sketch.Face.Value, sketch.Sketch.Plane );

		Report.Check( "the top face of a box gives four corners and four edges",
			reference.Points.Count == 4 && reference.Edges.Count == 4,
			$"{reference.Points.Count} points, {reference.Edges.Count} edges" );

		var (width, height) = Extent( reference );

		// 4 x 6, in whichever order the plane's axes happen to fall - the axes come from the normal
		// alone (FromPointAndNormal) and this test is not the place to pin down which is which.
		Report.Check( "and they are the size of the face",
			MathF.Abs( MathF.Min( width, height ) - 4f ) < 1e-3f
			&& MathF.Abs( MathF.Max( width, height ) - 6f ) < 1e-3f,
			$"{width} x {height}" );

		// EVERY corner is on the sketch plane, not merely near it. The outline is drawn and clicked
		// in plane coordinates, so a projection that quietly kept some of the face's depth would put
		// the snap targets somewhere the cursor can never reach.
		var onPlane = reference.Points.All( p =>
		{
			var world = sketch.Sketch.Plane.ToWorld( p );
			return MathF.Abs( Vec3.Dot( world - sketch.Sketch.Plane.Origin, sketch.Sketch.Plane.Normal ) ) < 1e-4f;
		} );

		Report.Check( "every corner lies in the sketch plane", onPlane );

		// A sketch on one of the three global planes has nothing underneath it to reference, and
		// must not invent any.
		var plain = new PartStudio();
		plain.Add( new PrimitiveFeature() );
		var loose = plain.Add( new SketchFeature() );
		plain.Rebuild();

		Report.Check( "a sketch on a global plane has no reference geometry",
			loose.Face is null );

		// THE SEAM RULE. A face that has been through a boolean is several coplanar faces, and the
		// cuts between them are not edges of anything - outlining each face separately would draw
		// lines across the middle of a flat surface and offer them as snap targets. Here the top of
		// the box is split into two triangles by hand, which is exactly that situation.
		var split = Primitives.Box( 4f, 4f, 2f );
		var topIndex = split.Faces.FindIndex( f => split.FaceNormal( f ).z > 0.99f );
		var top = split.Faces[topIndex];
		var corners = top.Indices;

		split.Faces.RemoveAt( topIndex );
		split.AddFace( new[] { corners[0], corners[1], corners[2] } );
		split.AddFace( new[] { corners[0], corners[2], corners[3] } );

		var body = new Body( "split", "Split", split );
		var seamFace = split.Faces.FindIndex( f => split.FaceNormal( f ).z > 0.99f );
		var seamRef = FacePlane.Capture( body, seamFace, split.FaceCentroid( split.Faces[seamFace] ) );

		FacePlane.TryResolve( new[] { body }, seamRef, out var seamPlane );

		var seam = SketchReference.FromFace( new[] { body }, seamRef, seamPlane );

		Report.Check( "a top face split into two triangles still outlines as one square",
			seam.Points.Count == 4 && seam.Edges.Count == 4,
			$"{seam.Points.Count} points, {seam.Edges.Count} edges" );
	}

	/// <summary>
	/// The outline is rebuilt from the model rather than stored, so it must move when the face
	/// does. This is the same property TestReferenceSurvivesUpstreamEdit checks for the plane, and
	/// it matters more here: a snap target left behind at the old size is worse than none at all,
	/// because it looks exactly like a correct one.
	/// </summary>
	static void TestReferenceOutlineFollowsFace()
	{
		var (studio, sketch) = BoxWithFaceSketch( 4f, 4f, 2f );

		var before = SketchReference.FromFace( studio.Bodies, sketch.Face.Value, sketch.Sketch.Plane );
		var (beforeWidth, _) = Extent( before );

		var box = studio.Features.OfType<PrimitiveFeature>().First();
		box.SizeX.Value = 10f;
		studio.MarkDirty( box );
		studio.Rebuild();

		var after = SketchReference.FromFace( studio.Bodies, sketch.Face.Value, sketch.Sketch.Plane );
		var (afterWidth, afterHeight) = Extent( after );

		Report.Check( "the outline was 4 units across before the box grew",
			MathF.Abs( beforeWidth - 4f ) < 1e-3f, $"{beforeWidth}" );

		Report.Check( "and 10 after",
			MathF.Abs( MathF.Max( afterWidth, afterHeight ) - 10f ) < 1e-3f,
			$"{afterWidth} x {afterHeight}" );
	}

	/// <summary>
	/// Drawing against the outline: a click near a corner of the face lands ON that corner, a click
	/// near one of its edges lands ON that edge, and neither is allowed to outrank the sketch's own
	/// points - closing a profile is the snap everything downstream depends on.
	/// </summary>
	static void TestSnapToReference()
	{
		var (studio, sketch) = BoxWithFaceSketch( 4f, 4f, 2f );
		var reference = SketchReference.FromFace( studio.Bodies, sketch.Face.Value, sketch.Sketch.Plane );

		if ( reference.Points.Count != 4 )
		{
			Report.Check( "the outline is there to snap to", false, $"{reference.Points.Count} points" );
			return;
		}

		// Screen-space tolerances, the way the viewport supplies them: a ~4 unit part framed in a
		// ~700px viewport, 12px of reach.
		var upp = 4f * 1.6f / 700f;

		var snapper = new SketchSnapper
		{
			PointRadius = 12f * upp,
			AlignmentRadius = 7f * upp,
			GridStep = 0f,
			Reference = reference,
			ReferencePointRadius = 12f * upp,
			ReferenceEdgeRadius = 12f * upp,
		};

		var corner = reference.Points[0];
		var nearCorner = corner + new Vec2( 3f * upp, 3f * upp );

		var onCorner = snapper.Snap( new Sketch(), nearCorner, Array.Empty<Vec2>(), false );

		Report.Check( "a click near a corner of the face lands exactly on it",
			onCorner.ReferencePointIndex == 0
			&& (onCorner.Point - corner).Length < 1e-5f,
			$"reference point {onCorner.ReferencePointIndex}, off by {(onCorner.Point - corner).Length}" );

		// Mid-edge, nudged off it. The landing point must be on the segment and nowhere near either
		// end, or the edge snap is really just a second corner snap.
		var (a, b) = reference.Segment( 0 );
		var middle = (a + b) * 0.5f;
		var normal = new Vec2( -(b - a).Normal.y, (b - a).Normal.x );

		var onEdge = snapper.Snap( new Sketch(), middle + normal * (4f * upp), Array.Empty<Vec2>(), false );

		Report.Check( "a click near an edge of the face lands on the edge",
			onEdge.ReferenceEdgeIndex == 0
			&& (onEdge.Point - SketchSnapper.ClosestOnSegment( a, b, onEdge.Point )).Length < 1e-5f,
			$"reference edge {onEdge.ReferenceEdgeIndex}, point {onEdge.Point}" );

		Report.Check( "and slides along it rather than jumping to an end",
			(onEdge.Point - middle).Length < 1e-3f, $"{(onEdge.Point - middle).Length} from the middle" );

		// A point already in the sketch, sitting slightly further away than the face's corner. The
		// sketch's own point still wins: this is the snap that closes a chain, and a corner of the
		// scenery underneath must never take it.
		var withPoint = new Sketch();
		withPoint.AddPoint( corner + new Vec2( 6f * upp, 0f ) );

		var contested = snapper.Snap( withPoint, corner + new Vec2( 5f * upp, 0f ), Array.Empty<Vec2>(), false );

		Report.Check( "the sketch's own point outranks a face corner when it is nearer",
			contested.SnappedPointIndex == 0 && contested.ReferencePointIndex < 0,
			$"sketch point {contested.SnappedPointIndex}, reference point {contested.ReferencePointIndex}" );

		// A LINE IN PROGRESS, aimed near an edge of the face. The line tool turns InferenceAxis into
		// a real Vertical/Horizontal constraint on the line it commits, so a snap that reports one
		// while landing somewhere that does not satisfy it attaches a rule the geometry breaks - and
		// the solver then drags the point off the edge it was just placed on.
		var start = middle + new Vec2( 0f, 20f * upp );
		var aimed = snapper.Snap( new Sketch(), middle + normal * (3f * upp), new[] { start }, lineInProgress: true );

		Report.Check( "a snap onto an edge claims no axis lock it does not satisfy",
			aimed.ReferenceEdgeIndex >= 0 && aimed.InferenceAxis == 0,
			$"edge {aimed.ReferenceEdgeIndex}, inference {aimed.InferenceAxis}" );

		// And the first corner of a half-drawn shape is not stolen by an edge running past it. This
		// is how a rectangle closes on a face - the pending corner has to beat the block's outline.
		var pendingCorner = corner + new Vec2( 2f * upp, 2f * upp );
		var closing = snapper.Snap( new Sketch(), pendingCorner + new Vec2( upp, upp ),
			new[] { pendingCorner }, lineInProgress: false );

		Report.Check( "a pending corner outranks the face's edge running past it",
			closing.ReferenceEdgeIndex < 0
			&& (closing.Point - pendingCorner).Length < 1e-5f,
			$"edge {closing.ReferenceEdgeIndex}, landed {(closing.Point - pendingCorner).Length} away" );

		// And with reference snapping off, the outline is inert - the same click grid-snaps as if
		// no face were underneath at all.
		snapper.ReferencePointRadius = 0f;
		snapper.ReferenceEdgeRadius = 0f;

		var ignored = snapper.Snap( new Sketch(), nearCorner, Array.Empty<Vec2>(), false );

		Report.Check( "turning reference snapping off makes the outline inert",
			ignored.ReferencePointIndex < 0 && ignored.ReferenceEdgeIndex < 0
			&& (ignored.Point - nearCorner).Length < 1e-5f,
			$"landed at {ignored.Point}, clicked at {nearCorner}" );
	}

	/// <summary>
	/// Use: turning the face's outline into geometry the sketch actually owns.
	///
	/// THIS IS THE THING THE OUTLINE ALONE CANNOT DO. Drawing one line across a face and expecting
	/// it to split in two is the natural move and it produces nothing, because the sketch contains
	/// one open line and no boundary — the face's edges are scenery until something copies them in.
	/// Onshape makes you press Use for exactly this reason. Every check below is that workflow.
	/// </summary>
	static void TestUseReference()
	{
		var (studio, sketch) = BoxWithFaceSketch( 4f, 4f, 2f );
		var reference = SketchReference.FromFace( studio.Bodies, sketch.Face.Value, sketch.Sketch.Plane );

		// The failure that sends you looking for a Use tool: a lone line across the face closes
		// nothing, because there is no boundary for it to close against.
		var lonely = new Sketch();
		lonely.Add( new SketchLine( lonely.AddPoint( new Vec2( -3f, 0f ) ), lonely.AddPoint( new Vec2( 3f, 0f ) ) ) );

		Report.Check( "a line drawn across a bare face closes no region at all",
			ProfileFinder.Find( lonely ).Profiles.Count == 0,
			$"{ProfileFinder.Find( lonely ).Profiles.Count} profiles" );

		// Use the whole outline, then the same line splits it in two.
		var used = new Sketch();
		var added = reference.UseAll( used );

		Report.Check( "Use all brings the four edges in", added == 4 && used.Curves.Count == 4,
			$"{added} added, {used.Curves.Count} curves" );

		Report.Check( "and they weld into four shared corners, not eight loose ends",
			used.Points.Count == 4, $"{used.Points.Count} points" );

		var whole = ProfileFinder.Find( used );

		Report.Check( "the outline on its own is one closed region",
			whole.Profiles.Count == 1 && whole.Warnings.Count == 0,
			$"{whole.Profiles.Count} profiles, {whole.Warnings.Count} warnings" );

		// CORNER TO CORNER, which is the case that works with nothing but Use. The diagonal's ends
		// are two corners the outline already owns, so those points reach degree three and the
		// half-edge walk splits the square into two triangles.
		var diagonal = new Sketch();
		reference.UseAll( diagonal );

		var corners = diagonal.Points.ToList();
		var far = 1;

		for ( var i = 1; i < corners.Count; i++ )
		{
			if ( (corners[i] - corners[0]).Length > (corners[far] - corners[0]).Length )
				far = i;
		}

		diagonal.Add( new SketchLine( 0, far ) );

		var split = ProfileFinder.Find( diagonal );

		Report.Check( "a diagonal between two used corners splits the face into two regions",
			split.Profiles.Count == 2, $"{split.Profiles.Count} profiles" );

		if ( split.Profiles.Count == 2 )
		{
			var total = split.Profiles.Sum( p => p.Area );

			Report.Check( "and the two halves add up to the whole face",
				MathF.Abs( total - 16f ) < 1e-2f, $"{total} vs 16" );
		}

		// MID-EDGE TO MID-EDGE, which is the move anyone actually reaches for. The line's ends sit
		// ON two opposite edges but are not endpoints OF them. The integer walk still prunes them
		// as dangling — coincidence is identity — and RecoverCutRegions imprints the T-junctions
		// on a copy so the two halves show up as faces. The sketch itself is not rewritten.
		var across = new Sketch();
		reference.UseAll( across );

		var lowX = across.Points.Min( p => p.x );
		var highX = across.Points.Max( p => p.x );
		var midY = across.Points.Average( p => p.y );

		across.Add( new SketchLine(
			SketchSnapper.PointIndex( across, new Vec2( lowX, midY ) ),
			SketchSnapper.PointIndex( across, new Vec2( highX, midY ) ) ) );

		var curvesBefore = across.Curves.Count;
		var crossed = ProfileFinder.Find( across );

		Report.Check( "a line across the middle splits the face into two regions",
			crossed.Profiles.Count == 2 && crossed.Warnings.Count == 0,
			$"{crossed.Profiles.Count} profiles, {crossed.Warnings.Count} warnings: "
			+ string.Join( " | ", crossed.Warnings ) );

		Report.Check( "without rewriting the sketch",
			across.Curves.Count == curvesBefore, $"{across.Curves.Count} curves, were {curvesBefore}" );

		if ( crossed.Profiles.Count == 2 )
		{
			Report.Check( "and the two halves add up to the whole face",
				MathF.Abs( crossed.Profiles.Sum( p => p.Area ) - 16f ) < 1e-2f,
				$"{crossed.Profiles.Sum( p => p.Area )} vs 16" );
		}

		// --- one edge at a time, and the guards ---------------------------------------------

		var single = new Sketch();

		Report.Check( "Use on one edge adds exactly that line",
			reference.UseEdge( single, 0 ) is not null && single.Curves.Count == 1,
			$"{single.Curves.Count} curves" );

		// USING THE SAME EDGE TWICE MUST NOT DOUBLE IT. Two curves between one pair of points is the
		// branching case ProfileFinder refuses, so a second click on an edge already used would
		// quietly destroy the profile being built rather than doing nothing.
		Report.Check( "using the same edge again is refused rather than doubled",
			reference.UseEdge( single, 0 ) is null && single.Curves.Count == 1,
			$"{single.Curves.Count} curves" );

		Report.Check( "and running Use all afterwards tops up the rest",
			reference.UseAll( single ) == 3 && single.Curves.Count == 4,
			$"{single.Curves.Count} curves" );

		Report.Check( "an out-of-range edge is refused rather than throwing",
			reference.UseEdge( single, 99 ) is null && reference.UseEdge( single, -1 ) is null );

		// A sketch on a global plane has an empty reference, so Use is a no-op rather than an error.
		Report.Check( "Use on an empty reference adds nothing",
			new SketchReference().UseAll( new Sketch() ) == 0 );
	}
}