Editor/Effigy/VmdlPhysics.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
namespace Effigy;
/// <summary>
/// CollisionBuilder's shapes, written as the PhysicsShapeList node a .vmdl carries them in.
///
/// WHAT THIS CLOSES. The shapes have been correct and tested for a while and had nowhere to go: the
/// editor could only report them to the console, because putting them in the .vmdl means writing
/// ModelDoc's KV3 and nothing here had seen that schema. A guessed node was the wrong risk to take
/// — the failure is a model that stops loading, not a model without collision.
///
/// EVERY KEY BELOW WAS MEASURED, NOT GUESSED. Each shape was written into a probe .vmdl, compiled by
/// the engine, and the compiled model's own physics bounds read back. That is what settles the
/// questions no amount of reading could:
///
/// - `PhysicsShapeBox.dimensions` is the box's FULL size, not its half-extents. A 32 wrote 32 across.
/// - `PhysicsShapeBox` is placed by `origin` — NOT by `center`, `translation` or `position`, all
/// three of which compile happily and leave the box at the model origin. `angles` works on it too,
/// and is written for completeness even though nothing reaches this with a rotated box: a rotated
/// Transform spoils the whole decomposition upstream (see CollisionBuilder).
/// - `PhysicsShapeSphere` is placed by `center`, and by nothing else. It is the one shape whose
/// placement key is not the box's.
/// - `PhysicsShapeCapsule` and `PhysicsShapeCylinder` carry `point0`/`point1` plus a `radius`, and
/// the points are where they go — the shape has no separate placement key and needs none. Read off
/// citizen_physicsshapelist.vmdl_prefab, which ships as source, and confirmed the same way.
/// - `PhysicsShapeHull.hull_vertices` takes points in MODEL SPACE and is exact: a hull written from
/// a 20-unit cube offset along x measured 20 across, so nothing is being re-centred underneath.
///
/// Base keys `parent_bone`, `surface_prop` and `collision_tags` come off the same citizen prefab.
///
/// WHY THE KERNEL AND NOT THE EDITOR. It is text, it has no engine types in it, and it is the half
/// that can be checked without s&box — which is the half that was worth testing. The editor's job is
/// reduced to splicing the node into the document it already builds.
/// </summary>
public static class VmdlPhysics
{
/// <summary>
/// The whole PhysicsShapeList node, indented to sit among a RootNode's children, or an empty
/// string when there is nothing to write.
///
/// EMPTY RATHER THAN AN EMPTY LIST. A PhysicsShapeList with no children is a model that declares
/// it has collision and has none, which is worse than a model that says nothing: the first reads
/// as a physics bug and the second reads as a missing step.
/// </summary>
public static string ShapeList( IReadOnlyList<CollisionShape> shapes,
string surfaceProp = "default", string collisionTags = "solid" )
{
if ( shapes is null || shapes.Count == 0 )
return "";
var body = new StringBuilder();
var written = 0;
foreach ( var shape in shapes )
{
var node = Shape( shape, surfaceProp, collisionTags );
if ( node is null )
continue;
body.Append( node );
written++;
}
if ( written == 0 )
return "";
var sb = new StringBuilder();
sb.Append( "\t\t\t{\n" );
sb.Append( "\t\t\t\t_class = \"PhysicsShapeList\"\n" );
sb.Append( "\t\t\t\tchildren = \n" );
sb.Append( "\t\t\t\t[\n" );
sb.Append( body );
sb.Append( "\t\t\t\t]\n" );
sb.Append( "\t\t\t},\n" );
return sb.ToString();
}
/// <summary>
/// The node this repo already ships on every hand-authored model: build the physics from the
/// render geometry.
///
/// Kept beside the exact shapes because it is the honest answer for a part whose history cannot
/// be read AND whose hulls would be a poor likeness — a tube, say, whose bore a hull fills in.
/// It is what Assets/models/tamagotchi and Assets/models/lightswitch use, so it is proven in this
/// project rather than merely proven in general.
/// </summary>
public static string MeshFromRender( string surfaceProp = "default", string collisionTags = "solid" ) =>
"\t\t\t{\n"
+ "\t\t\t\t_class = \"PhysicsShapeList\"\n"
+ "\t\t\t\tchildren = \n"
+ "\t\t\t\t[\n"
+ "\t\t\t\t\t{\n"
+ "\t\t\t\t\t\t_class = \"PhysicsMeshFromRender\"\n"
+ "\t\t\t\t\t\tparent_bone = \"\"\n"
+ $"\t\t\t\t\t\tsurface_prop = \"{surfaceProp}\"\n"
+ $"\t\t\t\t\t\tcollision_tags = \"{collisionTags}\"\n"
+ "\t\t\t\t\t},\n"
+ "\t\t\t\t]\n"
+ "\t\t\t},\n";
/// <summary>One shape, or null for one this cannot describe.</summary>
static string Shape( CollisionShape shape, string surfaceProp, string collisionTags )
{
if ( shape is null )
return null;
var sb = new StringBuilder();
switch ( shape.Kind )
{
case CollisionKind.Box:
Open( sb, "PhysicsShapeBox" );
// Doubled: CollisionShape.Size is half-extents and `dimensions` is the full size.
// Getting this backwards halves every collision box in the model and nothing about
// the compile complains.
Line( sb, "dimensions", Vector( shape.Size * 2f ) );
Line( sb, "origin", Vector( shape.Position ) );
break;
case CollisionKind.Sphere:
Open( sb, "PhysicsShapeSphere" );
Line( sb, "radius", Number( shape.Size.x ) );
// `center`, and only `center`. See the class comment.
Line( sb, "center", Vector( shape.Position ) );
break;
case CollisionKind.Cylinder:
// Size.z is the half-height, and the axis is z - CollisionBuilder cannot produce a
// cylinder on any other axis, because a rotated Transform spoils the decomposition
// before it gets here.
Open( sb, "PhysicsShapeCylinder" );
Line( sb, "radius", Number( shape.Size.x ) );
Line( sb, "point0", Vector( shape.Position - new Vec3( 0, 0, shape.Size.z ) ) );
Line( sb, "point1", Vector( shape.Position + new Vec3( 0, 0, shape.Size.z ) ) );
break;
case CollisionKind.Hull:
if ( shape.Points is not { Count: >= 4 } )
return null;
Open( sb, "PhysicsShapeHull" );
sb.Append( "\t\t\t\t\t\thull_vertices = \n" );
sb.Append( "\t\t\t\t\t\t[\n" );
foreach ( var p in shape.Points )
sb.Append( $"\t\t\t\t\t\t\t{Vector( p )},\n" );
sb.Append( "\t\t\t\t\t\t]\n" );
break;
default:
return null;
}
sb.Append( "\t\t\t\t\t},\n" );
return sb.ToString();
void Open( StringBuilder into, string cls )
{
into.Append( "\t\t\t\t\t{\n" );
into.Append( $"\t\t\t\t\t\t_class = \"{cls}\"\n" );
into.Append( "\t\t\t\t\t\tparent_bone = \"\"\n" );
into.Append( $"\t\t\t\t\t\tsurface_prop = \"{surfaceProp}\"\n" );
into.Append( $"\t\t\t\t\t\tcollision_tags = \"{collisionTags}\"\n" );
}
static void Line( StringBuilder into, string key, string value ) =>
into.Append( $"\t\t\t\t\t\t{key} = {value}\n" );
}
static string Vector( Vec3 v ) => $"[ {Number( v.x )}, {Number( v.y )}, {Number( v.z )} ]";
/// <summary>
/// A KV3 float, always with a decimal point and never with an exponent.
///
/// InvariantCulture is not a nicety here: a machine set to a comma decimal separator would write
/// `[ 1,5, 0,0, 0,0 ]`, which is a six-element array of integers as far as the parser is
/// concerned, and the compile would either fail or silently take the wrong numbers. And "R" or a
/// plain ToString can produce `1E-05`, which KV3 does not read as a number at all.
/// </summary>
static string Number( float value )
{
if ( !float.IsFinite( value ) )
value = 0f;
var text = value.ToString( "0.0######", CultureInfo.InvariantCulture );
// "-0.0" is a valid float and an eyesore in a file people read.
return text == "-0.0" ? "0.0" : text;
}
}