Effigy.Tests/SweepLoftTests.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy.Tests;

/// <summary>
/// Sweep and loft, checked against volumes that can be written down in advance.
///
/// VOLUME IS THE TEST THAT CATCHES THE REAL FAILURES. Both features can produce a mesh that is
/// closed, manifold, has the expected face count and is still wrong — twisted between sections,
/// inside out, or collapsed at a turn. A prism swept along a straight path has to enclose exactly
/// area times length, and a loft between two squares has to enclose the frustum formula. Neither
/// number survives a twist.
///
/// The SIGN of the volume is asserted too, not just its size. A solid built with its rings stitched
/// the wrong way round encloses the same magnitude with every normal pointing inward, which every
/// closed-and-manifold check in this repo passes happily and which renders as a hole.
/// </summary>
public static class SweepLoftTests
{
	public static void Run()
	{
		Report.Section( "sweep" );
		TestSweep();

		Report.Section( "loft" );
		TestLoft();
	}

	static void TestSweep()
	{
		// A 2x2 square swept 5 units straight up is a prism of volume 20, whatever route the code
		// takes to get there.
		var studio = new PartStudio();

		var profile = studio.Add( new SketchFeature() );
		profile.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var path = studio.Add( new SketchFeature() );
		path.Plane.Index = 1; // Front (XZ), so the path climbs out of the profile's plane
		var bottom = path.Sketch.AddPoint( 0f, 0f );
		var top = path.Sketch.AddPoint( 0f, 5f );
		path.Sketch.Add( new SketchLine( bottom, top ) );

		var sweep = studio.Add( new SweepFeature() );
		sweep.SketchFeatureId = profile.Id;
		sweep.PathSketchId = path.Id;

		var report = studio.Rebuild();

		Report.Check( "a sweep builds", !report.HasErrors,
			string.Join( "; ", studio.Features.Where( f => f.Error is not null ).Select( f => f.Error ) ) );

		var body = studio.Bodies.LastOrDefault();

		Report.Check( "into a closed solid",
			body is not null && MeshValidator.Validate( body.Mesh ).IsClosed,
			body is null ? "no body" : MeshValidator.Validate( body.Mesh ).ToString() );

		if ( body is null )
			return;

		var volume = Volume( body.Mesh );

		Report.Check( "of exactly area times length", MathF.Abs( volume - 20f ) < 0.01f,
			$"{volume:0.####}, wanted 20" );

		Report.Check( "with its faces pointing outward rather than in", volume > 0f,
			$"signed volume {volume:0.####}" );

		// A TURN IS WHERE A NAIVE FRAME BREAKS. The profile has to stay perpendicular to the path
		// through the corner; if it keeps its original orientation the solid pinches or folds, and
		// a fold shows up as a mesh that is no longer manifold.
		var bent = new PartStudio();

		var bentProfile = bent.Add( new SketchFeature() );
		bentProfile.Sketch.AddRectangle( new Vec2( -0.5f, -0.5f ), new Vec2( 0.5f, 0.5f ) );

		var bentPath = bent.Add( new SketchFeature() );
		bentPath.Plane.Index = 1;
		var a = bentPath.Sketch.AddPoint( 0f, 0f );
		var b = bentPath.Sketch.AddPoint( 0f, 4f );
		var c = bentPath.Sketch.AddPoint( 4f, 4f );
		bentPath.Sketch.Add( new SketchLine( a, b ) );
		bentPath.Sketch.Add( new SketchLine( b, c ) );

		var bentSweep = bent.Add( new SweepFeature() );
		bentSweep.SketchFeatureId = bentProfile.Id;
		bentSweep.PathSketchId = bentPath.Id;

		bent.Rebuild();

		var bentBody = bent.Bodies.LastOrDefault();

		Report.Check( "a sweep round a corner stays a closed manifold solid",
			bentBody is not null && MeshValidator.Validate( bentBody.Mesh ).IsClosed,
			bentBody is null ? "no body" : MeshValidator.Validate( bentBody.Mesh ).ToString() );

		Report.Check( "and does not fold itself inside out at the turn",
			bentBody is not null && Volume( bentBody.Mesh ) > 0f,
			bentBody is null ? "no body" : $"{Volume( bentBody.Mesh ):0.####}" );

		// THE ROLES ARE INFERRED FROM THE SKETCHES, not from their order. With nothing configured,
		// the closed sketch has to be taken as the profile even though the open one was drawn last.
		var guessed = new PartStudio();

		var guessedProfile = guessed.Add( new SketchFeature() );
		guessedProfile.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var guessedPath = guessed.Add( new SketchFeature() );
		guessedPath.Plane.Index = 1;
		guessedPath.Sketch.Add( new SketchLine(
			guessedPath.Sketch.AddPoint( 0f, 0f ), guessedPath.Sketch.AddPoint( 0f, 5f ) ) );

		guessed.Add( new SweepFeature() );
		guessed.Rebuild();

		Report.Check( "an unconfigured sweep works out which sketch is the profile and which is the path",
			guessed.Bodies.Count > 0 && MathF.Abs( Volume( guessed.Bodies[^1].Mesh ) - 20f ) < 0.01f,
			guessed.Features.Last().Error ?? $"{(guessed.Bodies.Count > 0 ? Volume( guessed.Bodies[^1].Mesh ) : 0):0.###}" );

		// A closed path has no ends, so the solid must not be capped — a capped torus has two
		// internal walls and stops being manifold.
		var ring = new PartStudio();

		var ringProfile = ring.Add( new SketchFeature() );
		ringProfile.Sketch.AddCircle( new Vec2( 0f, 0f ), 0.4f );

		var ringPath = ring.Add( new SketchFeature() );
		ringPath.Plane.Index = 1;
		ringPath.Sketch.AddCircle( new Vec2( 0f, 0f ), 3f );

		var ringSweep = ring.Add( new SweepFeature() );
		ringSweep.SketchFeatureId = ringProfile.Id;
		ringSweep.PathSketchId = ringPath.Id;

		ring.Rebuild();

		var torus = ring.Bodies.LastOrDefault();

		Report.Check( "a sweep round a closed path is a ring, left uncapped",
			torus is not null && MeshValidator.Validate( torus.Mesh ).IsClosed,
			torus is null ? ring.Features.Last().Error : MeshValidator.Validate( torus.Mesh ).ToString() );

		// Pappus: a torus encloses 2*pi^2*R*r^2. Loose tolerance because both circles are
		// tessellated, and a tessellated ring always falls slightly inside the true one.
		if ( torus is not null )
		{
			var expected = 2f * MathF.PI * MathF.PI * 3f * 0.4f * 0.4f;
			var got = Volume( torus.Mesh );

			Report.Check( "enclosing about what Pappus says it should", MathF.Abs( got - expected ) < expected * 0.05f,
				$"{got:0.####} against {expected:0.####}" );
		}
	}

	static void TestLoft()
	{
		// Two identical 2x2 squares 5 apart. A loft between them is the same prism the sweep made,
		// so the same 20 applies — and a twist between the sections would lose volume to shear.
		var studio = new PartStudio();

		var lower = studio.Add( new SketchFeature() );
		lower.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var upper = studio.Add( new SketchFeature() );
		upper.PlaneOffset.Value = 5f;
		upper.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var loft = studio.Add( new LoftFeature() );
		loft.Sections.Add( lower.Id );
		loft.Sections.Add( upper.Id );
		loft.Segments.Value = 40;

		var report = studio.Rebuild();

		Report.Check( "a loft builds", !report.HasErrors,
			string.Join( "; ", studio.Features.Where( f => f.Error is not null ).Select( f => f.Error ) ) );

		var body = studio.Bodies.LastOrDefault();

		Report.Check( "into a closed solid",
			body is not null && MeshValidator.Validate( body.Mesh ).IsClosed,
			body is null ? "no body" : MeshValidator.Validate( body.Mesh ).ToString() );

		if ( body is null )
			return;

		var volume = Volume( body.Mesh );

		Report.Check( "of the volume of the prism it is", MathF.Abs( volume - 20f ) < 0.05f,
			$"{volume:0.####}, wanted 20" );

		Report.Check( "with its faces pointing outward", volume > 0f, $"signed volume {volume:0.####}" );

		// THE TWIST TEST. The upper square is drawn starting from a different corner and wound the
		// other way. Nothing about the SHAPE changed, so the volume must not change either — and it
		// will, badly, if the sections are skinned point-0-to-point-0 without being aligned first.
		var twisted = new PartStudio();

		var flat = twisted.Add( new SketchFeature() );
		flat.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var turned = twisted.Add( new SketchFeature() );
		turned.PlaneOffset.Value = 5f;

		// Same square, started at the opposite corner and wound clockwise.
		turned.Sketch.AddPolygon(
			new Vec2( 1f, 1f ), new Vec2( 1f, -1f ), new Vec2( -1f, -1f ), new Vec2( -1f, 1f ) );

		var twistedLoft = twisted.Add( new LoftFeature() );
		twistedLoft.Sections.Add( flat.Id );
		twistedLoft.Sections.Add( turned.Id );
		twistedLoft.Segments.Value = 40;

		twisted.Rebuild();

		var twistedBody = twisted.Bodies.LastOrDefault();
		var twistedVolume = twistedBody is null ? 0f : Volume( twistedBody.Mesh );

		Report.Check( "a section drawn from another corner and wound the other way does not twist the loft",
			MathF.Abs( twistedVolume - 20f ) < 0.05f, $"{twistedVolume:0.####}, wanted 20" );

		// A FRUSTUM HAS A FORMULA: h/3 * (A1 + A2 + sqrt(A1*A2)). A loft that interpolated linearly
		// in the wrong place would pass a "closed solid" check and miss this by a wide margin.
		var cone = new PartStudio();

		var wide = cone.Add( new SketchFeature() );
		wide.Sketch.AddRectangle( new Vec2( -2f, -2f ), new Vec2( 2f, 2f ) );

		var narrow = cone.Add( new SketchFeature() );
		narrow.PlaneOffset.Value = 6f;
		narrow.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var coneLoft = cone.Add( new LoftFeature() );
		coneLoft.Sections.Add( wide.Id );
		coneLoft.Sections.Add( narrow.Id );
		coneLoft.Segments.Value = 40;

		cone.Rebuild();

		var frustum = cone.Bodies.LastOrDefault();
		var expected = 6f / 3f * (16f + 4f + MathF.Sqrt( 16f * 4f ));

		Report.Check( "a loft between different sizes matches the frustum formula",
			frustum is not null && MathF.Abs( Volume( frustum.Mesh ) - expected ) < 0.1f,
			frustum is null ? "no body" : $"{Volume( frustum.Mesh ):0.####}, wanted {expected:0.####}" );

		// Three sections, to prove the stack is not hard-wired to two.
		var stack = new PartStudio();

		var one = stack.Add( new SketchFeature() );
		one.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var two = stack.Add( new SketchFeature() );
		two.PlaneOffset.Value = 2f;
		two.Sketch.AddRectangle( new Vec2( -2f, -2f ), new Vec2( 2f, 2f ) );

		var three = stack.Add( new SketchFeature() );
		three.PlaneOffset.Value = 4f;
		three.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 1f ) );

		var stackLoft = stack.Add( new LoftFeature() );
		stackLoft.Sections.Add( one.Id );
		stackLoft.Sections.Add( two.Id );
		stackLoft.Sections.Add( three.Id );
		stackLoft.Segments.Value = 40;

		stack.Rebuild();

		var stacked = stack.Bodies.LastOrDefault();

		// Two frustums back to back, each 2 tall between areas 4 and 16.
		var half = 2f / 3f * (4f + 16f + MathF.Sqrt( 4f * 16f ));

		Report.Check( "three sections loft as two frustums back to back",
			stacked is not null && MathF.Abs( Volume( stacked.Mesh ) - half * 2f ) < 0.2f,
			stacked is null ? "no body" : $"{Volume( stacked.Mesh ):0.####}, wanted {half * 2f:0.####}" );

		Report.Check( "a loft with only one section says so rather than building nothing",
			OneSectionFails(), "it built something" );
	}

	static bool OneSectionFails()
	{
		var studio = new PartStudio();

		var only = studio.Add( new SketchFeature() );
		only.Sketch.AddRectangle( new Vec2( 0f, 0f ), new Vec2( 1f, 1f ) );

		var loft = studio.Add( new LoftFeature() );
		loft.Sections.Add( only.Id );

		studio.Rebuild();

		return loft.Error is not null;
	}

	/// <summary>
	/// Signed volume by the divergence theorem, summed over a fan of each face. Positive when the
	/// faces wind so their normals point out of the solid.
	/// </summary>
	static float Volume( PolyMesh mesh ) => mesh.SignedVolume();
}