Effigy.Tests/BooleanFeatureTests.cs
using System;
using System.IO;
using System.Linq;
using Effigy;
using static Effigy.Tests.Report;
namespace Effigy.Tests;
/// <summary>
/// The Boolean feature: which bodies it acts on, which it consumes, and every way it refuses.
///
/// WHAT THIS DOES NOT TEST, DELIBERATELY: whether the CSG is correct. The kernel does not carry a
/// boolean — MeshBoolean is an interface with the engine's implementation installed behind it, for
/// the reasons written at the top of MeshBoolean.cs — so there is nothing here to be right or
/// wrong about the geometry. What IS here, and what had no coverage at all, is the bookkeeping
/// around the call: a union that folds three bodies into one and leaves the right id standing, a
/// subtract that consumes its tool, a target list that defaults to "everything that is not a
/// tool", and seven refusals that each have to say which control is wrong.
///
/// THE STUB PROVIDER IS THE POINT, not a limitation. Testing the plumbing needs a boolean that
/// returns SOMETHING; it does not need one that is correct. Append-for-union and pass-through-for-
/// subtract are the cheapest meshes that let the body bookkeeping be observed, and using the real
/// engine here would test the engine rather than this feature.
/// </summary>
public static class BooleanFeatureTests
{
public static void Run()
{
Section( "boolean: it refuses before it needs a provider" );
TestNoProviderSaysWhereToGetOne();
TestOneBodyIsRefused();
TestNoToolIsRefused();
TestBodyCannotBeItsOwnTool();
Section( "boolean: which bodies it acts on" );
TestUnionFoldsIntoTheFirstBody();
TestSubtractConsumesItsTool();
TestKeepToolsLeavesItStanding();
TestTargetDefaultsToEverythingButTheTool();
Section( "boolean: it survives the file" );
TestRoundTrip();
}
// --- fixtures ---------------------------------------------------------------------------
/// <summary>A boolean that does no CSG. Union appends, so the result is observably both
/// meshes; subtract and intersect hand the target back unchanged. Enough to watch the bodies
/// move around, and honest about being nothing more than that.</summary>
sealed class StubBoolean : IMeshBoolean
{
public bool TryApply( BooleanOp op, PolyMesh target, PolyMesh tool, out PolyMesh result, out string error )
{
error = null;
result = target.Clone();
if ( op == BooleanOp.Union )
MeshTransform.Append( result, tool );
return true;
}
}
/// <summary>Runs <paramref name="body"/> with the stub installed, and always puts the provider
/// back — a leaked provider would make every later test in the run see a boolean that the
/// kernel does not really have.</summary>
static void WithProvider( Action body )
{
var previous = MeshBoolean.Provider;
MeshBoolean.Provider = new StubBoolean();
try { body(); }
finally { MeshBoolean.Provider = previous; }
}
static PrimitiveFeature Box( PartStudio studio, string name, float x )
{
var box = studio.Add( new PrimitiveFeature() );
box.Name = name;
box.Shape.Index = 0;
box.SizeX.Value = 1f;
box.SizeY.Value = 1f;
box.SizeZ.Value = 1f;
box.Position.Value = new Vec3( x, 0, 0 );
return box;
}
// --- the refusals -------------------------------------------------------------------------
static void TestNoProviderSaysWhereToGetOne()
{
var previous = MeshBoolean.Provider;
MeshBoolean.Provider = null;
try
{
var studio = new PartStudio();
var a = Box( studio, "a", 0f );
var b = Box( studio, "b", 0.5f );
var op = studio.Add( new BooleanFeature() );
op.Operation.Index = 1; // Subtract
op.Tools.BodyIds.Add( b.Id + "b0" );
studio.Rebuild();
Check( "with no boolean installed it says so rather than doing nothing",
op.Error is not null, op.Error ?? "silent" );
Check( "and points at the editor, where there is one",
op.Error is not null && op.Error.Contains( "boolean", StringComparison.OrdinalIgnoreCase ),
op.Error );
}
finally { MeshBoolean.Provider = previous; }
}
static void TestOneBodyIsRefused() => WithProvider( () =>
{
var studio = new PartStudio();
var a = Box( studio, "a", 0f );
var op = studio.Add( new BooleanFeature() );
op.Tools.BodyIds.Add( a.Id + "b0" );
studio.Rebuild();
Check( "a boolean with one body in the studio is refused", op.Error is not null, op.Error ?? "silent" );
} );
static void TestNoToolIsRefused() => WithProvider( () =>
{
var studio = new PartStudio();
Box( studio, "a", 0f );
Box( studio, "b", 0.5f );
var op = studio.Add( new BooleanFeature() );
op.Operation.Index = 1;
studio.Rebuild();
// The empty selection must NOT be read as "all bodies" here, which is what it means
// everywhere else. See the comment in BooleanFeature.Execute.
Check( "an unpicked tool is refused rather than read as 'every body'",
op.Error is not null, op.Error ?? "silent" );
Check( "and the refusal names the Tool control", op.Error is not null && studio.Bodies.Count == 2,
$"{studio.Bodies.Count} bodies survived" );
} );
static void TestBodyCannotBeItsOwnTool() => WithProvider( () =>
{
var studio = new PartStudio();
var a = Box( studio, "a", 0f );
Box( studio, "b", 0.5f );
var op = studio.Add( new BooleanFeature() );
op.Operation.Index = 1;
op.Targets.BodyIds.Add( a.Id + "b0" );
op.Tools.BodyIds.Add( a.Id + "b0" );
studio.Rebuild();
Check( "a body picked as both target and tool is refused", op.Error is not null, op.Error ?? "silent" );
} );
// --- the bookkeeping ------------------------------------------------------------------------
static void TestUnionFoldsIntoTheFirstBody() => WithProvider( () =>
{
var studio = new PartStudio();
var a = Box( studio, "a", 0f );
var b = Box( studio, "b", 0.5f );
var c = Box( studio, "c", 1f );
var op = studio.Add( new BooleanFeature() );
op.Operation.Index = 0; // Union
op.Targets.BodyIds.Add( a.Id + "b0" );
op.Tools.BodyIds.Add( b.Id + "b0" );
op.Tools.BodyIds.Add( c.Id + "b0" );
studio.Rebuild();
Check( "a union of three bodies leaves one", studio.Bodies.Count == 1, $"{studio.Bodies.Count} bodies" );
Check( "and it is the first of them, so downstream references still resolve",
studio.Bodies.Count == 1 && studio.Bodies[0].Id == a.Id + "b0",
studio.Bodies.FirstOrDefault()?.Id ?? "none" );
// Append rather than real CSG, so the vertex count is exactly the three boxes added up —
// which is the observable proof that all three actually went in.
Check( "carrying the geometry of all three", studio.Bodies.Count == 1 && studio.Bodies[0].Mesh.VertexCount == 24,
$"{studio.Bodies.FirstOrDefault()?.Mesh.VertexCount ?? 0} verts" );
} );
static void TestSubtractConsumesItsTool() => WithProvider( () =>
{
var studio = new PartStudio();
var a = Box( studio, "a", 0f );
var b = Box( studio, "b", 0.5f );
var op = studio.Add( new BooleanFeature() );
op.Operation.Index = 1; // Subtract
op.Targets.BodyIds.Add( a.Id + "b0" );
op.Tools.BodyIds.Add( b.Id + "b0" );
studio.Rebuild();
Check( "a subtract leaves the target", op.Error is null && studio.Bodies.Count == 1,
op.Error ?? $"{studio.Bodies.Count} bodies" );
Check( "and consumes the tool", studio.Bodies.Count == 1 && studio.Bodies[0].Id == a.Id + "b0",
studio.Bodies.FirstOrDefault()?.Id ?? "none" );
} );
static void TestKeepToolsLeavesItStanding() => WithProvider( () =>
{
var studio = new PartStudio();
var a = Box( studio, "a", 0f );
var b = Box( studio, "b", 0.5f );
var op = studio.Add( new BooleanFeature() );
op.Operation.Index = 1;
op.Targets.BodyIds.Add( a.Id + "b0" );
op.Tools.BodyIds.Add( b.Id + "b0" );
op.KeepTools.Value = true;
studio.Rebuild();
Check( "Keep tool bodies leaves the tool in the studio", studio.Bodies.Count == 2,
$"{studio.Bodies.Count} bodies" );
} );
static void TestTargetDefaultsToEverythingButTheTool() => WithProvider( () =>
{
var studio = new PartStudio();
var a = Box( studio, "a", 0f );
var b = Box( studio, "b", 0.5f );
var tool = Box( studio, "cutter", 1f );
var op = studio.Add( new BooleanFeature() );
op.Operation.Index = 1;
op.Tools.BodyIds.Add( tool.Id + "b0" );
studio.Rebuild();
// The point: an unpicked target does not mean "every body", which would ask the tool to
// cut itself. It means every body that is not the tool.
Check( "an unpicked target cuts every body except the tool",
op.Error is null && studio.Bodies.Count == 2, op.Error ?? $"{studio.Bodies.Count} bodies" );
Check( "and both targets are still there",
studio.Bodies.Any( x => x.Id == a.Id + "b0" ) && studio.Bodies.Any( x => x.Id == b.Id + "b0" ),
string.Join( ", ", studio.Bodies.Select( x => x.Name ) ) );
} );
// --- the file ---------------------------------------------------------------------------
static void TestRoundTrip()
{
var studio = new PartStudio();
var a = Box( studio, "a", 0f );
var b = Box( studio, "b", 0.5f );
var op = studio.Add( new BooleanFeature() );
op.Operation.Index = 2; // Intersect
op.Targets.BodyIds.Add( a.Id + "b0" );
op.Tools.BodyIds.Add( b.Id + "b0" );
op.KeepTools.Value = true;
var path = Path.Combine( Path.GetTempPath(), "effigy_boolean_roundtrip.effigy" );
StudioDocument.WriteFile( studio, path );
var read = StudioDocument.ReadFile( path );
var back = read.Features.OfType<BooleanFeature>().FirstOrDefault();
File.Delete( path );
Check( "a Boolean survives a save and reopen", back is not null );
Check( "with its operation", back?.Operation.Index == 2, $"index {back?.Operation.Index}" );
Check( "its target", back?.Targets.BodyIds.Count == 1, $"{back?.Targets.BodyIds.Count ?? 0} ids" );
Check( "its tool", back?.Tools.BodyIds.Count == 1, $"{back?.Tools.BodyIds.Count ?? 0} ids" );
Check( "and its keep-tools flag", back?.KeepTools.Value == true );
}
}