Effigy.Tests/CollisionTests.cs
using System;
using System.Collections.Generic;
using Effigy;
using static Effigy.Tests.Report;
namespace Effigy.Tests;
/// <summary>
/// Collision, and the convex hull under it.
///
/// A HULL THAT IS SLIGHTLY TOO SMALL IS THE FAILURE THAT MATTERS. Too big and things bump into thin
/// air, which is visible the first time anyone plays; too small and they sink into the wall, which
/// looks like a physics glitch and gets blamed on the engine. So the checks here are about
/// containment: every point of the part inside the hull, and the hull's volume never less than the
/// part's.
/// </summary>
public static class CollisionTests
{
public static void Run()
{
Section( "collision: the convex hull" );
TestAHullOfABoxIsTheBox();
TestEveryPointIsInsideItsHull();
TestAConcavePartGetsABiggerHull();
TestADegenerateCloudIsRefusedRatherThanMangled();
Section( "collision: read from the history where it can be" );
TestPrimitivesComeBackAsPrimitives();
TestPatternsAndMirrorsCopyTheShapes();
TestAnythingElseFallsBackToHulls();
TestARotatedTransformSpoilsItRatherThanBeingIgnored();
TestASuppressedFeatureIsNotInTheCollision();
}
static void TestAHullOfABoxIsTheBox()
{
var box = Primitives.Box( 2, 2, 2 );
var hull = ConvexHull.Build( box.Positions );
Check( "a box has a hull", hull is not null );
Check( "with its eight corners and no more", hull!.Value.Points.Count == 8,
$"{hull.Value.Points.Count} points" );
// Six quads triangulated is twelve triangles, and a hull that came back with more has kept
// coplanar splits it should have merged - harmless, but it means the horizon logic is
// re-adding faces it should have deleted.
Check( "and twelve triangles", hull.Value.Faces.Count == 12, $"{hull.Value.Faces.Count} faces" );
var mesh = ConvexHull.ToMesh( box.Positions );
Check( "the hull mesh encloses the same volume as the box",
MathF.Abs( MathF.Abs( mesh.SignedVolume() ) - 8f ) < 1e-3f,
$"{MathF.Abs( mesh.SignedVolume() ):0.####}, wanted 8" );
}
static void TestEveryPointIsInsideItsHull()
{
// The containment check, on something with curvature and a lot of interior points.
var sphere = CatmullClark.Subdivide( Primitives.QuadSphere( 1f, 4 ), 1 );
var hull = ConvexHull.Build( sphere.Positions );
Check( "a subdivided sphere has a hull", hull is not null );
var outside = 0;
var worst = 0f;
var scale = sphere.BoundsDiagonal;
foreach ( var p in sphere.Positions )
{
foreach ( var face in hull!.Value.Faces )
{
var a = hull.Value.Points[face.A];
var normal = Vec3.Cross( hull.Value.Points[face.B] - a, hull.Value.Points[face.C] - a );
if ( normal.LengthSquared < 1e-20f )
continue;
var distance = Vec3.Dot( normal.Normal, p - a );
if ( distance > 1e-4f * scale )
{
outside++;
worst = MathF.Max( worst, distance );
break;
}
}
}
Check( "every vertex of the part is inside its own hull", outside == 0,
$"{outside} outside, worst by {worst:0.#####}" );
// A sphere has no interior vertices - every one of them is ON the hull - so dropping interior
// points has to be shown on a cloud that actually has some.
var withInside = new List<Vec3>( Primitives.Box( 2, 2, 2 ).Positions )
{
Vec3.Zero,
new( 0.3f, -0.2f, 0.1f ),
};
var trimmed = ConvexHull.Build( withInside );
Check( "and interior points are dropped rather than kept",
trimmed is not null && trimmed.Value.Points.Count == 8,
$"{trimmed?.Points.Count ?? 0} of {withInside.Count}" );
}
static void TestAConcavePartGetsABiggerHull()
{
// A hull fills a concavity in. That is the approximation, and it has to be stated as an
// over-estimate rather than discovered as a gap: bigger than the part, never smaller.
var tube = Primitives.Tube( 1f, 0.6f, 2f, 24 );
var hull = ConvexHull.ToMesh( tube.Positions );
Check( "a tube has a hull", hull is not null );
var partVolume = MathF.Abs( tube.SignedVolume() );
var hullVolume = MathF.Abs( hull.SignedVolume() );
Check( "which fills the bore in, so it is bigger", hullVolume > partVolume * 1.2f,
$"part {partVolume:0.###}, hull {hullVolume:0.###}" );
Check( "and never smaller than the part", hullVolume >= partVolume - 1e-4f );
}
static void TestADegenerateCloudIsRefusedRatherThanMangled()
{
// A flat cloud has no volume to enclose, and a hull of one is three coincident triangles.
// Returning null makes the caller decide; returning that would make the decision look taken.
var flat = new List<Vec3>
{
new( 0, 0, 0 ), new( 1, 0, 0 ), new( 1, 1, 0 ), new( 0, 1, 0 ), new( 0.5f, 0.5f, 0 ),
};
Check( "a flat cloud has no hull", ConvexHull.Build( flat ) is null );
var line = new List<Vec3> { new( 0, 0, 0 ), new( 1, 0, 0 ), new( 2, 0, 0 ), new( 3, 0, 0 ) };
Check( "nor does a collinear one", ConvexHull.Build( line ) is null );
Check( "nor three points", ConvexHull.Build( new List<Vec3> { Vec3.Zero, new( 1, 0, 0 ), new( 0, 1, 0 ) } ) is null );
}
static PartStudio Box( float size, Vec3 at )
{
var studio = new PartStudio();
var box = studio.Add( new PrimitiveFeature() );
box.SizeX.Value = size;
box.SizeY.Value = size;
box.SizeZ.Value = size;
box.Position.Value = at;
return studio;
}
static void TestPrimitivesComeBackAsPrimitives()
{
// The whole point: a model somebody built out of boxes IS its own collision, exactly, and
// hulling it would throw that away for no reason.
var studio = Box( 2f, new Vec3( 1, 0, 0 ) );
var second = studio.Add( new PrimitiveFeature() );
second.Shape.Index = 1; // Cylinder
second.Radius.Value = 0.5f;
second.SizeZ.Value = 3f;
second.Position.Value = new Vec3( -2, 0, 0 );
studio.Rebuild();
var report = CollisionBuilder.Build( studio );
Check( "two primitives give two shapes from the history",
report.FromHistory && report.Shapes.Count == 2, report.ToString() );
Check( "the box is a box, at its own size and place",
report.Shapes[0].Kind == CollisionKind.Box
&& MathF.Abs( report.Shapes[0].Size.x - 1f ) < 1e-4f
&& MathF.Abs( report.Shapes[0].Position.x - 1f ) < 1e-4f,
report.Shapes[0].ToString() );
Check( "and the cylinder is a cylinder, with its radius and half-height",
report.Shapes[1].Kind == CollisionKind.Cylinder
&& MathF.Abs( report.Shapes[1].Size.x - 0.5f ) < 1e-4f
&& MathF.Abs( report.Shapes[1].Size.z - 1.5f ) < 1e-4f,
report.Shapes[1].ToString() );
}
static void TestPatternsAndMirrorsCopyTheShapes()
{
var studio = Box( 1f, new Vec3( 2, 0, 0 ) );
var mirror = studio.Add( new MirrorFeature() );
mirror.PlaneNormal.Value = new Vec3( 1, 0, 0 );
mirror.PlanePoint.Value = Vec3.Zero;
studio.Rebuild();
var report = CollisionBuilder.Build( studio );
Check( "a mirror doubles the shapes", report.FromHistory && report.Shapes.Count == 2,
report.ToString() );
Check( "and puts the copy on the other side",
MathF.Abs( report.Shapes[1].Position.x + 2f ) < 1e-4f,
$"copy at x {report.Shapes[1].Position.x:0.###}" );
// Keep original off means the mirror REPLACES what it reflected. Collision left on the half
// that is no longer there would be an invisible wall.
mirror.KeepOriginal.Value = false;
studio.MarkDirty( mirror );
studio.Rebuild();
var replaced = CollisionBuilder.Build( studio );
Check( "and dropping the original drops its collision too",
replaced.Shapes.Count == 1 && MathF.Abs( replaced.Shapes[0].Position.x + 2f ) < 1e-4f,
replaced.ToString() );
}
static void TestAnythingElseFallsBackToHulls()
{
// A fillet is not describable as a primitive, so the history stops being a description of the
// shape and the fallback takes over - saying which feature spoiled it.
var studio = Box( 2f, Vec3.Zero );
studio.Add( new FilletFeature() ).Radius.Value = 0.2f;
studio.Rebuild();
var report = CollisionBuilder.Build( studio );
Check( "a filleted box is hulled rather than described", !report.FromHistory, report.ToString() );
Check( "and the report names what spoiled it",
report.Reason is not null && report.Reason.Contains( "Fillet" ), report.Reason ?? "" );
Check( "one hull for the one body",
report.Shapes.Count == 1 && report.Shapes[0].Kind == CollisionKind.Hull,
report.ToString() );
Check( "with real points on it", report.Shapes[0].Points is { Count: > 3 },
$"{report.Shapes[0].Points?.Count ?? 0} points" );
}
static void TestARotatedTransformSpoilsItRatherThanBeingIgnored()
{
// A CollisionShape has a position and a size but no orientation. Following the move and
// dropping the turn would leave a box square while the part it belongs to is at forty
// degrees, which shows up as something bouncing off thin air.
var studio = Box( 2f, Vec3.Zero );
var moved = studio.Add( new TransformFeature() );
moved.Translate.Value = new Vec3( 0, 0, 5 );
studio.Rebuild();
var slid = CollisionBuilder.Build( studio );
Check( "a plain move is followed", slid.FromHistory
&& MathF.Abs( slid.Shapes[0].Position.z - 5f ) < 1e-4f, slid.ToString() );
moved.RotationAngle.Value = 40f;
studio.MarkDirty( moved );
studio.Rebuild();
var turned = CollisionBuilder.Build( studio );
Check( "a rotation is not", !turned.FromHistory, turned.ToString() );
Check( "and it says so rather than dropping the rotation",
turned.Reason is not null && turned.Reason.Contains( "Transform" ), turned.Reason ?? "" );
}
static void TestASuppressedFeatureIsNotInTheCollision()
{
var studio = Box( 2f, Vec3.Zero );
var second = studio.Add( new PrimitiveFeature() );
second.Position.Value = new Vec3( 5, 0, 0 );
studio.Rebuild();
Check( "both primitives are in the collision",
CollisionBuilder.Build( studio ).Shapes.Count == 2 );
second.Suppressed = true;
studio.MarkDirty( second );
studio.Rebuild();
Check( "suppressing one takes its collision with it",
CollisionBuilder.Build( studio ).Shapes.Count == 1 );
}
}