Effigy.Tests/DocumentTests.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
namespace Effigy.Tests;
/// <summary>
/// Saving and loading a Part Studio.
///
/// The bar here is higher than "it wrote a file". A modelling document that loses something on the
/// way through is worse than one that fails to open: a failure sends you to a backup, a silent loss
/// sends you back to work you thought you had. So the tests compare the model that comes back
/// against the one that went in, field by field, and the strongest one is generated rather than
/// written — every feature type in the assembly, every field it declares, set to a value that is not
/// the default, round-tripped and compared.
///
/// That last test is what makes the reflective design safe. StudioDocument saves fields it finds
/// rather than fields someone remembered to list, so the risk it carries is a field of a type the
/// writer has no case for. This turns that from a silent hole into a failing test the day the field
/// is added.
/// </summary>
public static class DocumentTests
{
public static void Run()
{
Report.Section( "document: a studio survives the round trip" );
TestRoundTrip();
Report.Section( "document: every feature type carries every field it declares" );
TestEveryFieldSurvives();
Report.Section( "document: the geometry is identical after a reload" );
TestRebuildMatches();
Report.Section( "document: malformed and unfamiliar files" );
TestBadInput();
Report.Section( "document: it goes to disk and comes back" );
TestFile();
Report.Section( "document: the rig survives the round trip" );
TestRigRoundTrip();
Report.Section( "document: soft bones survive the round trip" );
TestSoftRoundTrip();
Report.Section( "document: a part with no rig is unchanged by the rig block existing" );
TestUnriggedUntouched();
}
/// <summary>
/// The gap this closes was total, not partial: before the rig block, a skeleton lived on the
/// editor's rig panel and StudioDocument had never heard of one. Placing bones, saving and
/// reopening lost every bone with no error and no warning, because nothing in the format was
/// ever asked to carry them.
/// </summary>
static void TestRigRoundTrip()
{
var studio = new PartStudio();
var spine = studio.Rig.AddBone( "spine", -1,
new Xform( new Vec3( 1, 0, 0 ), new Vec3( 0, 1, 0 ), new Vec3( 0, 0, 1 ), new Vec3( 0, 0, 4 ) ), 6f );
var tail = studio.Rig.AddBone( "tail", spine,
new Xform( new Vec3( 1, 0, 0 ), new Vec3( 0, 1, 0 ), new Vec3( 0, 0, 1 ), new Vec3( 0, 6, 0 ) ), 3.5f );
studio.BodyBoneMap["body_1"] = "spine";
studio.BodyBoneMap["body_2"] = "tail";
var back = StudioDocument.Read( StudioDocument.Write( studio ) );
Report.Check( "both bones come back", back.Rig.Count == 2, $"got {back.Rig.Count}" );
Report.Check( "with their names, in order",
back.Rig.Count == 2 && back.Rig.Bones[0].Name == "spine" && back.Rig.Bones[1].Name == "tail",
back.Rig.Count == 2 ? $"{back.Rig.Bones[0].Name}, {back.Rig.Bones[1].Name}" : "not two bones" );
// The parenting is the part an index-based format can get subtly wrong, and a rig whose
// chain has come apart still LOOKS like a rig.
Report.Check( "and their parenting",
back.Rig.Count == 2 && back.Rig.Bones[0].Parent == -1 && back.Rig.Bones[1].Parent == 0,
back.Rig.Count == 2 ? $"parents {back.Rig.Bones[0].Parent}, {back.Rig.Bones[1].Parent}" : "not two bones" );
Report.Check( "lengths exactly",
back.Rig.Count == 2 && back.Rig.Bones[0].Length == 6f && back.Rig.Bones[1].Length == 3.5f,
"lengths differ" );
Report.Check( "the bind pose exactly",
back.Rig.Count == 2
&& back.Rig.Bones[0].Local.Origin.Equals( new Vec3( 0, 0, 4 ) )
&& back.Rig.Bones[1].Local.Origin.Equals( new Vec3( 0, 6, 0 ) ),
"origins differ" );
Report.Check( "and the body bindings",
back.BodyBoneMap.Count == 2
&& back.BodyBoneMap["body_1"] == "spine"
&& back.BodyBoneMap["body_2"] == "tail",
$"{back.BodyBoneMap.Count} bindings came back" );
}
/// <summary>
/// Softness specifically, because it is the reason the block was written and because its
/// absence has to mean something precise: a bone with no soft line is RIGID, which is not the
/// same as a bone whose four numbers happen to be zero. Zero stiffness with a zero cone is a
/// dead limb pinned to its pose - a real setting somebody might choose - so a reader that
/// filled the gap with defaults would silently simulate every rigid bone in the file.
/// </summary>
static void TestSoftRoundTrip()
{
var studio = new PartStudio();
studio.Rig.AddBone( "rigid", -1, Xform.Identity, 4f );
var soft = studio.Rig.AddBone( "swings", 0, Xform.Identity, 4f );
studio.Rig.Bones[soft].Soft = new SoftBone
{
Stiffness = 123.5f,
Damping = 0.075f,
Weight = 0.5f,
MaxAngle = 33f,
};
var back = StudioDocument.Read( StudioDocument.Write( studio ) );
Report.Check( "the rigid bone comes back rigid",
back.Rig.Count == 2 && back.Rig.Bones[0].Soft is null,
back.Rig.Count == 2 ? "it came back soft" : "wrong bone count" );
var carried = back.Rig.Count == 2 ? back.Rig.Bones[1].Soft : null;
Report.Check( "the soft bone comes back soft", carried is not null, "it came back rigid" );
Report.Check( "with all four numbers exactly",
carried is not null
&& carried.Stiffness == 123.5f
&& carried.Damping == 0.075f
&& carried.Weight == 0.5f
&& carried.MaxAngle == 33f,
carried is null
? "nothing came back"
: $"{carried.Stiffness} {carried.Damping} {carried.Weight} {carried.MaxAngle}" );
// Zero is a value, not an absence. This is the case a defaults-filling reader gets wrong.
var zeroed = new PartStudio();
zeroed.Rig.AddBone( "dead", -1, Xform.Identity, 4f );
zeroed.Rig.Bones[0].Soft = new SoftBone { Stiffness = 0f, Damping = 0f, Weight = 0f, MaxAngle = 0f };
var zeroBack = StudioDocument.Read( StudioDocument.Write( zeroed ) ).Rig.Bones[0].Soft;
Report.Check( "four zeros survive as four zeros rather than as defaults",
zeroBack is not null && zeroBack.Stiffness == 0f && zeroBack.Damping == 0f
&& zeroBack.Weight == 0f && zeroBack.MaxAngle == 0f,
zeroBack is null ? "the bone came back rigid" : $"{zeroBack.Stiffness} {zeroBack.Damping}" );
}
/// <summary>
/// The rule the origin and the material scales already follow, applied to the rig: a document
/// nobody has rigged must have exactly the bytes it had before this block existed, and must
/// still claim the format version that build wrote. Otherwise adding the feature rewrites the
/// first line of every file in the repository the first time each is opened and saved.
/// </summary>
static void TestUnriggedUntouched()
{
var studio = Worked();
var text = StudioDocument.Write( studio );
Report.Check( "no rig block is written for a part with no bones",
!text.Contains( "\nrig\n" ) && !text.Contains( "endrig" ),
"a rig block appeared anyway" );
Report.Check( "and it still claims format 1",
text.StartsWith( "effigy 1\n" ),
text.Split( '\n' )[0] );
// And the other half of the same rule: a document that DOES carry a rig says so, because an
// older build reading it would drop the whole thing on the floor.
studio.Rig.AddBone( "spine", -1, Xform.Identity, 4f );
Report.Check( "a rigged part claims format 2",
StudioDocument.Write( studio ).StartsWith( "effigy 2\n" ),
StudioDocument.Write( studio ).Split( '\n' )[0] );
}
static void TestRoundTrip()
{
var studio = Worked();
var back = StudioDocument.Read( StudioDocument.Write( studio ) );
Report.Check( "the same number of features come back",
back.Features.Count == studio.Features.Count,
$"{studio.Features.Count} became {back.Features.Count}" );
Report.Check( "in the same order and of the same types",
back.Features.Select( f => f.GetType().Name ).SequenceEqual( studio.Features.Select( f => f.GetType().Name ) ),
string.Join( ", ", back.Features.Select( f => f.GetType().Name ) ) );
// IDS ARE NOT COSMETIC. Body ids derive from the feature that made them, and a FaceRef holds
// a body id — so a load that reissued feature ids would break every sketch drawn on a face
// the moment the file was reopened, which is the single worst thing this format could do.
Report.Check( "feature ids are preserved exactly",
back.Features.Select( f => f.Id ).SequenceEqual( studio.Features.Select( f => f.Id ) ),
string.Join( ", ", back.Features.Select( f => f.Id ) ) );
Report.Check( "names come back", back.Features[0].Name == studio.Features[0].Name,
$"'{studio.Features[0].Name}' became '{back.Features[0].Name}'" );
Report.Check( "the rollback bar comes back", back.RollbackIndex == studio.RollbackIndex,
$"{studio.RollbackIndex} became {back.RollbackIndex}" );
// THE PIVOT IS PART OF THE MODEL. It decides where every exported mesh, bone and hull sits
// relative to zero, so a load that dropped it would move the whole model on reopen - and
// silently, because nothing on screen is drawn from it.
Report.Check( "the pivot comes back",
( back.Origin - studio.Origin ).Length < 1e-6f,
$"{studio.Origin.x},{studio.Origin.y},{studio.Origin.z} became {back.Origin.x},{back.Origin.y},{back.Origin.z}" );
// A document that never moved the pivot must not grow an origin line, and must still read
// back as zero rather than as absent-meaning-something-else.
Report.Check( "an untouched pivot writes no line and comes back at zero",
!StudioDocument.Write( new PartStudio() ).Contains( "origin " )
&& StudioDocument.Read( StudioDocument.Write( new PartStudio() ) ).Origin.Length == 0f,
"an unmoved origin should be absent from the file" );
Report.Check( "material slot names come back, spaces and all",
back.NameForSlot( 3 ) == "brushed steel" && back.NameForSlot( 7 ) == "rubber",
$"{back.NameForSlot( 3 )} / {back.NameForSlot( 7 )}" );
Report.Check( "and an unnamed slot still falls back to its number",
back.NameForSlot( 5 ) == "material_5", back.NameForSlot( 5 ) );
Report.Check( "a renamed part comes back",
back.BodyNames.TryGetValue( "boxb0", out var partName ) && partName == "Housing",
partName ?? "missing" );
Report.Check( "a hidden part stays hidden", back.HiddenBodyIds.Contains( "boxb0" ) );
var box = (PrimitiveFeature)back.Features[0];
Report.Check( "float parameters come back exactly",
box.SizeX.Value == 4.25f && box.SizeZ.Value == 0.1f,
$"{box.SizeX.Value} / {box.SizeZ.Value}" );
Report.Check( "and suppression with them", back.Features.Any( f => f.Suppressed ) );
var sketch = back.Features.OfType<SketchFeature>().First();
Report.Check( "the sketch's points come back",
sketch.Sketch.Points.Count == 6, $"{sketch.Sketch.Points.Count} points" );
Report.Check( "its curves come back with their ids",
sketch.Sketch.Curves.Count == 5
&& sketch.Sketch.Curves.All( c => !string.IsNullOrEmpty( c.Id ) ),
$"{sketch.Sketch.Curves.Count} curves" );
Report.Check( "construction geometry stays construction",
sketch.Sketch.Curves.Count( c => c.Construction ) == 1 );
Report.Check( "and its constraints come back whole",
sketch.Sketch.Constraints.Count == 2
&& sketch.Sketch.Constraints.Any( c => c.Kind == SketchConstraintKind.Distance && c.Value == 3.5f ),
$"{sketch.Sketch.Constraints.Count} constraints" );
var attached = back.Features.OfType<SketchFeature>().Last();
Report.Check( "a sketch attached to a face remembers which face",
attached.Face is { } face && face.BodyId == "boxb0" && face.Anchored,
attached.Face?.BodyId ?? "no face" );
var extrude = back.Features.OfType<ExtrudeFeature>().First();
Report.Check( "an extrude remembers which sketch it consumes",
extrude.SketchFeatureId == "sketch1", extrude.SketchFeatureId ?? "none" );
Report.Check( "and which region of it", extrude.RegionSeeds.Count == 1
&& MathF.Abs( extrude.RegionSeeds[0].x - 1.5f ) < 1e-6f, $"{extrude.RegionSeed}" );
Report.Check( "the Result choice comes back", extrude.Result.Index == 1,
$"index {extrude.Result.Index}" );
var paint = back.Features.OfType<FaceMaterialFeature>().First();
Report.Check( "a face material keeps every face it was given", paint.Faces.Count == 2,
$"{paint.Faces.Count} faces" );
Report.Check( "and the slot it paints them", paint.Material.Value == 3, $"{paint.Material.Value}" );
var shell = back.Features.OfType<ShellFeature>().First();
Report.Check( "a body selection comes back", shell.Bodies.BodyIds.SequenceEqual( new[] { "boxb0" } ),
string.Join( ", ", shell.Bodies.BodyIds ) );
// Writing what was read must produce the same text. A round trip that is stable only once is
// a round trip that is losing something slowly.
Report.Check( "writing it again produces byte-identical text",
StudioDocument.Write( back ) == StudioDocument.Write( studio ) );
}
/// <summary>
/// Every feature, every field, set to something that is not its default.
///
/// The point is coverage without a list. StudioDocument finds fields by reflection, so this
/// finds them the same way: anything it cannot save throws while writing, and anything it saves
/// but does not restore comes back unequal. A feature added next year is covered the day it is
/// written, which is the only way this stays true.
/// </summary>
static void TestEveryFieldSurvives()
{
foreach ( var type in FeatureTypes() )
{
var feature = (Feature)Activator.CreateInstance( type );
var fields = SaveableFields( type );
foreach ( var field in fields )
Disturb( feature, field );
var studio = new PartStudio();
studio.Add( feature );
string text;
try
{
text = StudioDocument.Write( studio );
}
catch ( Exception e )
{
// The failure this whole design is exposed to: a field of a type the writer has no
// case for. Better here than as a model that saves and comes back missing a piece.
Report.Check( $"{type.Name} can be written", false, e.Message );
continue;
}
var back = StudioDocument.Read( text ).Features.Single();
var mismatch = fields.FirstOrDefault( f => !Same( field: f, a: feature, b: back ) );
Report.Check( $"{type.Name} round-trips all {fields.Count} of its fields",
mismatch is null,
mismatch is null ? "" : $"{mismatch.Name} ({mismatch.FieldType.Name}) came back different" );
}
}
static void TestRebuildMatches()
{
// The real test of a document format for a modeller: the model it describes has to build
// into the same geometry. Comparing the tree field by field can pass while the model comes
// out different, if something the tree depends on was not carried.
var studio = Worked();
var originalReport = studio.Rebuild();
Report.Check( "the fixture builds cleanly to begin with", !originalReport.HasErrors,
string.Join( "; ", studio.Features.Where( f => f.Error is not null ).Select( f => $"{f.TypeName}: {f.Error}" ) ) );
var before = studio.ToMesh();
var back = StudioDocument.Read( StudioDocument.Write( studio ) );
var report = back.Rebuild();
// Compared against the original rather than demanded to be clean: what this test is about is
// the format carrying the model, and a fixture that legitimately errored should error the
// same way on both sides rather than being unrepresentable.
Report.Check( "the reloaded studio builds exactly as well as the original",
report.Errors.Count == originalReport.Errors.Count,
$"{originalReport.Errors.Count} errors became {report.Errors.Count}: {report}" );
var after = back.ToMesh();
Report.Check( "same vertex count", after.VertexCount == before.VertexCount,
$"{before.VertexCount} became {after.VertexCount}" );
Report.Check( "same face count", after.FaceCount == before.FaceCount,
$"{before.FaceCount} became {after.FaceCount}" );
Report.Check( "same enclosed volume", MathF.Abs( Volume( after ) - Volume( before ) ) < 1e-3f,
$"{Volume( before ):0.####} became {Volume( after ):0.####}" );
Report.Check( "and the same body ids, so anything referring to one still resolves",
back.Bodies.Select( b => b.Id ).SequenceEqual( studio.Bodies.Select( b => b.Id ) ),
string.Join( ", ", back.Bodies.Select( b => b.Id ) ) );
// Material assignments are carried by faces rather than by bodies, so they are worth their
// own check: a document that lost them would still pass every count above.
Report.Check( "painted faces are still painted",
after.Faces.Count( f => f.Material == 3 ) == before.Faces.Count( f => f.Material == 3 ),
$"{before.Faces.Count( f => f.Material == 3 )} became {after.Faces.Count( f => f.Material == 3 )}" );
}
static void TestBadInput()
{
Report.Check( "something that is not a document is refused",
Refused( "hello\nworld\n", out var notADoc ), "it parsed" );
Report.Check( "and says what it wanted instead",
notADoc is not null && notADoc.Contains( "effigy" ), notADoc ?? "" );
Report.Check( "a file from a newer format is refused by version",
Refused( $"effigy {StudioDocument.Version + 9}\n", out var newer ), "it parsed" );
Report.Check( "naming both versions so it is obvious what to do",
newer is not null && newer.Contains( "newer" ), newer ?? "" );
Report.Check( "a feature type this build does not have is named",
Refused( "effigy 1\nfeature SomethingFromTheFuture\n\tid x\nend\n", out var unknown ), "it parsed" );
Report.Check( "rather than throwing something unreadable",
unknown is not null && unknown.Contains( "SomethingFromTheFuture" ), unknown ?? "" );
Report.Check( "a document that stops mid-feature is refused",
Refused( "effigy 1\nfeature PrimitiveFeature\n\tid x\n", out _ ), "it parsed" );
Report.Check( "and one that stops mid-sketch",
Refused( "effigy 1\nfeature SketchFeature\n\tid x\n\tsketch Sketch\n\t\tpoint 0 0\n", out _ ), "it parsed" );
// FORWARD COMPATIBILITY, which is the difference between a format that can grow and one
// that strands files. A parameter this build has never heard of is skipped, and everything
// around it still loads.
var withExtra = "effigy 1\nfeature PrimitiveFeature\n\tid keep\n\tparam SizeX 7\n"
+ "\tparam SomeFutureThing 42\n\tparam SizeY 3\nend\n";
var loaded = StudioDocument.Read( withExtra ).Features.Single() as PrimitiveFeature;
Report.Check( "an unfamiliar parameter is skipped rather than fatal",
loaded is not null && loaded.SizeX.Value == 7f && loaded.SizeY.Value == 3f,
loaded is null ? "did not load" : $"{loaded.SizeX.Value} / {loaded.SizeY.Value}" );
// Documents written before RegionSeeds was a list stored one point as `vec2 RegionSeed`.
var oldSeed = "effigy 1\nfeature ExtrudeFeature\n\tid e1\n"
+ "\ttext SketchFeatureId sketch1\n\tvec2 RegionSeed 1.5 1.5\nend\n";
var loadedOld = StudioDocument.Read( oldSeed ).Features.Single() as ExtrudeFeature;
Report.Check( "a document that stored one RegionSeed still loads it as the list",
loadedOld is not null && loadedOld.RegionSeeds.Count == 1
&& MathF.Abs( loadedOld.RegionSeeds[0].x - 1.5f ) < 1e-6f
&& MathF.Abs( loadedOld.RegionSeeds[0].y - 1.5f ) < 1e-6f,
loadedOld is null ? "did not load" : $"{loadedOld.RegionSeed}" );
// An empty studio is a real thing to save — a new document someone hits Ctrl+S in.
var empty = StudioDocument.Read( StudioDocument.Write( new PartStudio() ) );
Report.Check( "an empty studio round-trips", empty.Features.Count == 0 );
}
static void TestFile()
{
var path = Path.Combine( Path.GetTempPath(), $"effigy-doc-{Guid.NewGuid():N}{StudioDocument.Extension}" );
try
{
var studio = Worked();
StudioDocument.WriteFile( studio, path );
Report.Check( "the file is written", File.Exists( path ) );
var back = StudioDocument.ReadFile( path );
Report.Check( "and reads back as the same tree",
back.Features.Count == studio.Features.Count
&& back.Features.Select( f => f.Id ).SequenceEqual( studio.Features.Select( f => f.Id ) ) );
// Text, and diffable. The format is meant to be readable when something goes wrong with
// it, which a binary would not be.
var text = File.ReadAllText( path );
Report.Check( "it is text a person can read", text.StartsWith( "effigy " ) && text.Contains( "feature " ) );
Report.Check( "with no culture-dependent decimal commas in it",
!System.Text.RegularExpressions.Regex.IsMatch( text, @"\d,\d" ) );
}
finally
{
if ( File.Exists( path ) )
File.Delete( path );
}
}
// --- helpers ------------------------------------------------------------------------------
/// <summary>A studio with something of everything in it: a primitive, two sketches (one on a
/// face), an extrude that names its sketch and region, a face material, a shell with a body
/// selection, a suppressed feature and a rollback bar.</summary>
static PartStudio Worked()
{
var studio = new PartStudio();
var box = studio.Add( new PrimitiveFeature() );
box.Id = "box";
box.Name = "Base block";
box.SizeX.Value = 4.25f;
box.SizeY.Value = 3f;
box.SizeZ.Value = 0.1f;
var sketch = studio.Add( new SketchFeature() );
sketch.Id = "sketch1";
sketch.Sketch.AddRectangle( new Vec2( 1f, 1f ), new Vec2( 2f, 2f ) );
// A construction line ALONGSIDE the rectangle, not one of its edges. Marking an edge as
// construction takes it out of profile finding, which leaves the loop open and the extrude
// with nothing to build — correct behaviour, and not what this fixture is for.
sketch.Sketch.AddLine( new Vec2( 1f, 1.5f ), new Vec2( 2f, 1.5f ) ).Construction = true;
sketch.Sketch.AddConstraint( sketch.Sketch.Curves[0], SketchConstraintKind.Horizontal );
sketch.Sketch.AddConstraint( SketchConstraintKind.Distance, 0, 1, 3.5f );
var extrude = studio.Add( new ExtrudeFeature() );
extrude.Id = "extrude1";
extrude.SketchFeatureId = "sketch1";
extrude.RegionSeed = new Vec2( 1.5f, 1.5f );
extrude.Distance.Value = 1.25f;
extrude.Result.Index = 1;
var onFace = studio.Add( new SketchFeature() );
onFace.Id = "sketch2";
onFace.Face = new FaceRef( "boxb0", new Vec3( 0, 0, 0.05f ), new Vec3( 0, 0, 1 ),
new Vec2( 0.25f, 0.5f ), fromMaxX: true, fromMaxY: false );
onFace.Sketch.AddCircle( new Vec2( 0, 0 ), 0.4f );
var paint = studio.Add( new FaceMaterialFeature() );
paint.Id = "paint1";
paint.Material.Value = 3;
paint.Faces.Add( new FaceRef( "boxb0", new Vec3( 0, 0, 0.05f ), new Vec3( 0, 0, 1 ) ) );
paint.Faces.Add( new FaceRef( "boxb0", new Vec3( 0, 0, -0.05f ), new Vec3( 0, 0, -1 ) ) );
var shell = studio.Add( new ShellFeature() );
shell.Id = "shell1";
shell.Bodies.BodyIds.Add( "boxb0" );
shell.Suppressed = true;
studio.RollbackIndex = 6;
studio.Origin = new Vec3( 1.5f, -2f, 0.25f );
studio.MaterialNames[3] = "brushed steel";
studio.MaterialNames[7] = "rubber";
studio.BodyNames["boxb0"] = "Housing";
studio.HiddenBodyIds.Add( "boxb0" );
return studio;
}
static IEnumerable<Type> FeatureTypes() => typeof( Feature ).Assembly.GetTypes()
.Where( t => !t.IsAbstract && typeof( Feature ).IsAssignableFrom( t ) )
.OrderBy( t => t.Name, StringComparer.Ordinal );
static List<FieldInfo> SaveableFields( Type type ) => type
.GetFields( BindingFlags.Public | BindingFlags.Instance )
.Where( f => f.Name is not ("Id" or "Name" or "Suppressed" or "Visible") )
.ToList();
/// <summary>Move a field off its default, so a round trip that drops it is visible. A field left
/// at its default would come back "correct" from a writer that never wrote it.</summary>
static void Disturb( Feature feature, FieldInfo field )
{
switch ( field.GetValue( feature ) )
{
case FloatParam p: p.Value = 2.375f; break;
case IntParam p: p.Value = Math.Clamp( 3, p.Min, p.Max ); break;
case BoolParam p: p.Value = !p.Value; break;
case ChoiceParam p: p.Index = p.Options.Length - 1; break;
case Vec3Param p: p.Value = new Vec3( 0.5f, -1.25f, 2f ); break;
case StringParam p: p.Value = "meshes/some.obj"; break;
case BodySelectionParam p: p.BodyIds.Add( "someb0" ); break;
case List<int> ints: ints.Add( 4 ); ints.Add( 7 ); break;
case List<string> texts: texts.Add( "sk0001" ); texts.Add( "sk0002" ); break;
case List<Vec2> vecs: vecs.Add( new Vec2( 0.75f, -0.25f ) ); vecs.Add( new Vec2( 1.25f, 2f ) ); break;
case List<FaceRef> list: list.Add( new FaceRef( "someb0", new Vec3( 1, 2, 3 ), new Vec3( 0, 0, 1 ) ) ); break;
case List<EdgeRef> edges: edges.Add( new EdgeRef( "someb0", new Vec3( 1, 0, 1 ), new Vec3( 0, 1, 0 ) ) ); break;
case Sketch sketch:
sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 1.5f, 2.5f ) );
sketch.AddConstraint( SketchConstraintKind.Distance, 0, 1, 1.5f );
break;
case null when field.FieldType == typeof( FaceRef? ):
field.SetValue( feature, new FaceRef( "someb0", new Vec3( 1, 2, 3 ), new Vec3( 0, 1, 0 ),
new Vec2( 0.25f, 0.75f ), fromMaxX: true, fromMaxY: true ) );
break;
case null when field.FieldType == typeof( Vec2? ):
field.SetValue( feature, new Vec2( 0.75f, -0.25f ) );
break;
case string:
field.SetValue( feature, "some-id" );
break;
}
}
static bool Same( FieldInfo field, Feature a, Feature b )
{
var x = field.GetValue( a );
var y = field.GetValue( b );
return (x, y) switch
{
(FloatParam p, FloatParam q) => p.Value == q.Value && p.Expr == q.Expr,
(IntParam p, IntParam q) => p.Value == q.Value,
(BoolParam p, BoolParam q) => p.Value == q.Value,
(ChoiceParam p, ChoiceParam q) => p.Index == q.Index,
(Vec3Param p, Vec3Param q) => p.Value.x == q.Value.x && p.Value.y == q.Value.y && p.Value.z == q.Value.z,
(StringParam p, StringParam q) => p.Value == q.Value,
(BodySelectionParam p, BodySelectionParam q) => p.BodyIds.SequenceEqual( q.BodyIds ),
(List<int> p, List<int> q) => p.SequenceEqual( q ),
(List<string> p, List<string> q) => p.SequenceEqual( q ),
(List<Vec2> p, List<Vec2> q) => p.Count == q.Count
&& p.Zip( q ).All( pair => pair.First.x == pair.Second.x && pair.First.y == pair.Second.y ),
(List<FaceRef> p, List<FaceRef> q) => p.Count == q.Count && p.Zip( q ).All( pair => SameFace( pair.First, pair.Second ) ),
(List<EdgeRef> p, List<EdgeRef> q) => p.Count == q.Count && p.Zip( q ).All( pair => SameEdge( pair.First, pair.Second ) ),
(Sketch p, Sketch q) => SameSketch( p, q ),
(FaceRef p, FaceRef q) => SameFace( p, q ),
(Vec2 p, Vec2 q) => p.x == q.x && p.y == q.y,
(string p, string q) => p == q,
(null, null) => true,
_ => Equals( x, y )
};
}
static bool SameFace( FaceRef a, FaceRef b ) =>
a.BodyId == b.BodyId && a.Anchored == b.Anchored
&& a.AnchorFromMaxX == b.AnchorFromMaxX && a.AnchorFromMaxY == b.AnchorFromMaxY
&& a.Point.x == b.Point.x && a.Point.y == b.Point.y && a.Point.z == b.Point.z
&& a.Anchor.x == b.Anchor.x && a.Anchor.y == b.Anchor.y;
static bool SameEdge( EdgeRef a, EdgeRef b ) =>
a.BodyId == b.BodyId
&& a.Point.x == b.Point.x && a.Point.y == b.Point.y && a.Point.z == b.Point.z
&& a.Direction.x == b.Direction.x && a.Direction.y == b.Direction.y && a.Direction.z == b.Direction.z;
static bool SameSketch( Sketch a, Sketch b ) =>
a.Points.Count == b.Points.Count
&& a.Curves.Count == b.Curves.Count
&& a.Constraints.Count == b.Constraints.Count
&& a.Points.Zip( b.Points ).All( p => p.First.x == p.Second.x && p.First.y == p.Second.y )
&& a.Curves.Zip( b.Curves ).All( c => c.First.Id == c.Second.Id
&& c.First.GetType() == c.Second.GetType()
&& c.First.Construction == c.Second.Construction )
&& a.Constraints.Zip( b.Constraints ).All( c => c.First.Kind == c.Second.Kind
&& c.First.PointA == c.Second.PointA && c.First.Value == c.Second.Value );
static bool Refused( string text, out string message )
{
message = null;
try
{
StudioDocument.Read( text );
return false;
}
catch ( Exception e )
{
message = e.Message;
return true;
}
}
static float Volume( PolyMesh mesh ) => mesh.SignedVolume();
}