Effigy.Tests/ShellTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// Verification for shell.
///
/// THE CENTRAL TEST IS PLANE-TO-PLANE DISTANCE, and the reason is worth stating: it would be easy
/// and wrong to assert that each vertex moved by the thickness. At a box corner the correct answer
/// moves the vertex by thickness*sqrt(3) — asserting otherwise would enshrine the exact bug this
/// implementation exists to avoid. Wall thickness is a property of planes, so the tests measure
/// planes.
/// </summary>
public static class ShellTests
{
public static void Run()
{
Section( "shell: wall thickness is exact" );
TestThickness();
Section( "shell: topology and volume" );
TestTopology();
Section( "shell: openings" );
TestOpenings();
Section( "shell: in the feature tree" );
TestFeature();
Section( "shell: regressions from review" );
TestReviewRegressions();
Section( "shell: thicker than the shape itself" );
TestTooThick();
}
/// <summary>
/// Shelling a shape by more than it is thick.
///
/// This used to produce a closed mesh with its inner surface passed through itself: negative
/// volume in places, entirely normal from outside, and nothing said a word. It is refused now,
/// by two checks that catch different failures — see RefuseIfFolded for why one is not enough.
/// </summary>
static void TestTooThick()
{
// A 4x4x1 plate, shelled by 0.6. The top and bottom walls pass straight through each other.
//
// NOTE WHAT DOES NOT HAPPEN HERE: neither face inverts. Each is translated inward by 0.6 and
// a translation preserves a normal, so a per-face check sees nothing wrong. The surface as a
// whole is what turns inside out, and only the enclosed volume sees that.
var plate = Primitives.Box( 4, 4, 1 );
var message = Refused( () => ShellOperation.Shell( plate, 0.6f ) );
Check( "shelling a plate by more than half its thickness is refused", message is not null,
"it produced a mesh" );
Check( "and the message names the thickness that failed",
message is not null && message.Contains( "0.6" ), message ?? "" );
// Exactly half is the boundary: the two walls meet with no cavity between them. Zero
// volume is as useless as negative and is refused the same way.
Check( "so is shelling it by exactly half", Refused( () => ShellOperation.Shell( plate, 0.5f ) ) is not null,
"it produced a mesh" );
// Just under has to still work, or the guard is too eager and the parameter is unusable near
// the values anyone would actually pick.
var thin = ShellOperation.Shell( plate, 0.45f );
Check( "while just under it still shells", thin is not null && thin.FaceCount > 0 );
Check( "into a valid closed mesh",
MeshValidator.Validate( thin ) is { IsValid: true, IsClosed: true } );
// A CYLINDER SHELLED PAST ITS RADIUS is the other failure, and the one the per-face check is
// there for: the side faces invert around the axis rather than the surface flipping bodily.
var cylinder = Primitives.Cylinder( 0.5f, 4f, 16 );
Check( "shelling a cylinder by more than its radius is refused",
Refused( () => ShellOperation.Shell( cylinder, 0.8f ) ) is not null, "it produced a mesh" );
Check( "and a sane wall on the same cylinder still works",
ShellOperation.Shell( cylinder, 0.1f ) is { FaceCount: > 0 } );
// An opened shell skips the volume check, since its inner surface is genuinely not closed.
// It must still shell normally rather than being caught by the remaining per-face test.
var room = ShellOperation.Shell( Primitives.Box( 4, 4, 4 ), 0.3f, new[] { 0 } );
Check( "opening a face still shells normally", room is not null && room.FaceCount > 0 );
}
static string Refused( Func<PolyMesh> action )
{
try
{
action();
return null;
}
catch ( InvalidOperationException e )
{
return e.Message;
}
}
/// <summary>
/// Every one of these is a bug that shipped and was caught by review rather than by the suite.
/// They are kept together deliberately: the tests above check that shell is right, and these
/// check the specific ways it was wrong.
/// </summary>
static void TestReviewRegressions()
{
// 1. Openings that pinch to a point produced a non-manifold mesh - an edge shared by four
// faces - because four rim quads all contained the same outer-to-inner edge.
var subdivided = CatmullClark.Subdivide( Primitives.Box( 2, 2, 2 ), 1 );
var threw = false;
try { ShellOperation.Shell( subdivided, 0.05f, new[] { 0, 2 } ); }
catch ( InvalidOperationException ) { threw = true; }
Check( "an opening pinched at a vertex is refused, not returned broken", threw );
// ...but openings that share an edge are perfectly legal and must still work.
var adjacent = ShellOperation.Shell( subdivided, 0.05f, new[] { 0, 1 } );
Check( "two openings sharing an edge still shell cleanly",
MeshValidator.Validate( adjacent ).IsValid && MeshValidator.Validate( adjacent ).IsClosed,
MeshValidator.Validate( adjacent ).ToString() );
// 2. Opened faces were still constraining the vertex solve, so the wall was pulled back by
// the thickness and the rim came out as a 45-degree chamfer instead of a flat band.
var box = Primitives.Box( 2, 2, 2 );
var opened = ShellOperation.Shell( box, 0.1f, new[] { 0 } );
var openedNormal = box.FaceNormal( box.Faces[0] );
var worstDrift = 0f;
foreach ( var vi in box.Faces[0].Indices )
{
// A vertex on the rim must stay exactly in the opened face's plane, not retreat from it.
var outer = box.Positions[vi];
var inner = opened.Positions[vi + box.VertexCount];
worstDrift = MathF.Max( worstDrift, MathF.Abs( Vec3.Dot( openedNormal, outer - inner ) ) );
}
Check( "the rim stays flush with the opened face", worstDrift < 1e-5f, $"drifted {worstDrift:0.######}" );
Check( "so an opened box encloses exactly 1.844", Near( Volume( opened ), 1.844f, 1e-3f ),
$"{Volume( opened ):0.####}" );
// 3. The rank-deficient fallback divided by the LARGEST cosine where it needed the smallest,
// making walls too thin wherever one flat face was split into several coplanar polygons -
// which subdivision does to every face.
var split = ShellOperation.Shell( subdivided, 0.1f, null, out var approximated );
Check( "coplanar-split faces still give exact thickness",
WorstPlaneError( subdivided, split, 0.1f ) < 1e-4f,
$"worst {WorstPlaneError( subdivided, split, 0.1f ):0.#######}" );
Check( "and need no approximation at all", approximated == 0, $"{approximated} approximated" );
// 3b. On a CURVED surface there is no point exactly `thickness` from every neighbouring face
// plane, because the neighbours disagree. The normal equations return a least-squares
// compromise regardless and used to report it as exact, making the approximated count
// meaningless. It is now measured against the residual.
var curved = CatmullClark.Subdivide( Primitives.Box( 2, 2, 2 ), 2 );
ShellOperation.Shell( curved, 0.1f, null, out var curvedApprox );
Check( "a curved surface honestly reports vertices it cannot solve exactly", curvedApprox > 0,
$"{curvedApprox} approximated" );
Check( "but the answer stays the least-squares one, so the error stays small",
WorstPlaneError( curved, ShellOperation.Shell( curved, 0.1f ), 0.1f ) < 1e-3f,
$"worst {WorstPlaneError( curved, ShellOperation.Shell( curved, 0.1f ), 0.1f ):0.#######}" );
// 4. Shelling silently threw away a rig, because the result rebuilt the vertex list without
// rebuilding the weights alongside it.
var rigged = Primitives.Box( 2, 2, 2 );
var skeleton = new Skeleton();
skeleton.AddBoneFromPoints( "root", -1, new Vec3( 0, 0, -1 ), new Vec3( 0, 0, 1 ) );
rigged.Skin = SkinBinder.BindRigid( rigged, skeleton );
var riggedShell = ShellOperation.Shell( rigged, 0.1f );
Check( "a rigged body survives shelling", riggedShell.IsRigged );
Check( "with valid weights", riggedShell.Skin.Validate( riggedShell.VertexCount, skeleton.Count ).Count == 0 );
Check( "and the inner surface inherits the outer's weights",
Enumerable.Range( 0, rigged.VertexCount ).All( i =>
riggedShell.Skin[i].Length == riggedShell.Skin[i + rigged.VertexCount].Length
&& riggedShell.Skin[i][0].Bone == riggedShell.Skin[i + rigged.VertexCount][0].Bone ) );
// 5. The feature mutated bodies in place, so a throw partway left earlier bodies shelled -
// breaking Feature.Run's promise that a failed feature changes nothing.
var studio = new PartStudio();
var cylinder = studio.Add( new PrimitiveFeature() );
cylinder.Shape.Index = 1;
cylinder.Segments.Value = 16;
var boxFeature = studio.Add( new PrimitiveFeature() );
var shell = studio.Add( new ShellFeature() );
shell.Thickness.Value = 0.05f;
shell.OpenFaces.Add( 7 ); // valid on the 18-face cylinder, out of range on the 6-face box
studio.Rebuild();
Check( "a shell that fails partway records an error", shell.Error is not null, shell.Error );
Check( "and leaves every body untouched",
studio.Bodies.All( b => b.Mesh.FaceCount is 18 or 6 ),
string.Join( ",", studio.Bodies.Select( b => b.Mesh.FaceCount ) ) );
// 6. Flipped rim quads had their indices reversed but not their UVs, so they mapped mirrored
// relative to the unflipped quads beside them.
var rimFaces = opened.Faces.Skip( 10 ).ToList(); // 5 outer + 5 inner, then the rim
var outerV = new HashSet<float>();
foreach ( var face in rimFaces )
{
for ( var i = 0; i < face.Count; i++ )
{
if ( face.Indices[i] < box.VertexCount )
outerV.Add( MathF.Round( face.UVs[i].y, 3 ) );
}
}
Check( "every rim quad maps its outer edge to the same UV row", outerV.Count == 1,
string.Join( ",", outerV ) );
}
// --- the important one --------------------------------------------------------------
/// <summary>
/// For every face, every one of its vertices must sit exactly `thickness` from the face's own
/// plane after offsetting. That is the definition of wall thickness, and it holds regardless of
/// how far any individual vertex travelled.
/// </summary>
static float WorstPlaneError( PolyMesh original, PolyMesh shelled, float thickness )
{
var worst = 0f;
var offset = original.VertexCount;
for ( var fi = 0; fi < original.FaceCount; fi++ )
{
var face = original.Faces[fi];
var n = original.FaceNormal( face );
foreach ( var vi in face.Indices )
{
var outer = original.Positions[vi];
var inner = shelled.Positions[vi + offset];
// Distance from the inner point to the outer face's plane, along that plane's normal.
var distance = Vec3.Dot( n, outer - inner );
worst = MathF.Max( worst, MathF.Abs( distance - thickness ) );
}
}
return worst;
}
static void TestThickness()
{
const float t = 0.1f;
var cases = new (string Name, PolyMesh Mesh)[]
{
("box", Primitives.Box( 2, 2, 2 )),
("cylinder", Primitives.Cylinder( 1f, 2f, 16 )),
("wedge", Primitives.Wedge( 2, 2, 2 )),
("extrusion", ExtrudedRectangle( 4f, 2f, 2f ))
};
foreach ( var (name, mesh) in cases )
{
var shelled = ShellOperation.Shell( mesh, t, null, out var approximated );
var worst = WorstPlaneError( mesh, shelled, t );
Check( $"{name}: every wall is exactly {t} thick", worst < 1e-4f, $"worst error {worst:0.#######}" );
Check( $"{name}: no vertex needed the fallback", approximated == 0, $"{approximated} approximated" );
}
// The specific case the naive implementation gets wrong. A box corner's normal is
// (1,1,1)/sqrt(3), so the correct offset moves it t*sqrt(3) - NOT t.
var box = Primitives.Box( 2, 2, 2 );
var boxShell = ShellOperation.Shell( box, t );
var corner = box.Positions[0];
var innerCorner = boxShell.Positions[box.VertexCount];
var travelled = (corner - innerCorner).Length;
Check( "a box corner moves thickness*sqrt(3), not thickness",
MathF.Abs( travelled - t * MathF.Sqrt( 3f ) ) < 1e-5f,
$"moved {travelled:0.#####}, naive would be {t}" );
Check( "and lands on exactly +-0.9 in every axis",
MathF.Abs( MathF.Abs( innerCorner.x ) - 0.9f ) < 1e-5f
&& MathF.Abs( MathF.Abs( innerCorner.y ) - 0.9f ) < 1e-5f
&& MathF.Abs( MathF.Abs( innerCorner.z ) - 0.9f ) < 1e-5f,
innerCorner.ToString() );
// A faceted cylinder is a prism, and its SIDE FACES sit at the apothem r*cos(pi/n), not at
// r. Offsetting those planes inward by t and reading the resulting vertex radius gives a
// closed form:
//
// r_inner = ( r*cos(theta) - t ) / cos(theta) = r - t/cos(theta)
//
// which is strictly LESS than r - t, because a prism's vertices always travel further than
// its faces. Asserting r - t here would be asserting a misunderstanding of the geometry, so
// the exact expression is checked instead.
const int segments = 16;
var cyl = Primitives.Cylinder( 1f, 2f, segments );
var cylShell = ShellOperation.Shell( cyl, t );
var first = cylShell.Positions[cyl.VertexCount];
var innerRadius = MathF.Sqrt( first.x * first.x + first.y * first.y );
var cosTheta = MathF.Cos( MathF.PI / segments );
var predicted = 1f - t / cosTheta;
Check( "a prism's inner radius matches r - t/cos(pi/n) exactly",
MathF.Abs( innerRadius - predicted ) < 1e-5f,
$"{innerRadius:0.######} vs predicted {predicted:0.######}" );
Check( "which is strictly tighter than a naive r - t", innerRadius < 1f - t,
$"{innerRadius:0.######} vs {1f - t:0.######}" );
}
// --- topology and volume ------------------------------------------------------------
static void TestTopology()
{
var box = Primitives.Box( 2, 2, 2 );
var shelled = ShellOperation.Shell( box, 0.1f );
var validation = MeshValidator.Validate( shelled );
Check( "sealed shell is valid", validation.IsValid, validation.ToString() );
Check( "sealed shell is closed", validation.IsClosed, validation.ToString() );
// Two separate closed surfaces, each with Euler characteristic 2.
Check( "two shells give Euler characteristic 4", MeshValidator.EulerCharacteristic( shelled ) == 4,
$"{MeshValidator.EulerCharacteristic( shelled )}" );
Check( "face count doubles", shelled.FaceCount == box.FaceCount * 2 );
// 2^3 outer minus 1.8^3 inner. The inner surface is wound inside-out, so it subtracts.
var expected = 8f - 1.8f * 1.8f * 1.8f;
Check( "enclosed volume is exactly the wall material", Near( Volume( shelled ), expected, 1e-4f ),
$"{Volume( shelled ):0.######} vs {expected:0.######}" );
// Shelling twice as thick must remove strictly more material.
var thicker = ShellOperation.Shell( box, 0.2f );
var thickerExpected = 8f - 1.6f * 1.6f * 1.6f;
Check( "thicker walls enclose more material", Volume( thicker ) > Volume( shelled ) );
Check( "and the amount is exact", Near( Volume( thicker ), thickerExpected, 1e-4f ),
$"{Volume( thicker ):0.######} vs {thickerExpected:0.######}" );
var subdivided = CatmullClark.Subdivide( shelled, 1 );
Check( "a shell subdivides without corrupting topology",
MeshValidator.EulerCharacteristic( subdivided ) == 4 );
Check( "and stays positive volume", Volume( subdivided ) > 0f, $"{Volume( subdivided ):0.####}" );
var threw = false;
try { ShellOperation.Shell( box, 0f ); } catch ( InvalidOperationException ) { threw = true; }
Check( "zero thickness is refused", threw );
threw = false;
try { ShellOperation.Shell( Primitives.Plane( 1, 1, 2, 2 ), 0.1f ); } catch ( InvalidOperationException ) { threw = true; }
Check( "an open mesh cannot be shelled", threw );
threw = false;
try { ShellOperation.Shell( box, 0.1f, Enumerable.Range( 0, box.FaceCount ) ); } catch ( InvalidOperationException ) { threw = true; }
Check( "opening every face is refused", threw );
}
// --- openings -----------------------------------------------------------------------
static void TestOpenings()
{
var box = Primitives.Box( 2, 2, 2 );
var open = ShellOperation.Shell( box, 0.1f, new[] { 0 } );
var validation = MeshValidator.Validate( open );
Check( "an opened shell is still valid", validation.IsValid, validation.ToString() );
Check( "an opened shell is still closed — the rim seals it", validation.IsClosed, validation.ToString() );
// Outer sheet + inner sheet + a rim joining their borders is topologically a sphere.
Check( "an open box is genus 0", MeshValidator.EulerCharacteristic( open ) == 2,
$"{MeshValidator.EulerCharacteristic( open )}" );
// 5 outer + 5 inner + 4 rim quads.
Check( "faces are 5 + 5 + a 4-quad rim", open.FaceCount == 14, $"{open.FaceCount}" );
Check( "the rim faces outward, so volume stays positive", Volume( open ) > 0f,
$"{Volume( open ):0.####}" );
Check( "and encloses less than the sealed version",
Volume( open ) < Volume( ShellOperation.Shell( box, 0.1f ) ),
$"{Volume( open ):0.####}" );
// Two openings, at opposite ends: a tube. Still one closed surface, and now genus 1.
var tube = ShellOperation.Shell( box, 0.1f, new[] { 0, 1 } );
Check( "a shell opened at both ends is closed", MeshValidator.Validate( tube ).IsClosed,
MeshValidator.Validate( tube ).ToString() );
Check( "and is genus 1, being a tube", MeshValidator.EulerCharacteristic( tube ) == 0,
$"{MeshValidator.EulerCharacteristic( tube )}" );
Check( "with positive volume", Volume( tube ) > 0f, $"{Volume( tube ):0.####}" );
}
// --- the tree -----------------------------------------------------------------------
static void TestFeature()
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( -2, -2 ), new Vec2( 2, 2 ) );
var extrude = studio.Add( new ExtrudeFeature() );
extrude.Distance.Value = 3f;
var shell = studio.Add( new ShellFeature() );
shell.Thickness.Value = 0.25f;
studio.Rebuild();
Check( "shell feature runs", shell.Error is null, shell.Error );
Check( "the room is hollow", Volume( studio.Bodies[0].Mesh ) < 4f * 4f * 3f );
Check( "and encloses positive volume", Volume( studio.Bodies[0].Mesh ) > 0f );
// Exact: 4x4x3 outer, 3.5x3.5x2.5 inner.
var expected = 4f * 4f * 3f - 3.5f * 3.5f * 2.5f;
Check( "with exactly the right wall material", Near( Volume( studio.Bodies[0].Mesh ), expected, 1e-3f ),
$"{Volume( studio.Bodies[0].Mesh ):0.####} vs {expected:0.####}" );
// The whole point of the tree: change the room's size, the walls follow.
sketch.Sketch.Curves.Clear();
sketch.Sketch.Points.Clear();
sketch.Sketch.AddRectangle( new Vec2( -3, -2 ), new Vec2( 3, 2 ) );
studio.MarkDirty( sketch );
studio.Rebuild();
var grown = 6f * 4f * 3f - 5.5f * 3.5f * 2.5f;
Check( "resizing the room keeps the walls exact", Near( Volume( studio.Bodies[0].Mesh ), grown, 1e-3f ),
$"{Volume( studio.Bodies[0].Mesh ):0.####} vs {grown:0.####}" );
// And changing only the thickness must not disturb the outside.
shell.Thickness.Value = 0.5f;
studio.MarkDirty( shell );
studio.Rebuild();
var thicker = 6f * 4f * 3f - 5f * 3f * 2f;
Check( "and changing thickness alone is exact too", Near( Volume( studio.Bodies[0].Mesh ), thicker, 1e-3f ),
$"{Volume( studio.Bodies[0].Mesh ):0.####} vs {thicker:0.####}" );
}
// --- helpers ------------------------------------------------------------------------
/// <summary>
/// Build a solid by extruding a rectangle, through the public feature path.
///
/// The sketcher owns extrude now, so these tests go through the tree rather than calling a
/// mesh builder directly. That is the better test anyway: it exercises the path a user takes.
/// </summary>
static PolyMesh ExtrudedRectangle( float width, float depth, float height )
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( -width / 2f, -depth / 2f ), new Vec2( width / 2f, depth / 2f ) );
studio.Add( new ExtrudeFeature() ).Distance.Value = height;
studio.Rebuild();
return studio.Bodies[0].Mesh;
}
static float Volume( PolyMesh mesh ) => mesh.SignedVolume();
static bool Near( float a, float b, float tolerance = 1e-4f ) => MathF.Abs( a - b ) < tolerance;
static void Section( string title ) => Report.Section( title );
static void Check( string what, bool ok, string detail = null ) => Report.Check( what, ok, detail );
}