Unit test class for VMDL physics serialization and CollisionBuilder integration. It constructs various CollisionShape inputs, calls VmdlPhysics.ShapeList and related builders, and asserts KV3 text output formatting, placement, numbering, bone behavior, and writes a sample .obj/.vmdl for manual compile checks.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.IO;
using System.Threading;
using Effigy;
using static Effigy.Tests.Report;
namespace Effigy.Tests;
/// <summary>
/// The PhysicsShapeList node, which is the step that had been missing since CollisionBuilder was
/// written: the shapes were right and the .vmdl carried none of them.
///
/// WHAT THESE CAN AND CANNOT CHECK. Whether the ENGINE reads a key is not decidable here, and it was
/// not guessed either — every key was probed by writing a .vmdl, compiling it, and reading the
/// compiled model's physics bounds back (VmdlPhysics' own comment records what each probe answered).
/// What is left for a headless test is everything downstream of that: the right key for the right
/// shape, the right NUMBER against it, and text a KV3 parser can actually read.
///
/// The number is not a formality. `dimensions` is a full size and CollisionShape.Size is a
/// half-extent, so the one line that doubles it is the difference between a part's collision and
/// half of it — and a wrong one compiles, loads, and is only ever noticed by someone walking
/// through a wall.
/// </summary>
public static class VmdlPhysicsTests
{
/// <summary>
/// A studio whose collision has a known, easily-measured answer: a 4-cube at the origin and a
/// 2-cube out at x = 10, which together span 13 x 4 x 4.
///
/// Shared by the headless checks and by the sample file, so the number the compiler is asked to
/// agree with is the same number the tests assert.
/// </summary>
internal static PartStudio TwoBoxes()
{
var studio = new PartStudio();
var a = studio.Add( new PrimitiveFeature() );
a.SizeX.Value = 4f;
a.SizeY.Value = 4f;
a.SizeZ.Value = 4f;
var b = studio.Add( new PrimitiveFeature() );
b.SizeX.Value = 2f;
b.SizeY.Value = 2f;
b.SizeZ.Value = 2f;
b.Position.Value = new Vec3( 10, 0, 0 );
studio.Rebuild();
return studio;
}
public static void Run()
{
Section( "vmdl physics: each shape's own keys and numbers" );
TestBox();
TestSphere();
TestCylinder();
TestHull();
Section( "vmdl physics: the bone a shape rides on" );
TestBoneOnAHull();
TestNoBoneIsTheRoot();
TestBonedPrimitiveBecomesAHull();
Section( "vmdl physics: the node, and what it does with nothing" );
TestNodeShape();
TestNothingIsNothing();
Section( "vmdl physics: numbers a KV3 parser can read" );
TestNumberFormatting();
Section( "vmdl physics: a studio's collision, end to end" );
TestFromAStudio();
}
static void TestBox()
{
var text = VmdlPhysics.ShapeList( new[]
{
new CollisionShape
{
Kind = CollisionKind.Box,
Position = new Vec3( 1, 2, 3 ),
Size = new Vec3( 2, 3, 4 ),
}
} );
Check( "a box is a PhysicsShapeBox", text.Contains( "_class = \"PhysicsShapeBox\"" ) );
// THE DOUBLING. Size is half-extents, dimensions is the full size. Halved collision compiles
// and loads and is invisible until something falls through it.
Check( "dimensions is the FULL size, not the half-extents",
text.Contains( "dimensions = [ 4.0, 6.0, 8.0 ]" ), Line( text, "dimensions" ) );
// `origin`, and not the three keys that compile and are ignored.
Check( "and it is placed by origin", text.Contains( "origin = [ 1.0, 2.0, 3.0 ]" ),
Line( text, "origin" ) );
Check( "not by center, translation or position, which the engine ignores on a box",
!text.Contains( "center =" ) && !text.Contains( "translation =" ) && !text.Contains( "position =" ) );
}
static void TestSphere()
{
var text = VmdlPhysics.ShapeList( new[]
{
new CollisionShape
{
Kind = CollisionKind.Sphere,
Position = new Vec3( 0, 0, 5 ),
Size = new Vec3( 1.5f, 0, 0 ),
}
} );
Check( "a sphere is a PhysicsShapeSphere", text.Contains( "_class = \"PhysicsShapeSphere\"" ) );
Check( "with its radius", text.Contains( "radius = 1.5" ), Line( text, "radius" ) );
// The one shape whose placement key is NOT the box's. Probed both ways round.
Check( "and placed by center, which is the sphere's key and not the box's",
text.Contains( "center = [ 0.0, 0.0, 5.0 ]" ), Line( text, "center" ) );
Check( "and not by origin", !text.Contains( "origin =" ) );
}
static void TestCylinder()
{
var text = VmdlPhysics.ShapeList( new[]
{
new CollisionShape
{
Kind = CollisionKind.Cylinder,
Position = new Vec3( 0, 0, 10 ),
Size = new Vec3( 2f, 0f, 4f ),
}
} );
Check( "a cylinder is a PhysicsShapeCylinder", text.Contains( "_class = \"PhysicsShapeCylinder\"" ) );
Check( "with its radius", text.Contains( "radius = 2.0" ), Line( text, "radius" ) );
// Size.z is the HALF height, so the two points sit one half-height either side of the centre.
// Using the whole height for each would double the cylinder without changing anything a
// compile could complain about.
Check( "and its ends one half-height either side of the centre",
text.Contains( "point0 = [ 0.0, 0.0, 6.0 ]" ) && text.Contains( "point1 = [ 0.0, 0.0, 14.0 ]" ),
$"{Line( text, "point0" )} / {Line( text, "point1" )}" );
}
static void TestHull()
{
var cube = new List<Vec3>();
foreach ( var x in new[] { -1f, 1f } )
foreach ( var y in new[] { -1f, 1f } )
foreach ( var z in new[] { -1f, 1f } )
cube.Add( new Vec3( x, y, z ) );
var text = VmdlPhysics.ShapeList( new[]
{
new CollisionShape { Kind = CollisionKind.Hull, Points = cube }
} );
Check( "a hull is a PhysicsShapeHull", text.Contains( "_class = \"PhysicsShapeHull\"" ) );
Check( "carrying every one of its points", CountOf( text, "[ " ) >= 8,
$"{CountOf( text, "[ " )} vectors" );
Check( "under hull_vertices", text.Contains( "hull_vertices = " ) );
// The points go in as they are. The probe that settled this wrote a cube offset along x and
// measured 20 across, so nothing is being re-centred underneath.
Check( "in model space, not re-centred", text.Contains( "[ -1.0, -1.0, -1.0 ]" )
&& text.Contains( "[ 1.0, 1.0, 1.0 ]" ) );
// A "hull" of three points is a triangle, which is not a solid. Better nothing than a
// degenerate shape the physics engine has to decide what to do with.
var flat = VmdlPhysics.ShapeList( new[]
{
new CollisionShape
{
Kind = CollisionKind.Hull,
Points = new List<Vec3> { Vec3.Zero, new( 1, 0, 0 ), new( 0, 1, 0 ) },
}
} );
Check( "and a hull of fewer than four points is dropped rather than written",
flat.Length == 0, flat );
}
/// <summary>
/// A hull's points do NOT move when it is given a bone.
///
/// This is the whole reason the bone-parented path emits hulls and nothing else. The probe
/// VmdlPhysics records found that hull_vertices stay MODEL space while a primitive's origin is
/// read in the bone's own space — so a hull can name a bone without its numbers changing, and
/// this pins that: same points, bone or no bone.
/// </summary>
static void TestBoneOnAHull()
{
var points = new List<Vec3>
{
new( 0, 0, 0 ), new( 4, 0, 0 ), new( 0, 4, 0 ), new( 0, 0, 4 ),
};
var free = VmdlPhysics.ShapeList( new[]
{
new CollisionShape { Kind = CollisionKind.Hull, Points = points }
} );
var boned = VmdlPhysics.ShapeList( new[]
{
new CollisionShape { Kind = CollisionKind.Hull, Points = points, Bone = "upper_arm_L" }
} );
Check( "a hull's bone reaches parent_bone",
boned.Contains( "parent_bone = \"upper_arm_L\"" ), Line( boned, "parent_bone" ) );
Check( "and its vertices are the same ones it had without a bone - model space either way",
free.Replace( "parent_bone = \"\"", "parent_bone = \"upper_arm_L\"" ) == boned );
}
/// <summary>
/// No bone still means the root, which is what a static part wants and what every earlier test
/// in this file was written against.
/// </summary>
static void TestNoBoneIsTheRoot()
{
var text = VmdlPhysics.ShapeList( new[]
{
new CollisionShape { Kind = CollisionKind.Box, Size = new Vec3( 1, 1, 1 ) }
} );
Check( "a shape with no bone is parented to the root",
text.Contains( "parent_bone = \"\"" ), Line( text, "parent_bone" ) );
}
/// <summary>
/// A primitive that is given a bone comes out as a hull of its corners.
///
/// NOT PEDANTRY. A PhysicsShapeBox's `origin` is read in the parent bone's LOCAL space, rotation
/// included, and a CollisionShape has no rotation to describe that with — so a box written
/// against a bone lands wherever that bone's bind pose happens to put it. The corners are points
/// and points are model space, so the hull says the same thing in a space both families agree on.
/// </summary>
static void TestBonedPrimitiveBecomesAHull()
{
var text = VmdlPhysics.ShapeList( new[]
{
new CollisionShape
{
Kind = CollisionKind.Box,
Position = new Vec3( 10, 0, 0 ),
Size = new Vec3( 2, 3, 4 ),
Bone = "spine",
}
} );
Check( "a bone-parented box is written as a hull, not a PhysicsShapeBox",
text.Contains( "_class = \"PhysicsShapeHull\"" ) && !text.Contains( "PhysicsShapeBox" ) );
Check( "still on its bone", text.Contains( "parent_bone = \"spine\"" ) );
// The eight corners of 10±2, 0±3, 0±4 - the same solid, said with points.
Check( "and the corners are the box's own, in model space",
text.Contains( "[ 8.0, -3.0, -4.0 ]" ) && text.Contains( "[ 12.0, 3.0, 4.0 ]" ), text );
}
static void TestNodeShape()
{
var text = VmdlPhysics.ShapeList( new[]
{
new CollisionShape { Kind = CollisionKind.Box, Size = new Vec3( 1, 1, 1 ) },
new CollisionShape { Kind = CollisionKind.Box, Position = new Vec3( 4, 0, 0 ), Size = new Vec3( 1, 1, 1 ) },
} );
Check( "one PhysicsShapeList holds them all", CountOf( text, "PhysicsShapeList" ) == 1,
$"{CountOf( text, "PhysicsShapeList" )}" );
Check( "with a child per shape", CountOf( text, "_class = \"PhysicsShapeBox\"" ) == 2 );
// KV3 IS BRACE-SENSITIVE AND THE COMPILER'S MESSAGE FOR AN UNBALANCED ONE IS NOT KIND. This
// is the same reason DmxGrammarTests parses its output rather than searching it.
Check( "braces balance", CountOf( text, "{" ) == CountOf( text, "}" ),
$"{CountOf( text, "{" )} open, {CountOf( text, "}" )} close" );
Check( "and brackets balance", CountOf( text, "[" ) == CountOf( text, "]" ),
$"{CountOf( text, "[" )} open, {CountOf( text, "]" )} close" );
// It is spliced between a RootNode's other children, so it has to end the way they do.
Check( "the node is a complete child entry, comma and all",
text.TrimEnd( '\n' ).EndsWith( "}," ), text[^8..] );
Check( "every shape names a surface and a collision tag",
CountOf( text, "surface_prop" ) == 2 && CountOf( text, "collision_tags" ) == 2 );
}
static void TestNothingIsNothing()
{
// EMPTY, NOT AN EMPTY LIST. A PhysicsShapeList with no children is a model that says it has
// collision and has none, which reads as a physics bug rather than as a missing step.
Check( "no shapes writes no node", VmdlPhysics.ShapeList( new CollisionShape[0] ).Length == 0 );
Check( "and neither does null", VmdlPhysics.ShapeList( null ).Length == 0 );
var fallback = VmdlPhysics.MeshFromRender();
Check( "the fallback node is the one this project already ships",
fallback.Contains( "_class = \"PhysicsMeshFromRender\"" )
&& fallback.Contains( "_class = \"PhysicsShapeList\"" ) );
}
/// <summary>
/// The two ways a number can be written that KV3 will not read: with a comma for a decimal
/// point, and in exponent form. Both are what .NET does by default under the right conditions,
/// and neither fails loudly.
/// </summary>
static void TestNumberFormatting()
{
var text = VmdlPhysics.ShapeList( new[]
{
new CollisionShape
{
Kind = CollisionKind.Box,
Position = new Vec3( 0.0000015f, -0f, 12345.75f ),
Size = new Vec3( 0.5f, 1f, 2f ),
}
} );
Check( "no exponent notation anywhere", !text.Contains( "E" ) && !text.Contains( "e+" ),
Line( text, "origin" ) );
Check( "every number has a decimal point", text.Contains( "12345.75" ) && text.Contains( "1.0" ) );
Check( "and negative zero is written as zero", !text.Contains( "-0.0" ), Line( text, "origin" ) );
// A machine with a comma decimal separator writes [ 1,5, 0,0, 0,0 ], which a KV3 parser reads
// as six integers. Nothing about that fails at the point it happens.
var culture = Thread.CurrentThread.CurrentCulture;
try
{
Thread.CurrentThread.CurrentCulture = new CultureInfo( "de-DE" );
var german = VmdlPhysics.ShapeList( new[]
{
new CollisionShape { Kind = CollisionKind.Box, Size = new Vec3( 0.75f, 1f, 1f ) }
} );
Check( "and a comma-decimal machine still writes a dot",
german.Contains( "1.5" ) && !german.Contains( "1,5" ), Line( german, "dimensions" ) );
}
finally
{
Thread.CurrentThread.CurrentCulture = culture;
}
}
/// <summary>
/// The whole path: a studio, its collision, its node. Two boxes drawn apart come out as two
/// PhysicsShapeBoxes in the right places and at the right size — which is the claim
/// CollisionBuilder has been making since it was written and could not previously deliver on.
/// </summary>
static void TestFromAStudio()
{
var studio = TwoBoxes();
var report = CollisionBuilder.Build( studio );
Check( "the studio's collision is read from the history", report.FromHistory, report.Reason );
var text = VmdlPhysics.ShapeList( report.Shapes );
Check( "and comes out as two boxes", CountOf( text, "_class = \"PhysicsShapeBox\"" ) == 2,
$"{CountOf( text, "_class = \"PhysicsShapeBox\"" )}" );
Check( "the first at the origin, four across",
text.Contains( "dimensions = [ 4.0, 4.0, 4.0 ]" ) && text.Contains( "origin = [ 0.0, 0.0, 0.0 ]" ) );
Check( "the second out at x = 10, two across",
text.Contains( "dimensions = [ 2.0, 2.0, 2.0 ]" ) && text.Contains( "origin = [ 10.0, 0.0, 0.0 ]" ),
Line( text, "origin" ) );
// And the fallback path still produces something writable: a subdivide spoils the history,
// so this becomes hulls, and hulls still have to make a node.
studio.Add( new SubdivideFeature() ).Levels.Value = 1;
studio.Rebuild();
var hulled = CollisionBuilder.Build( studio );
var hullText = VmdlPhysics.ShapeList( hulled.Shapes );
Check( "a spoiled history still writes shapes, as hulls", !hulled.FromHistory
&& hullText.Contains( "_class = \"PhysicsShapeHull\"" ), hulled.ToString() );
}
/// <summary>
/// A complete .vmdl carrying this kernel's own collision output, written for the same reason the
/// sample DMX and the sample normal map are: the verdict that matters is somewhere else.
///
/// The checks above prove the text says what it should. Only the engine can say whether the
/// engine agrees, and the way to ask is:
///
/// copy out/sample_physics.{obj,vmdl} into Assets/models/effigy_probe/
/// register_external_assets, asset_compile, then kit_validate that folder
///
/// TwoBoxes spans 13 x 4 x 4 — a 4-cube at the origin and a 2-cube whose far face is at x = 11 —
/// so the answer is one number and it is not a matter of opinion. BOTH bounds have to read it:
/// PhysicsBounds alone says the shapes are right, and Bounds alone says the mesh is where it was
/// drawn, and the pair of them agreeing is the only thing that says the collision is on the
/// model rather than beside it. Run on 2026-08-31 it gave 13 x 4 x 4 for both.
///
/// The failures each look like something specific. Halved boxes read 11 x 4 x 4 (a `dimensions`
/// taken as half-extents). Shapes stacked at the origin read 4 x 4 x 4 (a placement key the
/// engine ignores). And 13 x 13 x 4 is the mesh and the shapes at ninety degrees to each other,
/// which is what the import_rotation below is there to stop.
/// </summary>
internal static void WriteSample( string outDir )
{
var studio = TwoBoxes();
var report = CollisionBuilder.Build( studio );
var physics = VmdlPhysics.ShapeList( report.Shapes );
ObjWriter.WriteFile( studio.ToMesh(), Path.Combine( outDir, "sample_physics.obj" ), "sample_physics" );
var vmdl =
"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc29:version{3cec427c-1b0e-4d48-a90a-0436f33a6041} -->\n"
+ "{\n\trootNode = \n\t{\n\t\t_class = \"RootNode\"\n\t\tchildren = \n\t\t[\n"
+ "\t\t\t{\n\t\t\t\t_class = \"RenderMeshList\"\n\t\t\t\tchildren = \n\t\t\t\t[\n"
+ "\t\t\t\t\t{\n\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\tname = \"Body_LOD0\"\n"
+ "\t\t\t\t\t\tchildren = \n\t\t\t\t\t\t[\n\t\t\t\t\t\t]\n"
+ "\t\t\t\t\t\tfilename = \"models/effigy_probe/sample_physics.obj\"\n"
+ "\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]\n"
// The -90 yaw the editor's own BuildVmdl writes, and for the reason given there:
// ModelDoc's OBJ importer turns the mesh a quarter turn, and the physics shapes are in
// the file's own coordinates. Without it this sample compiles with its collision at
// ninety degrees to its mesh, and the bounds read 13 x 13 x 4 instead of 13 x 4 x 4.
+ "\t\t\t\t\t\timport_rotation = [ 0.0, -90.0, 0.0 ]\n"
+ "\t\t\t\t\t\timport_scale = 1.0\n"
+ "\t\t\t\t\t\talign_origin_x_type = \"None\"\n"
+ "\t\t\t\t\t\talign_origin_y_type = \"None\"\n"
+ "\t\t\t\t\t\talign_origin_z_type = \"None\"\n"
+ "\t\t\t\t\t\tparent_bone = \"\"\n\t\t\t\t\t},\n\t\t\t\t]\n\t\t\t},\n"
+ physics
+ "\t\t]\n\t\tmodel_archetype = \"\"\n\t\tprimary_associated_entity = \"\"\n"
+ "\t\tanim_graph_name = \"\"\n\t\tbase_model_name = \"\"\n\t}\n}\n";
File.WriteAllText( Path.Combine( outDir, "sample_physics.vmdl" ), vmdl );
Check( $"wrote {outDir}/sample_physics.vmdl - compile it and its physics bounds should read 13 x 4 x 4",
physics.Length > 0 && report.FromHistory, report.ToString() );
}
// --- helpers ----------------------------------------------------------------------------------
static int CountOf( string text, string needle )
{
var count = 0;
var at = 0;
while ( (at = text.IndexOf( needle, at, StringComparison.Ordinal )) >= 0 )
{
count++;
at += needle.Length;
}
return count;
}
/// <summary>The first line naming a key, so a failure prints what was actually written.</summary>
static string Line( string text, string key )
{
foreach ( var line in text.Split( '\n' ) )
{
if ( line.Contains( key + " =", StringComparison.Ordinal ) )
return line.Trim();
}
return $"no '{key}' line";
}
}