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

namespace Effigy.Tests;

/// <summary>
/// Datum planes: placing a plane yourself, and drawing on it.
///
/// THE TWO HALVES ARE THE FRAME AND THE REFERENCE, and they fail in completely different ways. The
/// frame is arithmetic — offset along a normal, hinge about an axis — and it is wrong loudly: the
/// sketch appears somewhere you can see is not where you asked. The reference is the parametric
/// half, and it is wrong QUIETLY: a plane that stops riding the face it was built from still
/// produces a perfectly good sketch, in the wrong place, on a rebuild nobody was watching. So most
/// of what is here is the second kind — change the thing underneath and check that everything on
/// top moved with it.
/// </summary>
public static class PlaneTests
{
	public static void Run()
	{
		Report.Section( "planes: tilting a frame about its own axis" );
		TestTiltedFrame();

		Report.Section( "planes: an offset plane off a global one" );
		TestOffsetFromGlobalPlane();

		Report.Section( "planes: an angled plane leans what is built on it" );
		TestAngledPlane();

		Report.Section( "planes: which way the offset travels" );
		TestOffsetAxis();

		Report.Section( "planes: a plane built from a face rides that face" );
		TestPlaneRidesItsFace();

		Report.Section( "planes: planes stack" );
		TestChainedPlanes();

		Report.Section( "planes: a sketch on a plane that came off a part stays in that part" );
		TestHostBodyTravels();

		Report.Section( "planes: a reference to a plane that is not running" );
		TestMissingPlaneFails();

		Report.Section( "planes: an incremental rebuild does not lose them" );
		TestPlaneSurvivesIncrementalRebuild();

		Report.Section( "planes: saved and reopened" );
		TestRoundTrip();
	}

	// --- the frame -------------------------------------------------------------------------

	static void TestTiltedFrame()
	{
		var plane = SketchPlane.XY;

		Report.Check( "no angle is no change",
			plane.Tilted( 0f, aboutY: false ).Normal.AlmostEquals( plane.Normal ) );

		foreach ( var aboutY in new[] { false, true } )
		{
			foreach ( var degrees in new[] { -90f, -30f, 15f, 45f, 90f } )
			{
				var tilted = plane.Tilted( degrees, aboutY );

				// Orthonormal or every sketch coordinate on it is skewed, which is the kind of
				// wrong that looks like a modelling mistake rather than a maths one.
				Report.Check( $"tilt {degrees} about {(aboutY ? "Y" : "X")} keeps the axes unit length",
					MathF.Abs( tilted.XAxis.Length - 1f ) < 1e-4f
					&& MathF.Abs( tilted.YAxis.Length - 1f ) < 1e-4f,
					$"{tilted.XAxis.Length} / {tilted.YAxis.Length}" );

				Report.Check( $"tilt {degrees} about {(aboutY ? "Y" : "X")} keeps the axes square",
					MathF.Abs( Vec3.Dot( tilted.XAxis, tilted.YAxis ) ) < 1e-4f,
					$"{Vec3.Dot( tilted.XAxis, tilted.YAxis )}" );

				// The normal moved by exactly the angle asked for.
				var between = MathF.Acos( Math.Clamp( Vec3.Dot( tilted.Normal, plane.Normal ), -1f, 1f ) )
					* 180f / MathF.PI;

				Report.Check( $"tilt {degrees} about {(aboutY ? "Y" : "X")} turns the normal that far",
					MathF.Abs( between - MathF.Abs( degrees ) ) < 1e-3f, $"{between}" );

				// The hinge itself does not move — that is what makes it a hinge.
				var hinge = aboutY ? plane.YAxis : plane.XAxis;
				var after = aboutY ? tilted.YAxis : tilted.XAxis;

				Report.Check( $"tilt {degrees} about {(aboutY ? "Y" : "X")} leaves the hinge axis alone",
					after.AlmostEquals( hinge ), $"{hinge} -> {after}" );

				Report.Check( $"tilt {degrees} about {(aboutY ? "Y" : "X")} pivots where it sits",
					tilted.Origin.AlmostEquals( plane.Origin ), tilted.Origin.ToString() );
			}
		}
	}

	// --- the reference ---------------------------------------------------------------------

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

		var plane = studio.Add( new PlaneFeature() );
		plane.Name = "Raised";
		plane.Offset.Value = 4f;

		var sketch = studio.Add( new SketchFeature() );
		sketch.PlaneFeatureId = plane.Id;
		sketch.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.SketchFeatureId = sketch.Id;
		extrude.Distance.Value = 2f;

		studio.Rebuild();

		Report.Check( "the plane itself builds and makes nothing",
			plane.Error is null && studio.Bodies.Count == 1, plane.Error ?? $"{studio.Bodies.Count} bodies" );

		var bounds = Bounds( studio );

		Report.Check( "a sketch on a plane 4 up extrudes from 4 up",
			MathF.Abs( bounds.MinZ - 4f ) < 1e-3f && MathF.Abs( bounds.MaxZ - 6f ) < 1e-3f,
			$"z {bounds.MinZ} to {bounds.MaxZ}" );

		// Moving the PLANE moves everything on it, with nothing downstream edited. This is the
		// whole reason a plane is a feature rather than a number typed into each sketch.
		plane.Offset.Value = 10f;
		studio.MarkDirty( plane );
		studio.Rebuild();

		bounds = Bounds( studio );

		Report.Check( "moving the plane carries the solid built on it",
			MathF.Abs( bounds.MinZ - 10f ) < 1e-3f, $"z {bounds.MinZ} to {bounds.MaxZ}" );
	}

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

		var plane = studio.Add( new PlaneFeature() );
		plane.Angle.Value = 45f;
		plane.Hinge.Index = 0;      // its X axis, so the tilt happens in Y/Z

		var sketch = studio.Add( new SketchFeature() );
		sketch.PlaneFeatureId = plane.Id;
		sketch.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.SketchFeatureId = sketch.Id;
		extrude.Distance.Value = 4f;

		studio.Rebuild();

		Report.Check( "an angled plane builds", extrude.Error is null && studio.Bodies.Count == 1,
			extrude.Error ?? $"{studio.Bodies.Count} bodies" );

		// Pulled along a normal 45° off vertical, so the solid leans: the run in Y equals the rise
		// in Z. On the flat plane it would have had no Y extent past the profile at all.
		var bounds = Bounds( studio );
		var runY = bounds.MaxY - bounds.MinY;
		var runZ = bounds.MaxZ - bounds.MinZ;

		Report.Check( "the solid leans by the angle it was given",
			MathF.Abs( runY - runZ ) < 1e-2f && runZ > 2f, $"y {runY}, z {runZ}" );
	}

	/// <summary>
	/// Which direction Offset moves the plane in — the question the viewport's offset handle asks
	/// before it draws its arrow.
	///
	/// IT IS ONLY INTERESTING BECAUSE OF THE ORDER Execute does things in. Offset first, tilt second,
	/// about the origin the offset landed on: so a tilted plane FACES one way and MOVES another, and
	/// the two are the same direction only while the angle is zero. Everything an editor draws along
	/// "the plane's normal" is therefore right in the easy case and quietly wrong in the case the
	/// feature exists for.
	/// </summary>
	static void TestOffsetAxis()
	{
		var studio = new PartStudio();

		var flat = studio.Add( new PlaneFeature() );
		flat.Offset.Value = 3f;

		var leaning = studio.Add( new PlaneFeature() );
		leaning.Offset.Value = 3f;
		leaning.Angle.Value = 40f;
		leaning.Hinge.Index = 0;        // its X axis, so the lean happens in Y/Z

		studio.Rebuild();

		var flatPlane = studio.Planes[flat.Id];
		var leaningPlane = studio.Planes[leaning.Id];

		Report.Check( "a flat plane offsets the way it faces",
			flat.OffsetAxis( flatPlane ).AlmostEquals( flatPlane.Normal ),
			$"{flat.OffsetAxis( flatPlane )} vs {flatPlane.Normal}" );

		Report.Check( "a tilted one offsets the way it faced BEFORE the tilt",
			leaning.OffsetAxis( leaningPlane ).AlmostEquals( new Vec3( 0f, 0f, 1f ) ),
			$"{leaning.OffsetAxis( leaningPlane )}" );

		Report.Check( "which is not the way it faces now",
			!leaning.OffsetAxis( leaningPlane ).AlmostEquals( leaningPlane.Normal ),
			$"{leaningPlane.Normal}" );

		// The claim the handle makes, checked against the rebuild rather than against the arithmetic
		// that produced it: change Offset by five and the origin moves five along this axis and
		// nowhere else. An arrow drawn on it therefore stays under the cursor.
		var before = leaningPlane.Origin;

		leaning.Offset.Value = 8f;
		studio.MarkDirty( leaning );
		studio.Rebuild();

		var travelled = studio.Planes[leaning.Id].Origin - before;
		var expected = leaning.OffsetAxis( studio.Planes[leaning.Id] ) * 5f;

		Report.Check( "and the origin travels along it, five units for five",
			(travelled - expected).Length < 1e-3f, $"moved {travelled}, expected {expected}" );
	}

	static void TestPlaneRidesItsFace()
	{
		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 plane = studio.Add( new PlaneFeature() );
		plane.Face = TopFaceOf( studio.Bodies[0] );
		plane.Offset.Value = 3f;

		var sketch = studio.Add( new SketchFeature() );
		sketch.PlaneFeatureId = plane.Id;
		sketch.Sketch.AddRectangle( new Vec2( -0.5f, -0.5f ), new Vec2( 0.5f, 0.5f ) );

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.SketchFeatureId = sketch.Id;
		extrude.Distance.Value = 1f;
		extrude.Result.Index = 1;   // New body, so the tab's own extent is readable on its own

		studio.Rebuild();

		Report.Check( "a plane on a face builds", plane.Error is null && extrude.Error is null,
			plane.Error ?? extrude.Error ?? "" );

		var tab = studio.Bodies.Last();

		// The box is centred, so its top face is at +1 and the plane sits 3 above that.
		Report.Check( "the tab starts 3 above the face the plane was taken from",
			MathF.Abs( MinZ( tab ) - 4f ) < 1e-3f, $"{MinZ( tab )}" );

		// THE POINT OF THE WHOLE TYPE. Make the box taller: the face moves, so the plane must move
		// with it, and so must everything drawn on the plane. A plane that stored the world height
		// it happened to be at when it was made would leave the tab buried in the taller box.
		box.SizeZ.Value = 6f;
		studio.MarkDirty( box );
		studio.Rebuild();

		tab = studio.Bodies.Last();

		Report.Check( "make the box taller and the plane rides its top face",
			MathF.Abs( MinZ( tab ) - 6f ) < 1e-3f, $"{MinZ( tab )}" );
	}

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

		var first = studio.Add( new PlaneFeature() );
		first.Offset.Value = 5f;

		var second = studio.Add( new PlaneFeature() );
		second.BasePlaneId = first.Id;
		second.Offset.Value = 5f;

		var sketch = studio.Add( new SketchFeature() );
		sketch.PlaneFeatureId = second.Id;
		sketch.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.SketchFeatureId = sketch.Id;
		extrude.Distance.Value = 1f;

		studio.Rebuild();

		Report.Check( "a plane built from a plane builds",
			second.Error is null && studio.Bodies.Count == 1, second.Error ?? "" );

		Report.Check( "and the offsets add up",
			MathF.Abs( MinZ( studio.Bodies[0] ) - 10f ) < 1e-3f, $"{MinZ( studio.Bodies[0] )}" );

		// One number at the bottom of the chain moves everything above it. This is what stacking is
		// for, and it is the thing three separately-measured planes cannot do.
		first.Offset.Value = 1f;
		studio.MarkDirty( first );
		studio.Rebuild();

		Report.Check( "editing the plane underneath moves the whole chain",
			MathF.Abs( MinZ( studio.Bodies[0] ) - 6f ) < 1e-3f, $"{MinZ( studio.Bodies[0] )}" );
	}

	static void TestHostBodyTravels()
	{
		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 plane = studio.Add( new PlaneFeature() );
		plane.Face = TopFaceOf( studio.Bodies[0] );

		var sketch = studio.Add( new SketchFeature() );
		sketch.PlaneFeatureId = plane.Id;
		sketch.Sketch.AddRectangle( new Vec2( -0.5f, -0.5f ), new Vec2( 0.5f, 0.5f ) );

		// Result is Auto, which adds to the body the sketch grew out of. A plane in between must not
		// break that chain, or building through a datum plane silently starts a second part every
		// time.
		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.SketchFeatureId = sketch.Id;
		extrude.Distance.Value = 1f;

		studio.Rebuild();

		Report.Check( "a boss built through a plane off a face joins that part",
			studio.Bodies.Count == 1, $"{studio.Bodies.Count} bodies" );

		// And the opposite: a plane off a GLOBAL plane has no host, so Auto starts a new part.
		var loose = studio.Add( new PlaneFeature() );
		loose.Offset.Value = 8f;

		var apart = studio.Add( new SketchFeature() );
		apart.PlaneFeatureId = loose.Id;
		apart.Sketch.AddRectangle( new Vec2( -0.5f, -0.5f ), new Vec2( 0.5f, 0.5f ) );

		var second = studio.Add( new ExtrudeFeature() );
		second.SketchFeatureId = apart.Id;
		second.Distance.Value = 1f;

		studio.Rebuild();

		Report.Check( "a plane off a global plane starts its own part instead",
			studio.Bodies.Count == 2, $"{studio.Bodies.Count} bodies" );
	}

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

		var plane = studio.Add( new PlaneFeature() );
		plane.Offset.Value = 3f;

		var sketch = studio.Add( new SketchFeature() );
		sketch.PlaneFeatureId = plane.Id;
		sketch.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		studio.Rebuild();
		Report.Check( "the pair builds to start with", sketch.Error is null, sketch.Error ?? "" );

		// Suppressing the plane is the ordinary way to reach this: the sketch is standing on
		// something that is no longer running.
		plane.Suppressed = true;
		studio.MarkDirty( plane );
		studio.Rebuild();

		Report.Check( "a sketch on a suppressed plane fails rather than falling back to Top",
			sketch.Error is not null, "built anyway" );

		Report.Check( "and says what to do about it",
			sketch.Diagnostic is { Remedies.Count: > 0 } d && !string.IsNullOrEmpty( d.Cause ),
			sketch.Error ?? "" );

		// A plane BELOW the sketch has not published anything by the time the sketch runs, which is
		// what makes a cycle unrepresentable rather than something to detect.
		plane.Suppressed = false;
		studio.Move( studio.Features.IndexOf( plane ), studio.Features.Count - 1 );
		studio.Rebuild();

		Report.Check( "a plane below the sketch is not available to it",
			sketch.Error is not null, "built anyway" );
	}

	/// <summary>
	/// Editing something BELOW a plane reuses the plane from the cache rather than re-running it.
	/// The snapshot has to carry the published planes or the sketch above the edit finds nothing to
	/// stand on — a failure that appears on a rebuild which changed nothing the sketch could see,
	/// which is the worst kind to go looking for.
	/// </summary>
	static void TestPlaneSurvivesIncrementalRebuild()
	{
		var studio = new PartStudio();

		var plane = studio.Add( new PlaneFeature() );
		plane.Offset.Value = 3f;

		var sketch = studio.Add( new SketchFeature() );
		sketch.PlaneFeatureId = plane.Id;
		sketch.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var extrude = studio.Add( new ExtrudeFeature() );
		extrude.SketchFeatureId = sketch.Id;
		extrude.Distance.Value = 1f;

		var report = studio.Rebuild();
		Report.Check( "the first rebuild is clean", !report.HasErrors, report.ToString() );

		// Only the extrude is dirty, so the plane and the sketch above it come out of the cache.
		extrude.Distance.Value = 2f;
		studio.MarkDirty( extrude );
		report = studio.Rebuild();

		Report.Check( "reusing the cache keeps the plane available",
			!report.HasErrors && report.FeaturesReused > 0, report.ToString() );

		Report.Check( "and the solid is still on it",
			MathF.Abs( MinZ( studio.Bodies[0] ) - 3f ) < 1e-3f, $"{MinZ( studio.Bodies[0] )}" );
	}

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

		var plane = studio.Add( new PlaneFeature() );
		plane.Name = "Roof line";
		plane.Base.Index = 1;
		plane.Offset.Value = 7.5f;
		plane.Angle.Value = 22.5f;
		plane.Hinge.Index = 1;

		var sketch = studio.Add( new SketchFeature() );
		sketch.PlaneFeatureId = plane.Id;
		sketch.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var back = StudioDocument.Read( StudioDocument.Write( studio ) );
		var reopened = back.Features.OfType<PlaneFeature>().Single();

		Report.Check( "a plane comes back with its numbers",
			reopened.Base.Index == 1 && reopened.Offset.Value == 7.5f
			&& reopened.Angle.Value == 22.5f && reopened.Hinge.Index == 1,
			$"{reopened.Base.Index} / {reopened.Offset.Value} / {reopened.Angle.Value} / {reopened.Hinge.Index}" );

		Report.Check( "and the sketch still knows which plane it is on",
			back.Features.OfType<SketchFeature>().Single().PlaneFeatureId == reopened.Id,
			back.Features.OfType<SketchFeature>().Single().PlaneFeatureId );

		// Both must still agree after a rebuild, which is what the id is for.
		back.Rebuild();

		Report.Check( "and the reopened document rebuilds clean",
			back.Features.All( f => f.Error is null ),
			back.Features.FirstOrDefault( f => f.Error is not null )?.Error ?? "" );
	}

	// --- helpers ---------------------------------------------------------------------------

	static FaceRef TopFaceOf( Body body )
	{
		var mesh = body.Mesh;

		for ( var i = 0; i < mesh.Faces.Count; i++ )
		{
			if ( mesh.FaceNormal( mesh.Faces[i] ).Normal.z > 0.99f )
				return FacePlane.Capture( body, i, mesh.FaceCentroid( mesh.Faces[i] ) );
		}

		throw new InvalidOperationException( "no top face" );
	}

	static float MinZ( Body body ) => body.Mesh.Positions.Min( p => p.z );

	static (float MinY, float MaxY, float MinZ, float MaxZ) Bounds( PartStudio studio )
	{
		var points = studio.Bodies.SelectMany( b => b.Mesh.Positions ).ToList();

		return (points.Min( p => p.y ), points.Max( p => p.y ),
			points.Min( p => p.z ), points.Max( p => p.z ));
	}
}