Effigy.Tests/FeatureTests.cs
using System;
using System.Linq;
using Effigy;
using static Effigy.Tests.Report;
namespace Effigy.Tests;
/// <summary>
/// Checks on the feature tree — the parametric spine.
///
/// The properties that matter here are not geometric, they are about history: that editing a
/// feature rebuilds what depends on it and reuses what does not, that rollback shows the model as
/// it was, and that a broken feature does not take the rest of the tree down with it. Those are
/// exactly the behaviours that quietly stop working as features are added, so they are asserted
/// rather than assumed.
/// </summary>
public static class FeatureTests
{
public static void Run()
{
Section( "feature tree rebuilds" );
TestBasicRebuild();
Section( "rollback shows the model as it was" );
TestRollback();
Section( "incremental rebuild reuses upstream work" );
TestIncremental();
Section( "suppressed and failed features" );
TestSuppressAndErrors();
Section( "transforms" );
TestXform();
Section( "mirror reverses winding" );
TestMirrorWinding();
Section( "patterns" );
TestPatterns();
Section( "subdivide as a feature" );
TestSubdivideFeature();
Section( "subdivide can take a few faces instead of the body" );
TestLocalSubdivide();
Section( "subdivide all faces densifies without smoothing" );
TestAllFacesSubdivide();
Section( "parameter dialogs follow the shape" );
TestParameterVisibility();
Section( "parts-list name and hide survive a rebuild" );
TestBodyPresentation();
}
static float Volume( PolyMesh m ) => m.SignedVolume();
static PartStudio StudioWithBox( float size = 2f )
{
var studio = new PartStudio();
var box = studio.Add( new PrimitiveFeature() );
box.Shape.Index = 0;
box.SizeX.Value = box.SizeY.Value = box.SizeZ.Value = size;
return studio;
}
static void TestBasicRebuild()
{
var studio = StudioWithBox();
var report = studio.Rebuild();
Check( "one feature evaluated", report.FeaturesEvaluated == 1, report.ToString() );
Check( "one body produced", studio.Bodies.Count == 1 );
Check( "no errors", !report.HasErrors );
Check( "body is a 6-face box", studio.Bodies[0].Mesh.FaceCount == 6 );
Check( "feature got a default name", studio.Features[0].Name == "Box 1", studio.Features[0].Name );
// Changing a parameter and rebuilding must change the geometry — the whole point.
var box = (PrimitiveFeature)studio.Features[0];
box.SizeX.Value = 4f;
studio.MarkDirty( box );
studio.Rebuild();
var width = studio.Bodies[0].Mesh.Positions.Max( p => p.x ) - studio.Bodies[0].Mesh.Positions.Min( p => p.x );
Check( "editing a parameter changes the model", MathF.Abs( width - 4f ) < 1e-4f, $"width {width}" );
}
static void TestRollback()
{
var studio = StudioWithBox();
studio.Add( new PrimitiveFeature() ).Position.Value = new Vec3( 5, 0, 0 );
studio.Add( new PrimitiveFeature() ).Position.Value = new Vec3( 10, 0, 0 );
studio.Rebuild();
Check( "three features give three bodies", studio.Bodies.Count == 3, $"{studio.Bodies.Count}" );
studio.RollbackIndex = 2;
studio.MarkAllDirty();
studio.Rebuild();
Check( "rollback to 2 gives two bodies", studio.Bodies.Count == 2, $"{studio.Bodies.Count}" );
studio.RollbackIndex = 0;
studio.MarkAllDirty();
studio.Rebuild();
Check( "rollback to 0 gives nothing", studio.Bodies.Count == 0, $"{studio.Bodies.Count}" );
studio.RollbackIndex = int.MaxValue;
studio.MarkAllDirty();
studio.Rebuild();
Check( "rolling forward restores everything", studio.Bodies.Count == 3, $"{studio.Bodies.Count}" );
}
static void TestIncremental()
{
var studio = new PartStudio();
for ( var i = 0; i < 6; i++ )
studio.Add( new PrimitiveFeature() ).Position.Value = new Vec3( i * 3, 0, 0 );
var first = studio.Rebuild();
Check( "first rebuild evaluates everything", first.FeaturesEvaluated == 6, first.ToString() );
Check( "first rebuild reuses nothing", first.FeaturesReused == 0, first.ToString() );
// Touch feature 4 (index 3). Features 0-2 are upstream and must be reused; 3-5 re-run.
studio.MarkDirty( 3 );
var second = studio.Rebuild();
Check( "editing feature 4 reuses the first three", second.FeaturesReused == 3, second.ToString() );
Check( "editing feature 4 re-runs three", second.FeaturesEvaluated == 3, second.ToString() );
Check( "result is still six bodies", studio.Bodies.Count == 6, $"{studio.Bodies.Count}" );
// A rebuild with nothing dirty should do no work at all.
var third = studio.Rebuild();
Check( "clean rebuild evaluates nothing", third.FeaturesEvaluated == 0, third.ToString() );
Check( "clean rebuild still yields the model", studio.Bodies.Count == 6, $"{studio.Bodies.Count}" );
// Body ids must not be reissued when a rebuild starts mid-tree, or selections break.
var ids = studio.Bodies.Select( b => b.Id ).ToList();
Check( "body ids stay unique", ids.Distinct().Count() == ids.Count, string.Join( ",", ids ) );
// Reordering invalidates from the earlier position.
studio.Move( 5, 1 );
var fourth = studio.Rebuild();
Check( "reorder re-runs from the earlier index", fourth.FeaturesEvaluated == 5, fourth.ToString() );
}
static void TestSuppressAndErrors()
{
var studio = StudioWithBox();
var second = studio.Add( new PrimitiveFeature() );
second.Position.Value = new Vec3( 5, 0, 0 );
second.Suppressed = true;
studio.MarkAllDirty();
var report = studio.Rebuild();
Check( "suppressed feature produces no body", studio.Bodies.Count == 1, $"{studio.Bodies.Count}" );
Check( "suppressed feature counted", report.FeaturesSuppressed == 1, report.ToString() );
second.Suppressed = false;
studio.MarkAllDirty();
studio.Rebuild();
Check( "unsuppressing brings it back", studio.Bodies.Count == 2, $"{studio.Bodies.Count}" );
// A broken feature must record an error and let the rest of the tree carry on, so an
// upstream mistake does not cascade into every downstream feature also reporting failure.
var bad = studio.Add( new PrimitiveFeature() );
bad.Shape.Index = 4; // Tube
bad.InnerRadius.Value = 10f; // larger than the outer radius
bad.Radius.Value = 1f;
var third = studio.Add( new PrimitiveFeature() );
third.Position.Value = new Vec3( -5, 0, 0 );
studio.MarkAllDirty();
var withError = studio.Rebuild();
Check( "broken feature reports an error", withError.HasErrors, withError.ToString() );
Check( "error names the parameter", bad.Error is not null && bad.Error.Contains( "Inner radius" ), bad.Error );
Check( "later features still run", studio.Bodies.Count == 3, $"{studio.Bodies.Count}" );
// Fixing it clears the error.
bad.InnerRadius.Value = 0.5f;
studio.MarkAllDirty();
var fixedReport = studio.Rebuild();
Check( "fixing clears the error", !fixedReport.HasErrors, fixedReport.ToString() );
Check( "and the body appears", studio.Bodies.Count == 4, $"{studio.Bodies.Count}" );
}
static void TestXform()
{
var p = new Vec3( 1, 2, 3 );
var moved = Xform.Translate( new Vec3( 10, 0, 0 ) ).TransformPoint( p );
Check( "translate", moved.AlmostEquals( new Vec3( 11, 2, 3 ) ), moved.ToString() );
// 90 degrees about Z takes +X to +Y.
var rotated = Xform.Rotate( new Vec3( 0, 0, 1 ), MathF.PI / 2f ).TransformPoint( new Vec3( 1, 0, 0 ) );
Check( "rotate 90 about Z", rotated.AlmostEquals( new Vec3( 0, 1, 0 ) ), rotated.ToString() );
// Rotation about an offset axis leaves the axis itself fixed.
var about = Xform.RotateAbout( new Vec3( 5, 0, 0 ), new Vec3( 0, 0, 1 ), MathF.PI );
var onAxis = about.TransformPoint( new Vec3( 5, 0, 2 ) );
Check( "points on the axis stay put", onAxis.AlmostEquals( new Vec3( 5, 0, 2 ) ), onAxis.ToString() );
var swung = about.TransformPoint( new Vec3( 6, 0, 0 ) );
Check( "half turn about offset axis", swung.AlmostEquals( new Vec3( 4, 0, 0 ) ), swung.ToString() );
// Determinant signs: rotation preserves handedness, mirror does not.
Check( "rotation keeps handedness", !Xform.Rotate( new Vec3( 0, 0, 1 ), 1.1f ).FlipsWinding );
Check( "mirror flips handedness", Xform.Mirror( Vec3.Zero, new Vec3( 1, 0, 0 ) ).FlipsWinding );
Check( "negative scale flips handedness", Xform.Scale( new Vec3( -1, 1, 1 ) ).FlipsWinding );
Check( "double negative scale does not", !Xform.Scale( new Vec3( -1, -1, 1 ) ).FlipsWinding );
// Mirroring twice in the same plane is the identity.
var m = Xform.Mirror( new Vec3( 2, 0, 0 ), new Vec3( 1, 0, 0 ) );
var there = m.TransformPoint( new Vec3( 5, 1, 1 ) );
var back = m.TransformPoint( there );
Check( "mirror is its own inverse", back.AlmostEquals( new Vec3( 5, 1, 1 ) ), back.ToString() );
Check( "mirror reflects across the plane", there.AlmostEquals( new Vec3( -1, 1, 1 ) ), there.ToString() );
}
static void TestMirrorWinding()
{
// The bug this guards against: a mirrored solid whose faces all point inward. It renders
// black or lit from inside and looks fine in wireframe, so it survives visual review.
var studio = StudioWithBox();
var mirror = studio.Add( new MirrorFeature() );
mirror.PlanePoint.Value = new Vec3( 5, 0, 0 );
mirror.PlaneNormal.Value = new Vec3( 1, 0, 0 );
studio.Rebuild();
Check( "mirror adds a body", studio.Bodies.Count == 2, $"{studio.Bodies.Count}" );
var originalVolume = Volume( studio.Bodies[0].Mesh );
var mirroredVolume = Volume( studio.Bodies[1].Mesh );
Check( "original volume positive", originalVolume > 0, $"{originalVolume:0.###}" );
Check( "MIRRORED volume also positive", mirroredVolume > 0, $"{mirroredVolume:0.###}" );
Check( "mirrored volume matches original",
MathF.Abs( mirroredVolume - originalVolume ) < 1e-3f,
$"{originalVolume:0.###} vs {mirroredVolume:0.###}" );
// And it landed in the right place: reflected about x=5, a box centred at 0 goes to 10.
var centre = studio.Bodies[1].Mesh.Positions.Aggregate( Vec3.Zero, ( a, b ) => a + b )
/ studio.Bodies[1].Mesh.VertexCount;
Check( "mirrored body is across the plane", MathF.Abs( centre.x - 10f ) < 1e-4f, centre.ToString() );
// Still a valid closed solid afterwards.
var v = MeshValidator.Validate( studio.Bodies[1].Mesh );
Check( "mirrored body still closed and valid", v.IsValid && v.IsClosed, v.ToString() );
mirror.KeepOriginal.Value = false;
studio.MarkAllDirty();
studio.Rebuild();
Check( "keep-original off drops the source", studio.Bodies.Count == 1, $"{studio.Bodies.Count}" );
}
static void TestPatterns()
{
var studio = StudioWithBox( 1f );
var linear = studio.Add( new LinearPatternFeature() );
linear.Count.Value = 5;
linear.Spacing.Value = 2f;
linear.Direction.Value = new Vec3( 1, 0, 0 );
studio.Rebuild();
Check( "linear pattern of 5 gives 5 bodies", studio.Bodies.Count == 5, $"{studio.Bodies.Count}" );
var xs = studio.Bodies
.Select( b => b.Mesh.Positions.Aggregate( Vec3.Zero, ( a, p ) => a + p ).x / b.Mesh.VertexCount )
.OrderBy( x => x ).ToList();
Check( "instances are evenly spaced",
MathF.Abs( xs[1] - xs[0] - 2f ) < 1e-4f && MathF.Abs( xs[4] - xs[0] - 8f ) < 1e-4f,
string.Join( ", ", xs.Select( x => x.ToString( "0.##" ) ) ) );
linear.Merge.Value = true;
studio.MarkAllDirty();
studio.Rebuild();
Check( "merge collapses to one body", studio.Bodies.Count == 1, $"{studio.Bodies.Count}" );
Check( "merged body has all the faces", studio.Bodies[0].Mesh.FaceCount == 30,
$"{studio.Bodies[0].Mesh.FaceCount}" );
// Circular, full turn: 4 instances 90 degrees apart, and none coincident with the first.
var circ = new PartStudio();
var b = circ.Add( new PrimitiveFeature() );
b.SizeX.Value = b.SizeY.Value = b.SizeZ.Value = 1f;
b.Position.Value = new Vec3( 5, 0, 0 );
var pattern = circ.Add( new CircularPatternFeature() );
pattern.Count.Value = 4;
pattern.TotalAngle.Value = 360f;
circ.Rebuild();
Check( "circular pattern of 4 gives 4 bodies", circ.Bodies.Count == 4, $"{circ.Bodies.Count}" );
var centres = circ.Bodies
.Select( x => x.Mesh.Positions.Aggregate( Vec3.Zero, ( a, p ) => a + p ) / x.Mesh.VertexCount )
.ToList();
Check( "all instances sit on the same radius",
centres.All( c => MathF.Abs( new Vec3( c.x, c.y, 0 ).Length - 5f ) < 1e-3f ),
string.Join( " ", centres.Select( c => c.ToString() ) ) );
Check( "a full turn does not duplicate the original",
centres.All( c => centres.Count( o => o.AlmostEquals( c, 1e-3f ) ) == 1 ) );
// A partial sweep spans the arc inclusive, so the last instance lands exactly on the angle.
pattern.TotalAngle.Value = 90f;
circ.MarkAllDirty();
circ.Rebuild();
var last = circ.Bodies.Last().Mesh.Positions.Aggregate( Vec3.Zero, ( a, p ) => a + p )
/ circ.Bodies.Last().Mesh.VertexCount;
Check( "90 degree sweep ends on the axis", MathF.Abs( last.y - 5f ) < 1e-3f, last.ToString() );
// Circular merge shares the linear pattern's compounding trap — each copy must come from
// the original mesh, not from the mesh as it stands after the previous append. Unguarded
// this doubles instead of incrementing: 6, 12, 24, 48 faces rather than 6, 12, 18, 24.
pattern.TotalAngle.Value = 360f;
pattern.Merge.Value = true;
circ.MarkAllDirty();
circ.Rebuild();
Check( "circular merge collapses to one body", circ.Bodies.Count == 1, $"{circ.Bodies.Count}" );
Check( "circular merge has 4 boxes' worth of faces", circ.Bodies[0].Mesh.FaceCount == 24,
$"{circ.Bodies[0].Mesh.FaceCount}" );
}
static void TestSubdivideFeature()
{
var studio = StudioWithBox();
var subdiv = studio.Add( new SubdivideFeature() );
subdiv.Levels.Value = 2;
studio.Rebuild();
Check( "subdivide feature ran", studio.Bodies[0].Mesh.FaceCount == 96,
$"{studio.Bodies[0].Mesh.FaceCount}" );
var predicted = subdiv.PredictCost( new[] { new Body( "x", "x", Primitives.Box( 2, 2, 2 ) ) } );
Check( "cost prediction matches", predicted.Faces == 96, $"{predicted.Faces}" );
// The cage has to stay reachable: roll back above the subdivide and the low-poly is there.
// This is what the whole CAD-then-sculpt pipeline stands on.
studio.RollbackIndex = 1;
studio.MarkAllDirty();
studio.Rebuild();
Check( "rolling back exposes the cage again", studio.Bodies[0].Mesh.FaceCount == 6,
$"{studio.Bodies[0].Mesh.FaceCount}" );
// Editing the cage and rolling forward must give a different dense mesh, not a stale one.
var box = (PrimitiveFeature)studio.Features[0];
box.SizeX.Value = 10f;
studio.RollbackIndex = int.MaxValue;
studio.MarkAllDirty();
studio.Rebuild();
var width = studio.Bodies[0].Mesh.Positions.Max( p => p.x ) - studio.Bodies[0].Mesh.Positions.Min( p => p.x );
Check( "editing the cage changes the subdivided result", width > 6f, $"width {width:0.##}" );
Check( "and it is still dense", studio.Bodies[0].Mesh.FaceCount == 96,
$"{studio.Bodies[0].Mesh.FaceCount}" );
}
/// <summary>
/// Subdividing picked faces rather than the whole cage.
///
/// The two properties worth asserting are the ones a naive implementation gets wrong: the shape
/// must not move (linear, not Catmull-Clark, or the untouched half of the box would be dragged
/// in with the selection), and the mesh must stay watertight — a neighbouring face left alone
/// would have a T-junction on the edge the selection just split.
/// </summary>
static void TestLocalSubdivide()
{
var studio = StudioWithBox();
studio.Features[0].Id = "box";
var subdiv = studio.Add( new SubdivideFeature() );
subdiv.Faces.Add( new FaceRef( "boxb0", new Vec3( 0, 0, 1 ), new Vec3( 0, 0, 1 ) ) );
studio.Rebuild();
var mesh = studio.Bodies[0].Mesh;
Check( "picked face became four", mesh.FaceCount == 9, $"{mesh.FaceCount}" );
Check( "only the picked face's points were added", mesh.VertexCount == 13,
$"{mesh.VertexCount}" );
var predicted = CatmullClark.PredictLocalCost( Primitives.Box( 2, 2, 2 ), new[] { 4 }, 1 );
Check( "local cost prediction is exact",
predicted.Faces == mesh.FaceCount && predicted.Vertices == mesh.VertexCount,
$"predicted {predicted.Vertices}v {predicted.Faces}f" );
// The box is 2 units across and must still be. Catmull-Clark on a subset would have pulled
// the corners in, which is the whole reason this path is linear.
var width = mesh.Positions.Max( p => p.x ) - mesh.Positions.Min( p => p.x );
var height = mesh.Positions.Max( p => p.z ) - mesh.Positions.Min( p => p.z );
Check( "the shape did not move", Math.Abs( width - 2f ) < 1e-4f && Math.Abs( height - 2f ) < 1e-4f,
$"{width:0.###} x {height:0.###}" );
Check( "no T-junction against the neighbours", Watertight( mesh ) );
// The unselected faces around it grew a corner each rather than being subdivided.
Check( "neighbours were stitched, not subdivided",
mesh.Faces.Count( f => f.Count == 5 ) == 4, string.Join( ",", mesh.Faces.Select( f => f.Count ) ) );
// A second level goes over the same region, not outward into the stitched neighbours.
subdiv.Levels.Value = 2;
studio.MarkAllDirty();
studio.Rebuild();
var deeper = studio.Bodies[0].Mesh;
var predictedDeeper = CatmullClark.PredictLocalCost( Primitives.Box( 2, 2, 2 ), new[] { 4 }, 2 );
Check( "two levels stay watertight", Watertight( deeper ) );
Check( "two levels cost what was predicted",
predictedDeeper.Faces == deeper.FaceCount && predictedDeeper.Vertices == deeper.VertexCount,
$"predicted {predictedDeeper.Vertices}v {predictedDeeper.Faces}f, got {deeper.VertexCount}v {deeper.FaceCount}f" );
// A pick that no longer resolves is a warning, not a failure: there is still geometry.
subdiv.Faces.Add( new FaceRef( "body-that-is-gone", new Vec3( 0, 0, 5 ), new Vec3( 0, 0, 1 ) ) );
studio.MarkAllDirty();
studio.Rebuild();
Check( "a lost pick warns rather than fails",
subdiv.Error is null && subdiv.Warning is not null, subdiv.Error ?? subdiv.Warning ?? "silent" );
// And with nothing picked it is the whole-body Catmull-Clark it always was.
subdiv.Faces.Clear();
subdiv.Levels.Value = 1;
studio.MarkAllDirty();
studio.Rebuild();
Check( "clearing the picks goes back to the whole body",
studio.Bodies[0].Mesh.FaceCount == 24, $"{studio.Bodies[0].Mesh.FaceCount}" );
}
/// <summary>
/// "All faces" is the middle of the three subdivide forms: every face gets denser, but LINEARLY
/// — the shape does not move, unlike the whole-body Catmull-Clark which pulls the corners in.
/// The assertion that matters is the one that would catch it collapsing back into the whole-body
/// path: a box subdivided "all faces" is still exactly 2 units across.
/// </summary>
static void TestAllFacesSubdivide()
{
var studio = StudioWithBox();
var subdiv = studio.Add( new SubdivideFeature() );
subdiv.Levels.Value = 1;
subdiv.AllFaces.Value = true;
studio.Rebuild();
var mesh = studio.Bodies[0].Mesh;
Check( "every face became four", mesh.FaceCount == 24, $"{mesh.FaceCount}" );
Check( "all quads, no n-gons", mesh.Faces.All( f => f.Count == 4 ),
string.Join( ",", mesh.Faces.Select( f => f.Count ) ) );
var width = mesh.Positions.Max( p => p.x ) - mesh.Positions.Min( p => p.x );
var height = mesh.Positions.Max( p => p.z ) - mesh.Positions.Min( p => p.z );
Check( "the shape did not move (linear, not Catmull-Clark)",
Math.Abs( width - 2f ) < 1e-4f && Math.Abs( height - 2f ) < 1e-4f,
$"{width:0.###} x {height:0.###}" );
Check( "still watertight", Watertight( mesh ) );
var predicted = subdiv.PredictCost( new[] { new Body( "x", "x", Primitives.Box( 2, 2, 2 ) ) } );
Check( "cost prediction matches",
predicted.Faces == mesh.FaceCount && predicted.Vertices == mesh.VertexCount,
$"predicted {predicted.Vertices}v {predicted.Faces}f, got {mesh.VertexCount}v {mesh.FaceCount}f" );
// A picked face is more specific than "all faces" and wins.
studio.Features[0].Id = "box";
subdiv.Faces.Add( new FaceRef( "boxb0", new Vec3( 0, 0, 1 ), new Vec3( 0, 0, 1 ) ) );
studio.MarkAllDirty();
studio.Rebuild();
Check( "a pick narrows the all-faces form back to the picked face",
studio.Bodies[0].Mesh.FaceCount == 9, $"{studio.Bodies[0].Mesh.FaceCount}" );
}
/// <summary>Every edge used by exactly two faces. The cheap test for a crack.</summary>
static bool Watertight( PolyMesh mesh ) =>
mesh.BuildEdgeFaces().Values.All( faces => faces.Count == 2 );
static void TestParameterVisibility()
{
// Onshape's dialogs show only the fields the chosen shape has. Copying that behaviour is
// most of why a tool with many features still feels simple.
var f = new PrimitiveFeature();
f.Shape.Index = 0; // Box
var boxParams = f.Parameters.Select( p => p.Label ).ToList();
Check( "box dialog shows three lengths",
boxParams.Contains( "Width" ) && boxParams.Contains( "Depth" ) && boxParams.Contains( "Height" ) );
Check( "box dialog hides radius", !boxParams.Contains( "Radius" ), string.Join( ", ", boxParams ) );
f.Shape.Index = 1; // Cylinder
var cylParams = f.Parameters.Select( p => p.Label ).ToList();
Check( "cylinder dialog shows radius and segments",
cylParams.Contains( "Radius" ) && cylParams.Contains( "Segments" ) );
Check( "cylinder dialog hides width", !cylParams.Contains( "Width" ), string.Join( ", ", cylParams ) );
f.Shape.Index = 4; // Tube
Check( "tube dialog adds inner radius",
f.Parameters.Any( p => p.Label == "Inner radius" ) );
Check( "type name follows the shape", f.TypeName == "Tube", f.TypeName );
}
static void TestBodyPresentation()
{
var studio = StudioWithBox();
studio.Features[0].Id = "box";
studio.Features[0].Name = "Box 1";
studio.Rebuild();
Check( "a body starts with the feature's name",
studio.Bodies[0].Name == "Box 1", studio.Bodies[0].Name );
Check( "and is visible", studio.Bodies[0].Visible );
var id = studio.Bodies[0].Id;
studio.BodyNames[id] = "Housing";
studio.HiddenBodyIds.Add( id );
// No MarkDirty: the cache still has the default name and visible flag. The override has
// to be reapplied on a reuse or a Parts-list rename would last until the next edit.
studio.Rebuild();
Check( "a renamed part keeps the name across a cached rebuild",
studio.Bodies[0].Name == "Housing", studio.Bodies[0].Name );
Check( "a hidden part stays hidden across a cached rebuild",
!studio.Bodies[0].Visible );
studio.MarkAllDirty();
studio.Rebuild();
Check( "and both survive a full rebuild",
studio.Bodies[0].Name == "Housing" && !studio.Bodies[0].Visible,
$"{studio.Bodies[0].Name} visible={studio.Bodies[0].Visible}" );
studio.BodyNames.Remove( id );
studio.HiddenBodyIds.Remove( id );
studio.Rebuild();
Check( "clearing the override restores the feature name",
studio.Bodies[0].Name == "Box 1", studio.Bodies[0].Name );
Check( "and showing it again draws it", studio.Bodies[0].Visible );
}
}