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

namespace Effigy.Tests;

/// <summary>
/// Extrudes that ask the model how far to go rather than being told.
///
/// "Up to face" sits next to "cut" in every CAD tool and reads like it must need a boolean. It does
/// not: both of these are questions about DISTANCE, answered by a raycast, and the solid they
/// produce is an ordinary prism. What a boolean would add is trimming the new solid against the
/// target SURFACE, which is a different thing and is the limitation these tests pin down.
/// </summary>
public static class TerminationTests
{
	public static void Run()
	{
		Report.Section( "termination: up to next stops at the first thing in the way" );
		TestUpToNext();

		Report.Section( "termination: a target that is not parallel" );
		TestAngledTarget();

		Report.Section( "termination: through all clears everything" );
		TestThroughAll();

		Report.Section( "termination: when there is nothing to measure against" );
		TestNothingThere();
	}

	static void TestUpToNext()
	{
		// A plate floating at z = 5, and a post grown up from the origin plane to meet it.
		var studio = new PartStudio();

		var plate = studio.Add( new PrimitiveFeature() );
		plate.SizeX.Value = 10f;
		plate.SizeY.Value = 10f;
		plate.SizeZ.Value = 2f;
		plate.Position.Value = new Vec3( 0f, 0f, 6f ); // spans z 5..7

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

		var post = studio.Add( new ExtrudeFeature() );
		post.Termination.Index = 1; // Up to next
		post.Result.Index = 1; // its own body, so it can be measured

		var report = studio.Rebuild();

		Report.Check( "it builds", !report.HasErrors, report.ToString() );

		if ( report.HasErrors )
			return;

		var body = studio.Bodies.First( b => b.FeatureId == post.Id ).Mesh;
		var top = body.Positions.Max( p => p.z );

		Report.Check( "the post stops exactly at the plate's underside",
			MathF.Abs( top - 5f ) < 1e-3f, $"reached {top:0.####}, the plate starts at 5" );

		Report.Check( "and starts at the sketch plane",
			MathF.Abs( body.Positions.Min( p => p.z ) ) < 1e-4f );

		Report.Check( "with the volume that implies", MathF.Abs( Volume( body ) - 20f ) < 1e-2f,
			$"{Volume( body ):0.####}, expected 4 x 5" );

		Report.Check( "and no warning, since the target is parallel", post.Warning is null, post.Warning );

		// MOVE THE PLATE AND THE POST FOLLOWS. This is the whole point of measuring rather than
		// typing: the distance is a consequence of the model, not a number that goes stale.
		plate.Position.Value = new Vec3( 0f, 0f, 9f ); // now spans z 8..10
		studio.MarkDirty( plate );
		studio.Rebuild();

		var moved = studio.Bodies.First( b => b.FeatureId == post.Id ).Mesh;

		Report.Check( "moving the plate moves where the post stops",
			MathF.Abs( moved.Positions.Max( p => p.z ) - 8f ) < 1e-3f,
			$"reached {moved.Positions.Max( p => p.z ):0.####}, the plate now starts at 8" );

		// The NEAREST obstruction wins, not the furthest — a solid has to stop at the first thing in
		// the way, and anything beyond it is already hidden behind that.
		//
		// INSERTED ABOVE THE EXTRUDE, not appended. A feature only sees what runs before it, so a
		// block added at the end of the tree is not in the way as far as the extrude is concerned —
		// which is the history model working correctly and caught this test out first time.
		var second = new PrimitiveFeature();
		second.SizeX.Value = 10f;
		second.SizeY.Value = 10f;
		second.SizeZ.Value = 2f;
		second.Position.Value = new Vec3( 0f, 0f, 4f ); // spans z 3..5, closer than the plate

		studio.Insert( studio.Features.IndexOf( post ), second );
		studio.Rebuild();

		var stopped = studio.Bodies.First( b => b.FeatureId == post.Id ).Mesh;

		Report.Check( "a nearer obstruction takes precedence",
			MathF.Abs( stopped.Positions.Max( p => p.z ) - 3f ) < 1e-3f,
			$"reached {stopped.Positions.Max( p => p.z ):0.####}, the nearer block starts at 3" );
	}

	static void TestAngledTarget()
	{
		// A block overhead, TILTED, so the face above the profile slopes. A flat cap cannot meet it,
		// and the interesting question is whether the feature says so or quietly leaves a gap.
		//
		// A Wedge primitive was the obvious choice and is the wrong one: it presents a flat underside
		// to a profile below it, so every ray comes back the same distance and there is nothing to
		// warn about. The slope has to actually face the sketch.
		var studio = new PartStudio();

		var slab = studio.Add( new PrimitiveFeature() );
		slab.SizeX.Value = 12f;
		slab.SizeY.Value = 12f;
		slab.SizeZ.Value = 2f;
		slab.Position.Value = new Vec3( 0f, 0f, 8f );

		var tilt = studio.Add( new TransformFeature() );
		tilt.RotationAxis.Value = new Vec3( 1f, 0f, 0f );
		tilt.RotationAngle.Value = 15f;

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

		var post = studio.Add( new ExtrudeFeature() );
		post.Termination.Index = 1;
		post.Result.Index = 1;

		var report = studio.Rebuild();

		Report.Check( "it still builds against a sloped face", !report.HasErrors, report.ToString() );

		if ( report.HasErrors )
			return;

		Report.Check( "and warns that the face is not parallel", post.Warning is not null,
			"no warning — the gap would be silent" );

		Report.Check( "naming both distances so the gap is a number",
			post.Warning is not null && post.Warning.Contains( "between" ), post.Warning ?? "" );

		// It stops at the NEAREST point, which is what keeps it from pushing into the target.
		var body = studio.Bodies.First( b => b.FeatureId == post.Id ).Mesh;
		var top = body.Positions.Max( p => p.z );
		var slabBody = studio.Bodies.First( b => b.FeatureId == slab.Id ).Mesh;

		Report.Check( "and stops short of the target rather than through it",
			top <= slabBody.Positions.Max( p => p.z ) + 1e-3f, $"post reaches {top:0.###}" );
	}

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

		var block = studio.Add( new PrimitiveFeature() );
		block.SizeX.Value = 6f;
		block.SizeY.Value = 6f;
		block.SizeZ.Value = 4f;
		block.Position.Value = new Vec3( 0f, 0f, 5f ); // spans z 3..7

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

		var bar = studio.Add( new ExtrudeFeature() );
		bar.Termination.Index = 2; // Through all
		bar.Result.Index = 1;

		var report = studio.Rebuild();

		Report.Check( "it builds", !report.HasErrors, report.ToString() );

		if ( report.HasErrors )
			return;

		var body = studio.Bodies.First( b => b.FeatureId == bar.Id ).Mesh;
		var top = body.Positions.Max( p => p.z );

		Report.Check( "it reaches past everything in the way", top > 7f,
			$"reached {top:0.###}, the block ends at 7" );

		// NOT EXACTLY ON THE FAR SURFACE. A prism ending flush with a face leaves two coplanar faces
		// touching, which is the case every downstream operation finds hardest — and the one a
		// boolean would have to resolve. Clearing it outright costs nothing.
		Report.Check( "and clears it rather than stopping flush with it", top > 7.1f,
			$"reached {top:0.###}" );

		// It has to follow the model too, same as up to next.
		block.SizeZ.Value = 10f; // now spans z 0..10
		studio.MarkDirty( block );
		studio.Rebuild();

		Report.Check( "growing the block makes it reach further",
			studio.Bodies.First( b => b.FeatureId == bar.Id ).Mesh.Positions.Max( p => p.z ) > 10f );
	}

	static void TestNothingThere()
	{
		// Up to next with an empty studio: there is nothing to measure against, and inventing a
		// distance would be worse than saying so.
		var empty = new PartStudio();
		var sketch = empty.Add( new SketchFeature() );
		sketch.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 1, 1 ) );

		var lonely = empty.Add( new ExtrudeFeature() );
		lonely.Termination.Index = 1;

		empty.Rebuild();

		Report.Check( "up to next with nothing in the studio is an error",
			lonely.Error is not null, "it built something" );

		Report.Check( "and says what is missing",
			lonely.Error is not null && lonely.Error.Contains( "nothing" ), lonely.Error ?? "" );

		// A body that exists but sits BEHIND the profile is not something to stop at either.
		var behind = new PartStudio();

		var block = behind.Add( new PrimitiveFeature() );
		block.SizeX.Value = block.SizeY.Value = block.SizeZ.Value = 2f;
		block.Position.Value = new Vec3( 0f, 0f, -8f );

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

		var upward = behind.Add( new ExtrudeFeature() );
		upward.Termination.Index = 1;

		behind.Rebuild();

		Report.Check( "a body behind the profile does not count as being in the way",
			upward.Error is not null, "it measured against something behind it" );

		Report.Check( "and the message suggests flipping",
			upward.Error is not null && upward.Error.Contains( "flip" ), upward.Error ?? "" );

		// Flipping is exactly what fixes it, which is the check that the advice is worth taking.
		upward.Flip.Value = true;
		behind.MarkDirty( upward );
		behind.Rebuild();

		Report.Check( "and flipping it does fix it", upward.Error is null, upward.Error );
	}

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

	static float Volume( PolyMesh mesh ) => mesh.SignedVolume();
}