Effigy.Tests/AcceptsTests.cs
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// Feature.Accepts against what Feature.ApplyGeometrySelection actually does.
///
/// WHY THIS IS A TEST RATHER THAN A READING. Accepts exists to collapse three copies of one fact —
/// the type switch that consumes a selection, the prose the editor paints under the viewport, and
/// the per-dialog pick-mode flags. Collapsing them is only worth anything if the surviving copy is
/// TRUE, and a declaration that quietly disagrees with the code beneath it is worse than the three
/// copies were: the editor would dim a button that works, or offer one that ignores you.
///
/// So nothing here reads the declaration and nods at it. Every feature type is found by reflection,
/// handed a selection of exactly one KIND, and watched: if applying faces changes the feature, it
/// had better say Face, and if it says Face something had better change. Forgetting to declare is
/// not an available mistake, and neither is declaring something that is not true.
///
/// One asymmetry is deliberate and is asserted rather than worked around: a FACE selection also
/// names its face's BODY, so a body-only tool legitimately reacts to a face click by taking the
/// part. That is why the fingerprint below is split in two — the body-selection half and everything
/// else — instead of being one lump that cannot tell those apart.
/// </summary>
public static class AcceptsTests
{
public static void Run()
{
Report.Section( "accepts: the declaration matches what the feature consumes" );
TestEveryFeatureAgreesWithItself();
Report.Section( "accepts: a body selection stays a body selection" );
TestBodyOnlySelection();
Report.Section( "accepts: the tools a face is worth offering" );
TestFaceConsumers();
}
/// <summary>
/// For every feature: apply one kind of pick at a time, and require the declaration to predict
/// whether anything moved.
/// </summary>
static void TestEveryFeatureAgreesWithItself()
{
var fixture = Fixture( out var body, out var sketchId );
var faces = new[] { FaceOf( body, v => v.z > 0.99f ) };
var edges = EdgesOf( body );
foreach ( var type in FeatureTypes() )
{
var declared = Create( type ).Accepts;
// --- faces --------------------------------------------------------------------------
var feature = Create( type );
var before = Fingerprint( feature );
feature.ApplyGeometrySelection( faces, Array.Empty<string>(), fixture.Bodies );
var after = Fingerprint( feature );
Report.Check( $"{type.Name}: face pick is consumed exactly as declared",
(after.Geometry != before.Geometry) == declared.HasFlag( GeometryKind.Face ),
$"declared {declared}, geometry {before.Geometry} -> {after.Geometry}" );
// A face names its body, so a body tool takes the part it was pointed at. Onshape does
// the same thing and it is why clicking a face then Fillet fillets that part.
Report.Check( $"{type.Name}: the body behind a picked face lands where declared",
(after.Bodies != before.Bodies) == declared.HasFlag( GeometryKind.Body ),
$"declared {declared}, bodies {before.Bodies} -> {after.Bodies}" );
// --- edges --------------------------------------------------------------------------
feature = Create( type );
before = Fingerprint( feature );
feature.ApplyGeometrySelection( Array.Empty<FaceRef>(), Array.Empty<string>(),
fixture.Bodies, edges );
after = Fingerprint( feature );
Report.Check( $"{type.Name}: edge pick is consumed exactly as declared",
(after.Geometry != before.Geometry) == declared.HasFlag( GeometryKind.Edge ),
$"declared {declared}, geometry {before.Geometry} -> {after.Geometry}" );
// --- a sketch, and one region of it -------------------------------------------------
feature = Create( type );
before = Fingerprint( feature );
feature.ApplyGeometrySelection( Array.Empty<FaceRef>(), Array.Empty<string>(),
fixture.Bodies, null, sketchId, new[] { new Vec2( 0.5f, 0.5f ) } );
after = Fingerprint( feature );
Report.Check( $"{type.Name}: sketch pick is consumed exactly as declared",
(after.Geometry != before.Geometry) == declared.HasFlag( GeometryKind.SketchRegion ),
$"declared {declared}, geometry {before.Geometry} -> {after.Geometry}" );
}
}
/// <summary>
/// A part clicked in the Parts list is a body and nothing else. Nothing may invent a face out of
/// it — the tools that need one still have to ask.
/// </summary>
static void TestBodyOnlySelection()
{
var fixture = Fixture( out var body, out _ );
foreach ( var type in FeatureTypes() )
{
var declared = Create( type ).Accepts;
var feature = Create( type );
var before = Fingerprint( feature );
feature.ApplyGeometrySelection( Array.Empty<FaceRef>(), new[] { body.Id }, fixture.Bodies );
var after = Fingerprint( feature );
Report.Check( $"{type.Name}: takes a picked part only if it declares Body",
(after.Bodies != before.Bodies) == declared.HasFlag( GeometryKind.Body ),
$"declared {declared}, bodies {before.Bodies} -> {after.Bodies}" );
Report.Check( $"{type.Name}: a part alone is not a face",
after.Geometry == before.Geometry,
$"geometry {before.Geometry} -> {after.Geometry}" );
}
}
/// <summary>
/// The list the editor's hint line and the right-click face menu are generated FROM, pinned
/// here so a tool cannot fall off it silently — which is the exact complaint that started this:
/// a face was selected and the tool that wanted it was not on the list.
/// </summary>
static void TestFaceConsumers()
{
var consumers = FeatureTypes()
.Where( t => Create( t ).Accepts.HasFlag( GeometryKind.Face ) )
.Select( t => t.Name )
.ToList();
foreach ( var expected in new[]
{
nameof( SketchFeature ), nameof( PlaneFeature ), nameof( DraftFeature ), nameof( HoleFeature ),
nameof( FaceMaterialFeature ), nameof( SubdivideFeature ), nameof( ShellFeature ),
nameof( FilletFeature ), nameof( ChamferFeature ),
// Extrude joined this list when a mesh face became a profile it can pull. It used to be
// pinned here as deliberately ABSENT, with a note saying that the day it changed should
// be a day something said so out loud — this is that, kept rather than deleted.
nameof( ExtrudeFeature ), nameof( MoveFaceFeature ),
} )
{
Report.Check( $"{expected} is offered a face", consumers.Contains( expected ),
string.Join( ", ", consumers ) );
}
// Revolve is the one that is deliberately NOT here. Spinning a mesh face about an axis is a
// real operation and nothing has built it, so saying it takes a face would be a lie of the
// exact kind Accepts exists to stop. This is what says so when that changes.
Report.Check( "revolve does not claim a face it cannot use yet",
!consumers.Contains( nameof( RevolveFeature ) ), string.Join( ", ", consumers ) );
}
// --- the fingerprint --------------------------------------------------------------------------
/// <summary>
/// What a feature is holding, in two halves: the bodies it has been pointed at, and every other
/// piece of picked geometry on it.
///
/// BY REFLECTION over the public fields rather than a switch naming the ones known today. A
/// switch here would be a fourth copy of the fact this whole exercise exists to have one of, and
/// it would go stale the same way. Counts are enough — every consuming path fills something that
/// started empty or sets something that started null.
/// </summary>
readonly struct Snapshot
{
public Snapshot( string bodies, string geometry )
{
Bodies = bodies;
Geometry = geometry;
}
public string Bodies { get; }
public string Geometry { get; }
}
static Snapshot Fingerprint( Feature feature )
{
var bodies = string.Join( "/", feature.Parameters
.OfType<BodySelectionParam>()
.Select( p => $"{p.Label}:{p.BodyIds.Count}" ) );
var geometry = new List<string>();
foreach ( var field in feature.GetType().GetFields( BindingFlags.Public | BindingFlags.Instance ) )
{
var value = field.GetValue( feature );
switch ( value )
{
case null:
geometry.Add( $"{field.Name}:null" );
break;
// The body lists are the other half of the snapshot and must not be counted twice.
case BodySelectionParam:
break;
case FaceRef:
case EdgeRef:
geometry.Add( $"{field.Name}:set" );
break;
case string text:
geometry.Add( $"{field.Name}:{text}" );
break;
case ICollection collection:
geometry.Add( $"{field.Name}:{collection.Count}" );
break;
}
}
return new Snapshot( bodies, string.Join( "/", geometry ) );
}
// --- the fixture ------------------------------------------------------------------------------
/// <summary>A box to pick faces and edges off, and a closed sketch to pick as a profile.</summary>
static PartStudio Fixture( out Body body, out string sketchId )
{
var studio = new PartStudio();
studio.Add( new PrimitiveFeature() );
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( 0.25f, 0.25f ), new Vec2( 0.75f, 0.75f ) );
sketchId = sketch.Id;
studio.Rebuild();
body = studio.Bodies[0];
return studio;
}
static FaceRef FaceOf( Body body, Func<Vec3, bool> normal )
{
var mesh = body.Mesh;
for ( var i = 0; i < mesh.Faces.Count; i++ )
{
if ( normal( mesh.FaceNormal( mesh.Faces[i] ).Normal ) )
return FacePlane.Capture( body, i, mesh.FaceCentroid( mesh.Faces[i] ) );
}
throw new InvalidOperationException( "the fixture box has no face pointing that way" );
}
static EdgeRef[] EdgesOf( Body body )
{
var face = FaceOf( body, v => v.z > 0.99f );
if ( !FacePlane.TryResolveFace( new[] { body }, face, out var resolved, out var index ) )
throw new InvalidOperationException( "the fixture's top face did not resolve" );
return FacePlane.CaptureBoundary( resolved, index ).ToArray();
}
static List<Type> FeatureTypes() => typeof( Feature ).Assembly
.GetTypes()
.Where( t => t.IsSubclassOf( typeof( Feature ) ) && !t.IsAbstract )
.OrderBy( t => t.Name, StringComparer.Ordinal )
.ToList();
static Feature Create( Type t ) => (Feature)Activator.CreateInstance( t );
}