Effigy.Tests/ImportFeatureTests.cs
using System;
using System.IO;
using System.Linq;
using System.Text;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// Import: a mesh file becomes a body, the triangles stay out of the .effigy text, and a save
/// plus a load still has them.
///
/// The claim this exists to protect is the one the Host handoff made: Effigy is constructive-only,
/// so a sculpt could not be painted or weighted until it was a body. An Import that writes the
/// mesh into the document would "work" and still be the wrong answer — megabytes of vertices in a
/// format whose virtue is being readable. The sidecar tests are the ones that would fail if that
/// happened.
/// </summary>
public static class ImportFeatureTests
{
public static void Run()
{
Report.Section( "import: an OBJ becomes a body" );
TestLoadsAnObj();
TestPreservesQuadsAndUvs();
TestMissingSourceFailsUsefully();
TestWrongFormatFailsUsefully();
TestEmptyFileFailsUsefully();
Report.Section( "import: the mesh stays out of the document" );
TestDocumentHoldsThePathNotTheMesh();
TestSidecarCarriesTheMeshAcrossASaveAndLoad();
TestSavingDoesNotDeleteASidecarItDidNotWrite();
Report.Section( "import: the body is a body, so the rest of the tool can see it" );
TestPaintAndSkinSeeTheBody();
Report.Section( "import: a file with several objects becomes several parts" );
TestObjectsBecomeSeparateBodies();
TestSplitRenumbersEachPiece();
TestOneObjectKeepsTheFeatureName();
TestFusedReadIsUnchanged();
TestPiecesSurviveAWrite();
Report.Section( "import: deleting a part takes one piece, not the whole file" );
TestDeletingOnePieceKeepsTheRest();
TestRemovedPieceSurvivesASave();
TestAnUnchangedSourceIsNotReadAgain();
}
/// <summary>
/// Several parts in, the same several parts out.
///
/// The failure this pins down had nothing to do with the reader: RemeshGen decimated fourteen
/// segmented parts perfectly and then merged them into one mesh to write the file, so what came
/// back was one lump and the segmentation — the reason for using that export at all — was gone.
/// OBJ numbers v/vt/vn across the whole FILE rather than per object, so a multi-object write is
/// exactly where offsets go wrong, and an off-by-one there reads back as parts wearing each
/// other's geometry rather than as a parse error.
/// </summary>
static void TestPiecesSurviveAWrite()
{
var pieces = ObjReader.ReadPieces( ThreeObjects );
var round = ObjReader.ReadPieces( ObjWriter.Write( pieces ) );
Report.Check( "a multi-object write comes back as the same number of parts",
round.Count == pieces.Count, $"{pieces.Count} in, {round.Count} out" );
for ( var i = 0; i < Math.Min( pieces.Count, round.Count ); i++ )
{
Report.Check( $"part {i} keeps its name",
round[i].Name == pieces[i].Name, $"'{pieces[i].Name}' -> '{round[i].Name}'" );
Report.Check( $"part {i} keeps its geometry",
round[i].Mesh.VertexCount == pieces[i].Mesh.VertexCount
&& round[i].Mesh.FaceCount == pieces[i].Mesh.FaceCount,
$"{pieces[i].Mesh.VertexCount}v/{pieces[i].Mesh.FaceCount}f -> "
+ $"{round[i].Mesh.VertexCount}v/{round[i].Mesh.FaceCount}f" );
// The positions themselves, not just the counts. Two parts of the same size are what a
// bad offset swaps, and counts alone would call that a pass.
var moved = 0;
for ( var v = 0; v < Math.Min( round[i].Mesh.VertexCount, pieces[i].Mesh.VertexCount ); v++ )
{
if ( (round[i].Mesh.Positions[v] - pieces[i].Mesh.Positions[v]).Length > 1e-4f )
moved++;
}
Report.Check( $"part {i} is where it was", moved == 0, $"{moved} vertices moved" );
}
}
/// <summary>Deleting one part of a multi-object import removes that piece and leaves the rest,
/// instead of the whole import going. The point of the whole split: an imported character's
/// brows are separate precisely so they can be thrown away one at a time.</summary>
static void TestDeletingOnePieceKeepsTheRest()
{
var studio = StudioFromObj( ThreeObjects, out var import );
studio.Rebuild();
Report.Check( "every piece of a three-piece import can be removed as a piece",
import.CanRemovePiece( studio.Bodies[0].Id )
&& import.CanRemovePiece( studio.Bodies[1].Id )
&& import.CanRemovePiece( studio.Bodies[2].Id ) );
import.RemovePiece( studio.Bodies[0].Id );
studio.MarkDirty( import );
studio.Rebuild();
Report.Check( "removing one piece leaves the other two",
studio.Bodies.Count == 2, $"{studio.Bodies.Count} bodies" );
Report.Check( "and the survivors keep their ids rather than shifting",
studio.Bodies[0].Id == import.Id + "b1" && studio.Bodies[1].Id == import.Id + "b2",
string.Join( " | ", studio.Bodies.Select( b => b.Id ) ) );
import.RemovePiece( studio.Bodies[0].Id );
studio.MarkDirty( import );
studio.Rebuild();
Report.Check( "the last remaining piece is no longer removable as a piece",
studio.Bodies.Count == 1 && !import.CanRemovePiece( studio.Bodies[0].Id ),
$"{studio.Bodies.Count} bodies" );
}
/// <summary>A deleted piece has to survive a save and load, or the part comes back the next
/// time the document is opened. The removal lives in the document; the OBJ sidecar keeps every
/// piece, so the deleted one is skipped on the way back in.</summary>
static void TestRemovedPieceSurvivesASave()
{
var dir = Path.Combine( Path.GetTempPath(), $"effigy-import-{Guid.NewGuid():N}" );
Directory.CreateDirectory( dir );
try
{
var path = Path.Combine( dir, "model" + StudioDocument.Extension );
var studio = StudioFromObj( ThreeObjects, out var import );
studio.Rebuild();
import.RemovePiece( studio.Bodies[1].Id );
studio.MarkDirty( import );
studio.Rebuild();
StudioDocument.WriteFile( studio, path );
ImportSidecar.Save( studio, path );
var back = StudioDocument.ReadFile( path );
ImportSidecar.Load( back, path );
back.Rebuild();
var reloaded = back.Features.OfType<ImportFeature>().Single();
Report.Check( "the removed piece is recorded in the document",
reloaded.RemovedPieces.Count == 1 && reloaded.RemovedPieces[0] == 1,
string.Join( ",", reloaded.RemovedPieces ) );
Report.Check( "and it stays gone after a save and load",
back.Bodies.Count == 2, $"{back.Bodies.Count} bodies" );
}
finally
{
Directory.Delete( dir, recursive: true );
}
}
/// <summary>Deleting a piece re-runs the import, and that must not read the file again. It used
/// to: every rebuild parsed the whole source, so trimming a 900k-face import cost 1.3s per delete
/// however little of it was left. Proven without a stopwatch — the file is swapped for a comment
/// of the same size and write time, which only a re-read could notice.</summary>
static void TestAnUnchangedSourceIsNotReadAgain()
{
var dir = Path.Combine( Path.GetTempPath(), $"effigy-import-{Guid.NewGuid():N}" );
Directory.CreateDirectory( dir );
try
{
var path = Path.Combine( dir, "model.obj" );
File.WriteAllText( path, ThreeObjects );
var studio = new PartStudio();
var import = studio.Add( new ImportFeature() );
import.BindSource( path );
studio.Rebuild();
var size = (int)new FileInfo( path ).Length;
var written = File.GetLastWriteTimeUtc( path );
File.WriteAllBytes( path, Enumerable.Repeat( (byte)'#', size ).ToArray() );
File.SetLastWriteTimeUtc( path, written );
import.RemovePiece( studio.Bodies[0].Id );
studio.MarkDirty( import );
var report = studio.Rebuild();
Report.Check( "deleting a piece publishes the pieces already parsed, not the file again",
!report.HasErrors && studio.Bodies.Count == 2, $"{studio.Bodies.Count} bodies; {report}" );
// Exporting over the same path changes its size and write time, and has to be picked up.
var box = Primitives.Box( 1f, 1f, 1f );
File.WriteAllText( path, ObjWriter.Write( new[]
{
new ObjReader.ObjPiece( "a", box ), new ObjReader.ObjPiece( "b", box ),
new ObjReader.ObjPiece( "c", box ), new ObjReader.ObjPiece( "d", box ),
} ) );
studio.MarkDirty( import );
report = studio.Rebuild();
Report.Check( "a source exported over the same path is read again",
!report.HasErrors && studio.Bodies.Count == 3, $"{studio.Bodies.Count} bodies; {report}" );
}
finally
{
Directory.Delete( dir, recursive: true );
}
}
/// <summary>Three objects in the file, three rows in the Parts list. The point of the whole
/// split: a character exported with its brows kept separate has to arrive separate, because a
/// welded body cannot be hidden, re-materialled or weighted a piece at a time.</summary>
static void TestObjectsBecomeSeparateBodies()
{
var studio = StudioFromObj( ThreeObjects, out _ );
var report = studio.Rebuild();
Report.Check( "rebuilds without errors", !report.HasErrors, report.ToString() );
Report.Check( "three objects become three bodies",
studio.Bodies.Count == 3, $"{studio.Bodies.Count} bodies" );
Report.Check( "each body carries the exporter's name, prefixed by the feature's",
studio.Bodies[0].Name == "Sculpt / head"
&& studio.Bodies[1].Name == "Sculpt / brow_l"
&& studio.Bodies[2].Name == "Sculpt / brow_r",
string.Join( " | ", studio.Bodies.Select( b => b.Name ) ) );
Report.Check( "and its own body id, so a selection can tell them apart",
studio.Bodies.Select( b => b.Id ).Distinct().Count() == 3 );
}
/// <summary>OBJ indices are global to the FILE. A piece that kept them would index off the end
/// of its own shorter vertex list, or into somebody else's geometry.</summary>
static void TestSplitRenumbersEachPiece()
{
var studio = StudioFromObj( ThreeObjects, out _ );
studio.Rebuild();
var brow = studio.Bodies[1].Mesh;
Report.Check( "a piece holds only the vertices its own faces use",
brow.VertexCount == 3, $"{brow.VertexCount} vertices" );
Report.Check( "its face indexes them locally",
brow.Faces[0].Indices.SequenceEqual( new[] { 0, 1, 2 } ),
string.Join( ",", brow.Faces[0].Indices ) );
Report.Check( "and the positions are the ones the file gave that object",
MathF.Abs( brow.Positions[0].x - 5f ) < 1e-5f, $"x={brow.Positions[0].x}" );
}
/// <summary>The common case must read exactly as it did before the split existed - one object,
/// or an exporter that wrote no marker at all, is still one row called what the feature is
/// called.</summary>
static void TestOneObjectKeepsTheFeatureName()
{
var studio = StudioFromObj( "o mesh_node\nv 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n", out _ );
studio.Rebuild();
Report.Check( "one object is one body", studio.Bodies.Count == 1, $"{studio.Bodies.Count} bodies" );
Report.Check( "named after the feature, not after the object",
studio.Bodies[0].Name == "Sculpt", studio.Bodies[0].Name );
var bare = StudioFromObj( "v 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n", out _ );
bare.Rebuild();
Report.Check( "a file with no object marker at all is still one body",
bare.Bodies.Count == 1 && bare.Bodies[0].Name == "Sculpt",
$"{bare.Bodies.Count} bodies" );
}
/// <summary>ObjReader.Read is what the writers' round-trip tests compare against, so the split
/// must not have changed what it returns.</summary>
static void TestFusedReadIsUnchanged()
{
var fused = ObjReader.Read( ThreeObjects );
Report.Check( "Read still fuses every object into one mesh",
fused.VertexCount == 10 && fused.FaceCount == 3,
$"{fused.VertexCount}v {fused.FaceCount}f" );
var pieces = ObjReader.ReadPieces( ThreeObjects );
Report.Check( "and ReadPieces accounts for exactly the same faces",
pieces.Sum( p => p.Mesh.FaceCount ) == fused.FaceCount,
$"{pieces.Sum( p => p.Mesh.FaceCount )} vs {fused.FaceCount}" );
}
/// <summary>A head and two brows, kept apart the way an exporter keeps them apart.</summary>
const string ThreeObjects =
"o head\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4\n" +
"o brow_l\nv 5 0 0\nv 6 0 0\nv 6 1 0\nf 5 6 7\n" +
"o brow_r\nv 8 0 0\nv 9 0 0\nv 9 1 0\nf 8 9 10\n";
static PartStudio StudioFromObj( string obj, out ImportFeature import )
{
var studio = new PartStudio();
import = studio.Add( new ImportFeature() );
import.Name = "Sculpt";
import.LoadMesh( Encoding.UTF8.GetBytes( obj ) );
return studio;
}
static void TestLoadsAnObj()
{
var box = Primitives.Box( 2f, 3f, 4f );
var studio = StudioFromMesh( box, out var import );
var report = studio.Rebuild();
Report.Check( "rebuilds without errors", !report.HasErrors, report.ToString() );
Report.Check( "produces one body", studio.Bodies.Count == 1, $"{studio.Bodies.Count} bodies" );
Report.Check( "with the OBJ's vertex count",
studio.Bodies[0].Mesh.VertexCount == box.VertexCount,
$"{box.VertexCount} -> {studio.Bodies[0].Mesh.VertexCount}" );
Report.Check( "and its face count",
studio.Bodies[0].Mesh.FaceCount == box.FaceCount,
$"{box.FaceCount} -> {studio.Bodies[0].Mesh.FaceCount}" );
Report.Check( "named after the feature",
studio.Bodies[0].Id == import.Id + "b0", studio.Bodies[0].Id );
}
static void TestPreservesQuadsAndUvs()
{
var box = Primitives.Box();
var studio = StudioFromMesh( box, out _ );
studio.Rebuild();
var mesh = studio.Bodies[0].Mesh;
var quads = mesh.Faces.Count( f => f.Count == 4 );
Report.Check( "a box stays quads, not a triangulation",
quads == box.FaceCount, $"{quads} quads of {mesh.FaceCount} faces" );
var src = box.Faces[0].UVs[0];
var dst = mesh.Faces[0].UVs[0];
Report.Check( "UVs survive the writer flip and the reader un-flip",
MathF.Abs( src.x - dst.x ) < 1e-4f && MathF.Abs( src.y - dst.y ) < 1e-4f,
$"({src.x},{src.y}) -> ({dst.x},{dst.y})" );
}
static void TestMissingSourceFailsUsefully()
{
var studio = new PartStudio();
var import = studio.Add( new ImportFeature() );
import.Source.Value = Path.Combine( Path.GetTempPath(), "effigy-no-such-mesh.obj" );
studio.Rebuild();
Report.Check( "a missing file fails rather than producing an empty body",
import.Error is not null && studio.Bodies.Count == 0, import.Error ?? "built anyway" );
var diagnostic = import.Diagnostic;
Report.Check( "and the message names the path, with a cause and a remedy",
diagnostic is not null
&& diagnostic.Cause.Contains( "effigy-no-such-mesh.obj" )
&& diagnostic.Remedies.Count > 0,
$"{diagnostic?.Cause} | remedies={diagnostic?.Remedies.Count ?? 0}" );
}
static void TestWrongFormatFailsUsefully()
{
var studio = new PartStudio();
var import = studio.Add( new ImportFeature() );
import.Source.Value = Path.Combine( Path.GetTempPath(), "sculpt.fbx" );
File.WriteAllText( import.Source.Value, "not an obj" );
try
{
studio.Rebuild();
}
finally
{
File.Delete( import.Source.Value );
}
Report.Check( "an FBX is refused by extension, not parsed",
import.Error is not null && import.Error.Contains( "OBJ" ), import.Error ?? "built anyway" );
Report.Check( "and the cause names FBX",
import.Diagnostic is not null && import.Diagnostic.Cause.Contains( ".fbx" ),
import.Diagnostic?.Cause ?? "" );
}
static void TestEmptyFileFailsUsefully()
{
var studio = new PartStudio();
var import = studio.Add( new ImportFeature() );
import.LoadMesh( Encoding.UTF8.GetBytes( "# empty\n" ) );
studio.Rebuild();
Report.Check( "an OBJ with no faces fails",
import.Error is not null && import.Error.Contains( "empty" ), import.Error ?? "built anyway" );
}
static void TestDocumentHoldsThePathNotTheMesh()
{
var box = Primitives.Box();
var studio = StudioFromMesh( box, out var import );
import.Source.Value = @"C:\sculpts\host.obj";
studio.Rebuild();
var text = StudioDocument.Write( studio );
Report.Check( "the document names the feature", text.Contains( "ImportFeature" ) );
Report.Check( "and carries the source path", text.Contains( @"C:\sculpts\host.obj" ) );
Report.Check( "and does not contain the vertex list",
!text.Contains( "v " ) && !ContainsVertexDump( text ),
"document looks like it grew a mesh" );
var back = StudioDocument.Read( text );
var reloaded = back.Features.OfType<ImportFeature>().Single();
Report.Check( "the path survives the round trip",
reloaded.Source.Value == import.Source.Value, reloaded.Source.Value );
}
static void TestSidecarCarriesTheMeshAcrossASaveAndLoad()
{
var dir = Path.Combine( Path.GetTempPath(), $"effigy-import-{Guid.NewGuid():N}" );
Directory.CreateDirectory( dir );
try
{
var path = Path.Combine( dir, "model" + StudioDocument.Extension );
var box = Primitives.Box( 2f, 3f, 4f );
var studio = StudioFromMesh( box, out var import );
studio.Rebuild();
var before = studio.Bodies[0].Mesh.Clone();
StudioDocument.WriteFile( studio, path );
var written = ImportSidecar.Save( studio, path );
Report.Check( "saving writes one OBJ beside the document",
written == 1 && File.Exists( ImportSidecar.PathFor( path, import.Id ) ),
$"wrote {written}" );
var back = StudioDocument.ReadFile( path );
var loaded = ImportSidecar.Load( back, path );
var report = back.Rebuild();
var reloaded = back.Features[0] as ImportFeature;
Report.Check( "loading hands the OBJ to the feature that owns it",
loaded == 1 && reloaded is not null );
Report.Check( "the reloaded document rebuilds without errors",
!report.HasErrors, report.ToString() );
Report.Check( "the mesh is on the model again, same vertex count",
back.Bodies.Count == 1 && back.Bodies[0].Mesh.VertexCount == before.VertexCount,
back.Bodies.Count == 0 ? "no body" : $"{back.Bodies[0].Mesh.VertexCount} verts" );
Report.Check( "and the same volume",
MathF.Abs( back.Bodies[0].Mesh.SignedVolume() - before.SignedVolume() ) < 1e-4f,
$"{before.SignedVolume()} -> {back.Bodies[0].Mesh.SignedVolume()}" );
}
finally
{
Directory.Delete( dir, recursive: true );
}
}
static void TestSavingDoesNotDeleteASidecarItDidNotWrite()
{
var dir = Path.Combine( Path.GetTempPath(), $"effigy-import-{Guid.NewGuid():N}" );
Directory.CreateDirectory( dir );
try
{
var path = Path.Combine( dir, "model" + StudioDocument.Extension );
var studio = StudioFromMesh( Primitives.Box(), out var import );
studio.Rebuild();
ImportSidecar.Save( studio, path );
var stray = Path.Combine( ImportSidecar.DirectoryFor( path ), "deadbeef.obj" );
File.WriteAllText( stray, "v 0 0 0\n" );
ImportSidecar.Save( studio, path );
Report.Check( "saving leaves an OBJ whose feature it does not know about", File.Exists( stray ) );
var pruned = ImportSidecar.Prune( studio, path );
Report.Check( "and pruning removes it only when asked",
pruned == 1 && !File.Exists( stray ) && File.Exists( ImportSidecar.PathFor( path, import.Id ) ),
$"pruned {pruned}" );
}
finally
{
Directory.Delete( dir, recursive: true );
}
}
static void TestPaintAndSkinSeeTheBody()
{
var studio = StudioFromMesh( Primitives.Box(), out var import );
studio.Rebuild();
var paint = studio.Add( new PaintFeature() );
paint.Bodies.BodyIds.Add( studio.Bodies[0].Id );
var painted = studio.Rebuild();
Report.Check( "a Paint on the imported body rebuilds",
painted.HasErrors == false && paint.Error is null,
paint.Error ?? painted.ToString() );
var skeleton = new Skeleton();
skeleton.AddBone( "root", -1, Xform.Identity, 1f );
var weights = SkinBinder.BindRigid( studio.Bodies[0].Mesh, skeleton );
Report.Check( "SkinBinder binds every imported vertex",
weights.Count == studio.Bodies[0].Mesh.VertexCount, $"{weights.Count} weights" );
Report.Check( "and the partition of unity holds",
Enumerable.Range( 0, weights.Count ).All( i =>
{
var sum = 0f;
foreach ( var w in weights[i] ) sum += w.Weight;
return MathF.Abs( sum - 1f ) < 1e-4f;
} ) );
}
static PartStudio StudioFromMesh( PolyMesh mesh, out ImportFeature import )
{
var studio = new PartStudio();
import = studio.Add( new ImportFeature() );
import.Name = "Sculpt";
import.LoadMesh( Encoding.UTF8.GetBytes( ObjWriter.Write( mesh, "sculpt" ) ) );
return studio;
}
/// <summary>A document that serialised the mesh would grow a vertex-per-line dump. The path
/// line is allowed to contain a letter v.</summary>
static bool ContainsVertexDump( string text )
{
foreach ( var line in text.Split( '\n' ) )
{
var t = line.Trim();
if ( t.StartsWith( "v ", StringComparison.Ordinal ) )
return true;
}
return false;
}
}