Effigy.Tests/VmdlDocumentTests.cs

Unit test file for VmdlDocument behavior. It contains several tests that verify import rotation for OBJ/DMX/SMD, structure of generated .vmdl documents (LOD groups, render meshes, physics mesh inclusion), and that skinned models carry bind poses and animation clips.

File Access
using System;
using Effigy;
using static Effigy.Tests.Report;

namespace Effigy.Tests;

/// <summary>
/// The one thing this file exists to hold: an OBJ-backed .vmdl carries the import_rotation
/// correction and a DMX-backed one does not.
///
/// THIS WAS A REAL MODEL THAT COMPILED LYING ON ITS SIDE. The correction was measured once and
/// written into EffigyWindow.BuildVmdl, and then the same template was copied into
/// BuildSkinnedVmdl, TreeGen and TentacleGen without it — three silent copies, because a literal
/// string cannot tell you which mesh format it is about. Deriving the rotation from the filename
/// is what makes the mistake unrepresentable; this is what keeps it that way.
/// </summary>
public static class VmdlDocumentTests
{
	public static void Run()
	{
		Section( "vmdl document: an OBJ is turned, a DMX is not" );
		TestObjGetsTheCorrection();
		TestDmxAndSmdDoNot();

		Section( "vmdl document: the nodes each kind of model needs" );
		TestStaticHasOneMeshFile();
		TestSkinnedCarriesTheBindPose();
		TestStaticWithLods();
	}

	static void TestStaticWithLods()
	{
		var vmdl = VmdlDocument.StaticWithLods(
			new[] { "models/effigy/rock.obj", "models/effigy/rock_lod1.obj", "models/effigy/rock_lod2.obj" },
			new[] { 0f, 300f, 750f } );

		Check( "a model with LODs has one render mesh per level", CountOf( vmdl, "_class = \"RenderMeshFile\"" ) == 3 );
		Check( "named Body_LOD0..2", vmdl.Contains( "name = \"Body_LOD0\"" ) && vmdl.Contains( "name = \"Body_LOD1\"" ) && vmdl.Contains( "name = \"Body_LOD2\"" ) );
		Check( "and one LOD group per level", vmdl.Contains( "_class = \"LODGroupList\"" ) && CountOf( vmdl, "_class = \"LODGroup\"" ) == 3 );
		Check( "the first group switches at zero and the rest at their distances",
			vmdl.Contains( "switch_threshold = 0.0\n" ) && vmdl.Contains( "switch_threshold = 300.0\n" ) && vmdl.Contains( "switch_threshold = 750.0\n" ) );
		Check( "each group names its mesh", vmdl.Contains( "\"Body_LOD2\"," ) );
		Check( "every level gets the OBJ correction", CountOf( vmdl, "import_rotation = [ -90.0, -90.0, 0.0 ]" ) == 3 );

		var threw = false;
		try { VmdlDocument.StaticWithLods( new[] { "a.obj", "b.obj" }, new[] { 0f } ); } catch ( ArgumentException ) { threw = true; }
		Check( "a distance count that does not match the meshes is refused", threw );
	}

	static void TestObjGetsTheCorrection()
	{
		Check( "an OBJ needs the -90/-90 inverse of ModelDoc's axis permutation",
			VmdlDocument.ImportRotation( "models/effigy/x.obj" ) == "-90.0, -90.0, 0.0" );

		// Case does not decide it. An exporter that wrote .OBJ would otherwise land on its side.
		Check( "the extension test is case insensitive",
			VmdlDocument.ImportRotation( "X.OBJ" ) == "-90.0, -90.0, 0.0" );

		var vmdl = VmdlDocument.Static( "models/effigy/remesh_all.obj" );

		Check( "a static OBJ model writes the corrected rotation",
			vmdl.Contains( "import_rotation = [ -90.0, -90.0, 0.0 ]" ) );
	}

	static void TestDmxAndSmdDoNot()
	{
		// Only the OBJ importer turns the mesh. Applying the correction to a DMX would break the
		// rigged path, which is the export that actually ships.
		Check( "the DMX importer lands the mesh where the file says",
			VmdlDocument.ImportRotation( "models/effigy/x.dmx" ) == "0.0, 0.0, 0.0" );

		Check( "nor does an SMD get turned",
			VmdlDocument.ImportRotation( "models/effigy/x.smd" ) == "0.0, 0.0, 0.0" );

		var skinned = VmdlDocument.Skinned( "models/effigy/x.dmx", new Skeleton() );

		Check( "a skinned DMX model leaves the rotation alone",
			skinned.Contains( "import_rotation = [ 0.0, 0.0, 0.0 ]" ) );
	}

	static void TestStaticHasOneMeshFile()
	{
		var vmdl = VmdlDocument.Static( "models/effigy/box.obj", VmdlPhysics.MeshFromRender() );

		Check( "the document has a root node", vmdl.Contains( "_class = \"RootNode\"" ) );
		Check( "it names the mesh", vmdl.Contains( "filename = \"models/effigy/box.obj\"" ) );

		Check( "exactly one render mesh, not a duplicated node",
			CountOf( vmdl, "_class = \"RenderMeshFile\"" ) == 1 );

		Check( "the physics the caller passed is in it", vmdl.Contains( "PhysicsMeshFromRender" ) );

		// A static model has no skeleton, so no bone markup: those nodes are the rigged path's
		// business and describe bones a static mesh does not have.
		Check( "no bone markup on a static model", !vmdl.Contains( "BoneMarkup" ) );
	}

	static void TestSkinnedCarriesTheBindPose()
	{
		var skinned = VmdlDocument.Skinned( "models/effigy/x.dmx", new Skeleton() );

		Check( "a rigged model carries the bind pose, or morph targets and IK break quietly",
			skinned.Contains( "AnimationList" ) || skinned.Contains( "BindPose" ) );

		// The caller's own AnimationList replaces the bind-pose-only default rather than joining it.
		var withClips = VmdlDocument.Skinned( "models/effigy/x.dmx", new Skeleton(),
			animations: "\t\t\tCLIPS\n" );

		Check( "clips reach the document", withClips.Contains( "CLIPS" ) );
		Check( "and are not written twice", CountOf( withClips, "CLIPS" ) == 1 );
	}

	static int CountOf( string haystack, string needle )
	{
		var n = 0;

		for ( var i = haystack.IndexOf( needle, StringComparison.Ordinal ); i >= 0;
			i = haystack.IndexOf( needle, i + needle.Length, StringComparison.Ordinal ) )
			n++;

		return n;
	}
}