Effigy.Tests/MergeTests.cs

Unit test file for PartStudio merging and boolean cut behavior. Defines several test cases that build studios, sketches and extrudes, stub an IMeshBoolean provider, and assert merging rules, body identity, remove/cut plumbing and error messages.

Obfuscated CodeExternal Download
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&amp;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;
	}
}