Effigy.Tests/HoleFeatureTests.cs
using System;
using Effigy;
using static Effigy.Tests.Report;
namespace Effigy.Tests;
/// <summary>
/// The hole tool, checked as a SHAPE rather than as a hole.
///
/// The cut itself is the engine's boolean, and it is not here. What is here is the negative this
/// hands it: the right diameter, pointing the right way, starting proud of the surface it enters,
/// and — for a counterbore or a countersink — wider at the mouth than at the bottom. Every one of
/// those can be wrong while the boolean succeeds, and a hole in the wrong place is a hole.
/// </summary>
public static class HoleFeatureTests
{
public static void Run()
{
Section( "hole: the shape of the void" );
TestASimpleHoleIsAShaftOfTheRightSize();
TestItStartsProudOfTheSurfaceItEnters();
TestItPointsWhereItWasAimed();
TestABlindHoleStopsAndAThroughHoleDoesNot();
TestACounterboreIsWiderAtTheMouth();
TestACountersinkFollowsItsAngle();
TestAHeadNarrowerThanTheShaftIsRefused();
TestTheFeatureDrillsThroughTheProvider();
}
static (Vec3 Min, Vec3 Max) Bounds( PolyMesh mesh )
{
var min = new Vec3( float.MaxValue, float.MaxValue, float.MaxValue );
var max = new Vec3( float.MinValue, float.MinValue, float.MinValue );
foreach ( var p in mesh.Positions )
{
min = new Vec3( MathF.Min( min.x, p.x ), MathF.Min( min.y, p.y ), MathF.Min( min.z, p.z ) );
max = new Vec3( MathF.Max( max.x, p.x ), MathF.Max( max.y, p.y ), MathF.Max( max.z, p.z ) );
}
return (min, max);
}
/// <summary>Widest radius about the drilling axis among vertices near a given depth.</summary>
static float RadiusAt( PolyMesh mesh, float z, float tolerance = 1e-3f )
{
var widest = 0f;
foreach ( var p in mesh.Positions )
{
if ( MathF.Abs( p.z - z ) <= tolerance )
widest = MathF.Max( widest, MathF.Sqrt( p.x * p.x + p.y * p.y ) );
}
return widest;
}
static void TestASimpleHoleIsAShaftOfTheRightSize()
{
// Drilled straight down from the origin, so the tool's own coordinates are the answer.
var tool = HoleOperation.Build( HoleStyle.Simple, Vec3.Zero, new Vec3( 0, 0, -1 ),
diameter: 0.5f, depth: 1f, headDiameter: 0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 4f );
var (min, max) = Bounds( tool );
Check( "the shaft is the diameter it was given",
MathF.Abs( (max.x - min.x) - 0.5f ) < 0.01f, $"{max.x - min.x:0.####} across" );
Check( "and it is a closed solid the boolean can use", MeshValidator.Validate( tool ).IsClosed,
MeshValidator.Validate( tool ).ToString() );
}
static void TestItStartsProudOfTheSurfaceItEnters()
{
// A tool whose end cap sits exactly ON the face it enters gives the boolean two coplanar
// faces to resolve, and those are the ones that produce slivers rather than a clean mouth.
var tool = HoleOperation.Build( HoleStyle.Simple, Vec3.Zero, new Vec3( 0, 0, -1 ),
diameter: 0.5f, depth: 1f, headDiameter: 0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 4f );
var (min, max) = Bounds( tool );
Check( "the mouth sits above the surface it drills into", max.z > 1e-4f, $"top at {max.z:0.#####}" );
Check( "and the bottom is at the depth asked for",
MathF.Abs( min.z + 1f ) < 0.05f, $"bottom at {min.z:0.####}, wanted -1" );
}
static void TestItPointsWhereItWasAimed()
{
// Drilling along the face's OUTWARD normal would put the tool entirely outside the body and
// cut nothing — and a boolean that removes nothing succeeds, so it would look like the
// feature quietly not working. The direction is the whole of that bug.
var along = HoleOperation.Build( HoleStyle.Simple, Vec3.Zero, new Vec3( 1, 0, 0 ),
diameter: 0.4f, depth: 2f, headDiameter: 0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 4f );
var (min, max) = Bounds( along );
Check( "aimed along +X, the tool runs along +X", max.x > 1.5f && min.x < 0.1f,
$"x from {min.x:0.###} to {max.x:0.###}" );
Check( "and is only its own diameter across the other axes",
max.z - min.z < 0.5f && max.y - min.y < 0.5f,
$"{max.y - min.y:0.###} by {max.z - min.z:0.###}" );
// Straight up is the case the alignment maths has to special-case: the cross product with +Z
// is zero and gives no axis to turn about.
var up = HoleOperation.Build( HoleStyle.Simple, Vec3.Zero, new Vec3( 0, 0, 1 ),
diameter: 0.4f, depth: 2f, headDiameter: 0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 4f );
var upBounds = Bounds( up );
Check( "and drilling straight back up +Z still produces a solid",
upBounds.Max.z > 1.5f && MeshValidator.Validate( up ).IsClosed,
$"z to {upBounds.Max.z:0.###}" );
}
static void TestABlindHoleStopsAndAThroughHoleDoesNot()
{
var blind = HoleOperation.Build( HoleStyle.Simple, Vec3.Zero, new Vec3( 0, 0, -1 ),
diameter: 0.3f, depth: 0.5f, headDiameter: 0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 6f );
var through = HoleOperation.Build( HoleStyle.Simple, Vec3.Zero, new Vec3( 0, 0, -1 ),
diameter: 0.3f, depth: 0f, headDiameter: 0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 6f );
var blindDepth = -Bounds( blind ).Min.z;
var throughDepth = -Bounds( through ).Min.z;
Check( "a blind hole stops where it was told", MathF.Abs( blindDepth - 0.5f ) < 0.05f,
$"{blindDepth:0.###} deep" );
Check( "a through hole runs past the body entirely", throughDepth > 6f,
$"{throughDepth:0.###} deep against a body {6f} across" );
}
static void TestACounterboreIsWiderAtTheMouth()
{
var tool = HoleOperation.Build( HoleStyle.Counterbore, Vec3.Zero, new Vec3( 0, 0, -1 ),
diameter: 0.4f, depth: 2f, headDiameter: 0.8f, headDepth: 0.5f, sinkAngleDegrees: 90f, through: 6f );
var atHead = RadiusAt( tool, -0.5f, 0.02f );
var atShaft = RadiusAt( tool, -1.5f, 0.6f );
Check( "the head is the width it was given", MathF.Abs( atHead - 0.4f ) < 0.02f,
$"radius {atHead:0.####}, wanted 0.4" );
Check( "and the shaft below it is narrower", atShaft < 0.25f, $"radius {atShaft:0.####}" );
Check( "the head stops at its own depth",
RadiusAt( tool, -0.8f, 0.05f ) < 0.25f, "the head runs deeper than asked" );
}
static void TestACountersinkFollowsItsAngle()
{
// A countersink is specified by an included angle and a head size, never by a depth - the
// depth follows, and working it out is the whole reason this is a feature rather than a
// second circle in a sketch.
var tool = HoleOperation.Build( HoleStyle.Countersink, Vec3.Zero, new Vec3( 0, 0, -1 ),
diameter: 0.4f, depth: 2f, headDiameter: 1.0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 6f );
// 90 degrees included: the cone falls (1.0 - 0.4) / 2 = 0.3 over its own radius difference.
var expected = (1.0f - 0.4f) * 0.5f / MathF.Tan( 45f * MathF.PI / 180f );
var atCone = RadiusAt( tool, -expected, 0.02f );
// MEASURED AT THE SURFACE, which is where a countersink's head diameter is specified and
// where no vertex sits: the tool's rings are above the surface and at the bottom of the cone,
// so the number that matters is the one between them. Reading the widest ring instead would
// have called an over-wide mouth correct, which is exactly the bug this caught - the cone was
// reaching its head diameter a third of a unit ABOVE the part.
var (min, max) = Bounds( tool );
var topRing = RadiusAt( tool, max.z, 0.02f );
var atSurface = topRing + (atCone - topRing) * (max.z / (max.z + expected));
Check( "the mouth is the head diameter where it meets the surface",
MathF.Abs( atSurface - 0.5f ) < 0.03f,
$"radius {atSurface:0.####} at the surface, wanted 0.5 (top ring {topRing:0.####} at z {max.z:0.###})" );
Check( "and the cone reaches the shaft at the depth its angle implies",
MathF.Abs( atCone - 0.2f ) < 0.05f, $"radius {atCone:0.####} at {expected:0.###} deep, wanted 0.2" );
// A shallower included angle makes a deeper cone for the same head, which is the whole
// relationship: get the tangent the wrong way up and every countersink comes out inverted.
var steep = HoleOperation.Build( HoleStyle.Countersink, Vec3.Zero, new Vec3( 0, 0, -1 ),
diameter: 0.4f, depth: 2f, headDiameter: 1.0f, headDepth: 0f, sinkAngleDegrees: 60f, through: 6f );
var deeper = (1.0f - 0.4f) * 0.5f / MathF.Tan( 30f * MathF.PI / 180f );
Check( "a narrower angle sinks deeper for the same head", deeper > expected,
$"{deeper:0.###} against {expected:0.###}" );
Check( "and the tool follows it", RadiusAt( steep, -deeper, 0.03f ) < 0.25f,
"the 60 degree cone did not reach the shaft where its angle says" );
}
static void TestAHeadNarrowerThanTheShaftIsRefused()
{
// Not clamped: a counterbore whose head is narrower than its shaft is a number somebody typed
// wrongly, and quietly widening it would drill a hole they did not ask for.
var threw = false;
try
{
HoleOperation.Build( HoleStyle.Counterbore, Vec3.Zero, new Vec3( 0, 0, -1 ),
diameter: 0.8f, depth: 2f, headDiameter: 0.4f, headDepth: 0.5f, sinkAngleDegrees: 90f, through: 6f );
}
catch ( InvalidOperationException )
{
threw = true;
}
Check( "a head narrower than the shaft is refused", threw );
}
static void TestTheFeatureDrillsThroughTheProvider()
{
// End to end: the feature has to reach the boolean, with a tool that overlaps the body. A
// tool aimed the wrong way still reaches the boolean and still succeeds, so the check is on
// what it was HANDED, not on whether it was called.
var previous = MeshBoolean.Provider;
try
{
var stub = new RecordingBoolean();
MeshBoolean.Provider = stub;
var studio = new PartStudio();
var box = studio.Add( new PrimitiveFeature() );
box.SizeX.Value = 4f;
box.SizeY.Value = 4f;
box.SizeZ.Value = 4f;
studio.Rebuild();
var body = studio.Bodies[0];
var top = -1;
for ( var i = 0; i < body.Mesh.FaceCount; i++ )
{
if ( body.Mesh.FaceNormal( body.Mesh.Faces[i] ).Normal.z > 0.99f )
top = i;
}
var hole = studio.Add( new HoleFeature() );
hole.Diameter.Value = 0.5f;
hole.Faces.Add( FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) ) );
studio.Rebuild();
Check( "the feature built without complaint", hole.Error is null, hole.Error ?? "" );
Check( "and reached the boolean", stub.Calls == 1, $"{stub.Calls} calls" );
Check( "as a subtraction", stub.LastOp == BooleanOp.Subtract, $"{stub.LastOp}" );
// The tool has to go DOWN into the block, not up off it.
var (min, max) = Bounds( stub.LastTool );
Check( "with a tool that goes into the material rather than off it",
min.z < 1.9f, $"tool spans z {min.z:0.###} to {max.z:0.###}, block top is 2" );
Check( "and overlaps the block it is cutting", max.z > 1.9f && min.z < 0f,
$"tool spans z {min.z:0.###} to {max.z:0.###}" );
}
finally
{
MeshBoolean.Provider = previous;
}
}
sealed class RecordingBoolean : IMeshBoolean
{
public int Calls;
public BooleanOp LastOp;
public PolyMesh LastTool;
public bool TryApply( BooleanOp op, PolyMesh target, PolyMesh tool, out PolyMesh result, out string error )
{
Calls++;
LastOp = op;
LastTool = tool;
result = target.Clone();
error = null;
return true;
}
}
}