Effigy.Tests/MergeTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy.Tests;
/// <summary>
/// Building a part up out of several extrudes, and getting one part out of it.
///
/// The behaviour this replaces: every extrude made its own body, so three bosses on a block listed
/// as four separate parts. That is not what "I built this out of four extrudes" means to anyone,
/// and it made the parts list useless on exactly the models it was there for.
///
/// The rule is the sketch's attachment, not proximity: a sketch drawn ON A FACE of a body adds to
/// that body, a sketch on a global plane starts a new one. It needs no parameter set either way,
/// and it cannot pick the wrong body, because the answer was decided when the sketch was placed.
///
/// What merging does NOT do is cut the interface — see SketchConsumingFeature.Emit. The tests below
/// therefore assert on total enclosed volume and body count, and deliberately not on manifoldness,
/// because the merged mesh is not manifold along the join and pretending otherwise here would
/// enshrine an expectation the code does not meet.
/// </summary>
public static class MergeTests
{
public static void Run()
{
Report.Section( "merge: extruding off a face builds up one part" );
TestBossesMergeIn();
Report.Section( "merge: what starts a new part instead" );
TestNewBodyCases();
Report.Section( "merge: the explicit settings" );
TestExplicitResult();
Report.Section( "merge: identity survives it" );
TestIdentity();
Report.Section( "remove: the cut goes through the boolean seam" );
TestRemove();
Report.Section( "remove: the cut tool has to be INSIDE the thing it cuts" );
TestCutToolOverlapsTarget();
}
/// <summary>
/// The assertion every other Remove test here was missing, and the reason a cut that is wired
/// correctly end to end still does nothing in the editor.
///
/// Everything above stubs the boolean and checks the PLUMBING: that a cut is routed to a
/// provider, that the result is adopted, that the body keeps its id. The stub returns whatever
/// it is told to, so no test ever looked at the tool solid it was handed — and the tool solid
/// is the whole problem.
///
/// A sketch on a face takes that FACE'S OUTWARD NORMAL as its plane normal (FacePlane.Capture
/// reads mesh.FaceNormal directly). An extrude with Flip off travels along that normal. So a
/// sketch drawn on the top of a block extrudes UP AND AWAY from it — correct for a boss, and
/// exactly backwards for a cut, which needs to travel down INTO the material.
///
/// The two solids then meet on a plane and enclose no common volume, and subtracting something
/// that isn't there takes nothing away. The engine reports that as a refusal whose text is "they
/// may not overlap", which reads as an adapter fault and is nothing of the kind.
/// </summary>
static void TestCutToolOverlapsTarget()
{
var previous = MeshBoolean.Provider;
try
{
var stub = new StubBoolean();
MeshBoolean.Provider = stub;
// Exactly the editor's case: block, sketch on its top face, Remove, default direction.
var studio = CutSetup( out var cut );
studio.Rebuild();
Report.Check( "the cut reached the boolean at all", stub.Calls > 0,
"the provider was never called" );
var target = Bounds( stub.LastTarget );
var tool = Bounds( stub.LastTool );
// The block is 2 tall about the origin, so its top face is at z = +1. A tool that cuts
// it has to reach BELOW that; one that starts there and climbs is outside the material.
// Before DirectionSign existed this came out as target -1..1 against tool 1..2.
var overlap = MathF.Min( target.Max, tool.Max ) - MathF.Max( target.Min, tool.Min );
Report.Check( "a cut off a face travels into the material, not away from it", overlap > 1e-4f,
$"target z {target.Min:0.###}..{target.Max:0.###}, tool z {tool.Min:0.###}..{tool.Max:0.###} "
+ $"- overlap {overlap:0.###}" );
Report.Check( "and it stops where the distance says", MathF.Abs( tool.Min - 0f ) < 1e-4f,
$"tool reaches down to {tool.Min:0.###}, expected 0 for a distance of 1 off a face at z=1" );
// Flip still means what it always meant: the other way from the sensible default. For a
// cut that is back OUT of the material, which is now the broken direction rather than
// the default one - and the pre-flight check is what has to catch it.
cut.Flip.Value = true;
studio.MarkDirty( 0 );
var flippedReport = studio.Rebuild();
Report.Check( "flipping a face cut back out of the material is refused", flippedReport.HasErrors,
"it built something" );
Report.Check( "and the refusal says which way it missed and by how much",
cut.Error is not null && cut.Error.Contains( "does not reach into the part" )
&& cut.Error.Contains( "Flip" ),
cut.Error ?? "no error" );
}
finally
{
MeshBoolean.Provider = previous;
}
}
/// <summary>Z extent of a mesh. The cut in CutSetup travels along Z, so one axis answers it.</summary>
static (float Min, float Max) Bounds( PolyMesh mesh )
{
if ( mesh is null || mesh.VertexCount == 0 )
return (0f, 0f);
var min = float.MaxValue;
var max = float.MinValue;
foreach ( var p in mesh.Positions )
{
min = MathF.Min( min, p.z );
max = MathF.Max( max, p.z );
}
return (min, max);
}
/// <summary>
/// Removing material.
///
/// There is no boolean in the kernel and there is not going to be one — the plan of record is
/// the engine's, installed at startup inside the s&box editor (see MeshBoolean). So what can be
/// verified here is everything EXCEPT the cut arithmetic: that Remove asks for a subtract, that
/// it hands over the right two solids the right way round, that the answer replaces the target
/// without disturbing its identity, and that every way this can go wrong produces something a
/// user can act on.
///
/// A stub provider is what makes that testable. It is not a boolean and does not pretend to be
/// one — it records what it was asked and returns a mesh it was told to return.
/// </summary>
static void TestRemove()
{
var previous = MeshBoolean.Provider;
try
{
TestRemoveWithoutProvider();
TestRemoveThroughProvider();
TestRemoveFailures();
}
finally
{
// Restored whatever happens: a provider left installed would leak into every test that
// runs after this one, and the ones that assert Remove is unavailable would start
// passing for the wrong reason.
MeshBoolean.Provider = previous;
}
}
static void TestRemoveWithoutProvider()
{
MeshBoolean.Provider = null;
Report.Check( "with nothing installed, the kernel reports no boolean", !MeshBoolean.Available );
var studio = CutSetup( out var cut );
var report = studio.Rebuild();
Report.Check( "asking for a cut without a provider is an error, not a silent no-op",
report.HasErrors, "it built something" );
Report.Check( "and the error names what is missing",
cut.Error is not null && cut.Error.Contains( "mesh boolean" ), cut.Error ?? "no error" );
// A host that knows more gets to say more. The editor knows the engine has a boolean and
// what is needed to reach it; the kernel only knows there is none here.
MeshBoolean.UnavailableReason = "Run effigy_probe_boolean to dump the engine's API.";
var hosted = CutSetup( out var hostedCut );
hosted.Rebuild();
Report.Check( "and a host can replace that with something actionable",
hostedCut.Error is not null && hostedCut.Error.Contains( "effigy_probe_boolean" ),
hostedCut.Error ?? "no error" );
MeshBoolean.UnavailableReason = null;
// The part it could not cut is still there. A failed feature must not take the model with
// it — the geometry above the failure is exactly what you need to see to fix it.
Report.Check( "the part being cut survives the failure", studio.Bodies.Count == 1,
$"{studio.Bodies.Count} bodies" );
}
static void TestRemoveThroughProvider()
{
var stub = new StubBoolean();
MeshBoolean.Provider = stub;
var studio = CutSetup( out _ );
var idBefore = studio.Bodies.Single().Id;
// What the stub returns stands in for the cut result. A box of a known size means the
// assertion below is about the result being ADOPTED, not about what a boolean would compute.
stub.Result = Primitives.Box( 1, 1, 1 );
var report = studio.Rebuild();
Report.Check( "the cut builds", !report.HasErrors, report.ToString() );
Report.Check( "it asked for a subtract", stub.LastOp == BooleanOp.Subtract, $"{stub.LastOp}" );
Report.Check( "exactly once", stub.Calls == 1, $"{stub.Calls} calls" );
// THE OPERANDS, THE RIGHT WAY ROUND. Subtract is not commutative, and swapping them is the
// kind of mistake that produces a plausible-looking solid rather than an error: the target
// is the part being cut, the tool is the shape of the hole.
Report.Check( "the part is the target and the extrusion is the tool",
MathF.Abs( EnclosedVolume( stub.LastTarget ) - 48f ) < 1e-2f
&& MathF.Abs( EnclosedVolume( stub.LastTool ) - 2f ) < 1e-2f,
$"target {EnclosedVolume( stub.LastTarget ):0.##} (block is 48), tool {EnclosedVolume( stub.LastTool ):0.##} (cut is 2)" );
Report.Check( "the result replaces the part rather than adding another",
studio.Bodies.Count == 1, $"{studio.Bodies.Count} bodies" );
Report.Check( "and the part keeps its id, so anything built on it still resolves",
studio.Bodies.Single().Id == idBefore, $"{idBefore} became {studio.Bodies.Single().Id}" );
Report.Check( "the body now holds what the boolean returned",
MathF.Abs( EnclosedVolume( studio.Bodies.Single().Mesh ) - 1f ) < 1e-2f,
$"volume {EnclosedVolume( studio.Bodies.Single().Mesh ):0.####}" );
}
static void TestRemoveFailures()
{
var stub = new StubBoolean();
MeshBoolean.Provider = stub;
// A provider that cannot do it says so, and the reason has to reach the user rather than
// being swallowed into a generic failure.
stub.Fail = "the solids do not overlap";
var studio = CutSetup( out var cut );
studio.Rebuild();
Report.Check( "a refused boolean surfaces the provider's own reason",
cut.Error is not null && cut.Error.Contains( "do not overlap" ), cut.Error ?? "no error" );
// A provider that throws is a failed boolean, not a failed rebuild.
stub.Fail = null;
stub.Throw = true;
var throwing = CutSetup( out var thrown );
var throwingReport = throwing.Rebuild();
Report.Check( "a provider that throws is caught and reported",
throwingReport.HasErrors && thrown.Error is not null && thrown.Error.Contains( "failed" ),
thrown.Error ?? "no error" );
Report.Check( "and the rebuild carries on rather than dying", throwing.Bodies.Count == 1 );
// An empty result is a real answer to "cut this with something that swallows it", and it is
// never a useful one — a body with no faces is indistinguishable from a broken feature
// everywhere downstream, so it is refused where it happens.
stub.Throw = false;
stub.Result = new PolyMesh();
var emptied = CutSetup( out var vanished );
emptied.Rebuild();
Report.Check( "a cut that leaves nothing behind explains itself",
vanished.Error is not null && vanished.Error.Contains( "nothing" ), vanished.Error ?? "no error" );
// Nothing to cut into at all.
stub.Result = Primitives.Box( 1, 1, 1 );
var lonely = new PartStudio();
lonely.Add( new SketchFeature() ).Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 1, 1 ) );
var nothing = lonely.Add( new ExtrudeFeature() );
nothing.Distance.Value = 1f;
nothing.Result.Index = 3;
lonely.Rebuild();
Report.Check( "removing with no body to remove from says what to do instead",
nothing.Error is not null && nothing.Error.Contains( "remove from" ), nothing.Error ?? "no error" );
// Auto must never cut. Adding and removing are indistinguishable from the geometry, so a
// rule that guessed would eventually guess a hole into someone's part.
stub.Calls = 0;
var auto = CutSetup( out var automatic );
automatic.Result.Index = 0;
auto.Rebuild();
Report.Check( "Auto never reaches for the boolean", stub.Calls == 0, $"{stub.Calls} calls" );
}
/// <summary>A block with a sketch on its top face set to cut into it: the shape of every Remove
/// test, so each one differs only in what the provider does.</summary>
static PartStudio CutSetup( out ExtrudeFeature cut )
{
var studio = new PartStudio();
var block = studio.Add( new PrimitiveFeature() );
block.SizeX.Value = 6f;
block.SizeY.Value = 4f;
block.SizeZ.Value = 2f;
studio.Rebuild();
var sketch = studio.Add( new SketchFeature() );
sketch.Face = TopFaceOf( studio, studio.Bodies[0].Id );
sketch.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 1f, 0f ) );
cut = studio.Add( new ExtrudeFeature() );
cut.Distance.Value = 1f;
cut.Result.Index = 3; // Remove
return studio;
}
/// <summary>
/// Stands in for the engine's boolean. Records what it was asked and returns what it was told
/// to return — it performs no geometry at all, on purpose, because a half-real boolean in a test
/// would be a second implementation to be wrong in its own way.
/// </summary>
sealed class StubBoolean : IMeshBoolean
{
public int Calls;
public BooleanOp LastOp;
public PolyMesh LastTarget, LastTool;
/// <summary>What to hand back. Null means "refuse", via Fail.</summary>
public PolyMesh Result = Primitives.Box( 1, 1, 1 );
/// <summary>Set to refuse with this reason.</summary>
public string Fail;
/// <summary>Set to throw instead, which is what a misbehaving engine call looks like.</summary>
public bool Throw;
public bool TryApply( BooleanOp op, PolyMesh target, PolyMesh tool, out PolyMesh result, out string error )
{
Calls++;
LastOp = op;
LastTarget = target;
LastTool = tool;
if ( Throw )
throw new InvalidOperationException( "the engine said no" );
if ( Fail is not null )
{
result = null;
error = Fail;
return false;
}
result = Result;
error = null;
return true;
}
}
static void TestBossesMergeIn()
{
// A 6x4x2 block, then three separate 1x1x1 bosses off its top face. One part, and its
// volume is the block plus all three: 48 + 3.
var studio = new PartStudio();
var block = studio.Add( new PrimitiveFeature() );
block.SizeX.Value = 6f;
block.SizeY.Value = 4f;
block.SizeZ.Value = 2f;
studio.Rebuild();
var blockId = studio.Bodies.Single().Id;
// Captured ONCE, before any boss exists, and reused — which is also what actually happens
// when someone draws three sketches on the same face. Re-capturing inside the loop finds the
// highest +Z face instead, which after the first boss is that boss's own top, and the three
// quietly stack into a tower. The reference resolving to the right face while a boss stands
// on it is half of what is being tested.
var blockTop = TopFaceOf( studio, blockId );
for ( var i = 0; i < 3; i++ )
{
var sketch = studio.Add( new SketchFeature() );
sketch.Face = blockTop;
var x = -2f + i * 2f;
sketch.Sketch.AddRectangle( new Vec2( x, -0.5f ), new Vec2( x + 1f, 0.5f ) );
studio.Add( new ExtrudeFeature() ).Distance.Value = 1f;
studio.Rebuild();
}
var report = studio.Rebuild();
Report.Check( "it builds", !report.HasErrors, report.ToString() );
Report.Check( "three bosses off the same block leave ONE part, not four",
studio.Bodies.Count == 1, $"{studio.Bodies.Count} bodies" );
var volume = EnclosedVolume( studio.Bodies.Single().Mesh );
Report.Check( "and that part measures the block plus all three bosses",
MathF.Abs( volume - 51f ) < 1e-2f, $"enclosed volume {volume:0.####}, expected 48 + 3" );
// Volume alone would pass with a boss merged in at the wrong height, so check the part now
// reaches exactly one unit above the block's top face.
var top = studio.Bodies.Single().Mesh.Positions.Max( p => p.z );
Report.Check( "the part stands a unit proud of the block's top face",
MathF.Abs( top - 2f ) < 1e-3f, $"top at {top}, block top is 1" );
// A boss on a boss: the second sketch attaches to a face of the merged body, which by then
// is the same body id it always was. This is the case that breaks if merging invalidates
// the face references built on it.
var stacked = studio.Add( new SketchFeature() );
stacked.Face = TopFaceOf( studio, blockId );
stacked.Sketch.AddRectangle( new Vec2( -0.25f, -0.25f ), new Vec2( 0.25f, 0.25f ) );
studio.Add( new ExtrudeFeature() ).Distance.Value = 1f;
var stackedReport = studio.Rebuild();
Report.Check( "building on top of what was already merged still works",
!stackedReport.HasErrors, stackedReport.ToString() );
Report.Check( "and is still one part", studio.Bodies.Count == 1, $"{studio.Bodies.Count} bodies" );
}
static void TestNewBodyCases()
{
// A sketch on a global plane is not attached to anything, so it starts its own part even
// with a body already in the studio. "Until a new sketch is extruded off the mass" — this is
// the other half of that.
var studio = new PartStudio();
var block = studio.Add( new PrimitiveFeature() );
block.SizeX.Value = block.SizeY.Value = block.SizeZ.Value = 2f;
var loose = studio.Add( new SketchFeature() );
loose.PlaneOffset.Value = 10f;
loose.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 1, 1 ) );
studio.Add( new ExtrudeFeature() ).Distance.Value = 1f;
var report = studio.Rebuild();
Report.Check( "a sketch on a global plane starts its own part",
!report.HasErrors && studio.Bodies.Count == 2, $"{studio.Bodies.Count} bodies" );
// And moving a sketch back off a face must stop it merging. The attachment is republished
// on every rebuild, so a stale one would keep merging into a body it no longer touches.
var studio2 = new PartStudio();
var host = studio2.Add( new PrimitiveFeature() );
host.SizeX.Value = host.SizeY.Value = host.SizeZ.Value = 2f;
studio2.Rebuild();
var attached = studio2.Add( new SketchFeature() );
attached.Face = TopFaceOf( studio2, studio2.Bodies[0].Id );
attached.Sketch.AddRectangle( new Vec2( -0.5f, -0.5f ), new Vec2( 0.5f, 0.5f ) );
studio2.Add( new ExtrudeFeature() ).Distance.Value = 1f;
studio2.Rebuild();
Report.Check( "attached, it merges", studio2.Bodies.Count == 1, $"{studio2.Bodies.Count} bodies" );
attached.Face = null;
attached.PlaneOffset.Value = 5f;
studio2.MarkDirty( attached );
studio2.Rebuild();
Report.Check( "moved back onto a plane, it stops merging",
studio2.Bodies.Count == 2, $"{studio2.Bodies.Count} bodies" );
}
static void TestExplicitResult()
{
var studio = new PartStudio();
var block = studio.Add( new PrimitiveFeature() );
block.SizeX.Value = block.SizeY.Value = block.SizeZ.Value = 2f;
studio.Rebuild();
var sketch = studio.Add( new SketchFeature() );
sketch.Face = TopFaceOf( studio, studio.Bodies[0].Id );
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;
boss.Result.Index = 1; // New body
studio.Rebuild();
Report.Check( "New body overrides the attachment and keeps them apart",
studio.Bodies.Count == 2, $"{studio.Bodies.Count} bodies" );
boss.Result.Index = 0; // back to Auto
studio.MarkDirty( boss );
studio.Rebuild();
Report.Check( "and Auto merges it again", studio.Bodies.Count == 1,
$"{studio.Bodies.Count} bodies" );
// Explicit Add with a sketch on a global plane: one body in the studio is unambiguous, so
// it is used. This is the "sketch over the top of the only part" case.
var single = new PartStudio();
var only = single.Add( new PrimitiveFeature() );
only.SizeX.Value = only.SizeY.Value = only.SizeZ.Value = 2f;
var overSketch = single.Add( new SketchFeature() );
overSketch.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 1, 1 ) );
var adding = single.Add( new ExtrudeFeature() );
adding.Distance.Value = 3f;
adding.Result.Index = 2; // Add
var singleReport = single.Rebuild();
Report.Check( "Add with one body in the studio uses that body",
!singleReport.HasErrors && single.Bodies.Count == 1, singleReport.ToString() );
// Two bodies and no attachment: there is no way to tell which was meant, and guessing is
// how a boss silently lands on the wrong part. It has to say so.
var ambiguous = new PartStudio();
var a = ambiguous.Add( new PrimitiveFeature() );
a.SizeX.Value = a.SizeY.Value = a.SizeZ.Value = 2f;
var b = ambiguous.Add( new PrimitiveFeature() );
b.SizeX.Value = b.SizeY.Value = b.SizeZ.Value = 1f;
b.Position.Value = new Vec3( 8f, 0f, 0f );
ambiguous.Add( new SketchFeature() ).Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 1, 1 ) );
var guessing = ambiguous.Add( new ExtrudeFeature() );
guessing.Distance.Value = 1f;
guessing.Result.Index = 2; // Add
var ambiguousReport = ambiguous.Rebuild();
Report.Check( "Add with two bodies and no attachment refuses rather than guessing",
ambiguousReport.HasErrors, "it picked one" );
Report.Check( "and the error says what to do about it",
guessing.Error is not null && guessing.Error.Contains( "which" ),
guessing.Error ?? "no error" );
}
static void TestIdentity()
{
// Merging must not change the host body's id. Everything built on that body — every face
// reference, every body selection — is holding the id, and a merge that renamed it would
// break all of them at once, which is the exact failure feature-derived ids were introduced
// to stop.
var studio = new PartStudio();
var block = studio.Add( new PrimitiveFeature() );
block.SizeX.Value = 4f;
block.SizeY.Value = 4f;
block.SizeZ.Value = 2f;
studio.Rebuild();
var idBefore = studio.Bodies.Single().Id;
var featureBefore = studio.Bodies.Single().FeatureId;
var sketch = studio.Add( new SketchFeature() );
sketch.Face = TopFaceOf( studio, idBefore );
sketch.Sketch.AddRectangle( new Vec2( -0.5f, -0.5f ), new Vec2( 0.5f, 0.5f ) );
studio.Add( new ExtrudeFeature() ).Distance.Value = 1f;
studio.Rebuild();
var merged = studio.Bodies.Single();
Report.Check( "the merged part keeps the host's id", merged.Id == idBefore,
$"{idBefore} became {merged.Id}" );
Report.Check( "and still names the feature that first made it",
merged.FeatureId == featureBefore, $"{featureBefore} became {merged.FeatureId}" );
// A body selection made before the merge still matches afterwards, which is the practical
// consequence of the id holding.
var selection = new BodySelectionParam( "Bodies" );
selection.BodyIds.Add( idBefore );
Report.Check( "so a selection made before the merge still matches the part",
selection.Matches( merged ) );
// Rebuilding twice must not merge twice. Bodies are rebuilt from scratch each time, but a
// merge that appended into a cached mesh rather than a fresh one would double the volume on
// every rebuild — and would look completely normal in the viewport.
var first = EnclosedVolume( studio.Bodies.Single().Mesh );
studio.MarkDirty( 0 );
studio.Rebuild();
studio.MarkDirty( 0 );
studio.Rebuild();
var third = EnclosedVolume( studio.Bodies.Single().Mesh );
Report.Check( "rebuilding repeatedly does not merge the same boss again and again",
MathF.Abs( third - first ) < 1e-3f, $"{first:0.####} became {third:0.####}" );
// Same check on the incremental path: edit the LAST feature so everything above it is
// restored from the snapshot cache rather than re-run.
var boss = (ExtrudeFeature)studio.Features.Last();
boss.Distance.Value = 2f;
studio.MarkDirty( boss );
studio.Rebuild();
var taller = EnclosedVolume( studio.Bodies.Single().Mesh );
Report.Check( "and an incremental rebuild resumes with the attachment intact",
studio.Bodies.Count == 1 && MathF.Abs( taller - 34f ) < 1e-2f,
$"{studio.Bodies.Count} bodies, volume {taller:0.####}, expected 32 + 2" );
}
// --- helpers ------------------------------------------------------------------------------
static FaceRef TopFaceOf( PartStudio studio, string bodyId )
{
var body = studio.Bodies.First( b => b.Id == bodyId );
var mesh = body.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 FacePlane.Capture( body, mesh.Faces.IndexOf( top.Face ), top.Centroid );
}
static float EnclosedVolume( PolyMesh mesh )
{
var acc = 0f;
foreach ( var f in mesh.Faces )
acc += Vec3.Dot( mesh.FaceCentroid( f ), mesh.FaceNormal( f ) ) * mesh.FaceArea( f );
return acc / 3f;
}
}