ASCII FBX 7.4 exporter used by the editor. Writes a static bind-pose mesh FBX file including vertices, normals, UVs, polygon indices, per-polygon material indices, materials and basic model/global blocks.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace GmodAssetImporter;
/// <summary>
/// Minimal ASCII FBX 7.4 writer for ModelDoc.
/// Exports bind-pose mesh + named materials (no skin). Material slot names must match
/// VMDL DefaultMaterialGroup remap "from" values (slot + ".vmat").
/// </summary>
public static class FbxWriter
{
static readonly Encoding Utf8NoBom = new UTF8Encoding( false );
static long _id;
public static void WriteMesh( string path, List<BoneData> bones, MeshExport mesh )
{
_ = bones;
Directory.CreateDirectory( Path.GetDirectoryName( path )! );
_id = 100000;
var matNames = mesh.Triangles
.Select( t => string.IsNullOrWhiteSpace( t.MaterialName ) ? "default" : t.MaterialName )
.Distinct( StringComparer.OrdinalIgnoreCase )
.ToList();
if ( matNames.Count == 0 )
matNames.Add( "default" );
var matIndexByName = new Dictionary<string, int>( StringComparer.OrdinalIgnoreCase );
for ( var i = 0; i < matNames.Count; i++ )
matIndexByName[matNames[i]] = i;
using var sw = new StreamWriter( path, false, Utf8NoBom );
WriteHeader( sw );
sw.WriteLine( "Definitions: {" );
sw.WriteLine( "\tVersion: 100" );
sw.WriteLine( "\tCount: 4" );
WriteObjectType( sw, "GlobalSettings", 1 );
WriteObjectType( sw, "Model", 1 );
WriteObjectType( sw, "Geometry", 1 );
WriteObjectType( sw, "Material", matNames.Count );
sw.WriteLine( "}" );
sw.WriteLine();
sw.WriteLine( "Objects: {" );
WriteGlobalSettings( sw );
var rootModelId = NextId();
var geoId = NextId();
WriteGeometry( sw, geoId, mesh, matIndexByName );
WriteModel( sw, rootModelId, Sanitize( mesh.Name ), "Mesh" );
var matIds = new long[matNames.Count];
for ( var i = 0; i < matNames.Count; i++ )
{
matIds[i] = NextId();
// Keep slot spelling aligned with MaterialRemapFromName / MaterialSlotName (do not turn '.' into '_')
WriteMaterial( sw, matIds[i], PathResolve.SanitizeAssetName( matNames[i] ) );
}
sw.WriteLine( "}" );
sw.WriteLine();
sw.WriteLine( "Connections: {" );
sw.WriteLine( $"\tC: \"OO\",{geoId},{rootModelId}" );
foreach ( var matId in matIds )
sw.WriteLine( $"\tC: \"OO\",{matId},{rootModelId}" );
sw.WriteLine( $"\tC: \"OO\",{rootModelId},0" );
sw.WriteLine( "}" );
sw.WriteLine();
sw.WriteLine( "; End of FBX" );
}
static void WriteHeader( StreamWriter sw )
{
sw.WriteLine( "; FBX 7.4.0 project file" );
sw.WriteLine( "; Generated by GmodAssetImporter for s&box ModelDoc (static bind-pose mesh)" );
sw.WriteLine( "FBXHeaderExtension: {" );
sw.WriteLine( "\tFBXHeaderVersion: 1003" );
sw.WriteLine( "\tFBXVersion: 7400" );
sw.WriteLine( "\tCreator: \"GmodAssetImporter\"" );
sw.WriteLine( "}" );
sw.WriteLine();
}
static void WriteObjectType( StreamWriter sw, string type, int count )
{
sw.WriteLine( "\tObjectType: \"" + type + "\" {" );
sw.WriteLine( $"\t\tCount: {count}" );
sw.WriteLine( "\t}" );
}
static void WriteGlobalSettings( StreamWriter sw )
{
sw.WriteLine( $"\tGlobalSettings: {NextId()}, \"\" {{" );
sw.WriteLine( "\t\tVersion: 1000" );
sw.WriteLine( "\t\tProperties70: {" );
sw.WriteLine( "\t\t\tP: \"UpAxis\", \"int\", \"Integer\", \"\",2" );
sw.WriteLine( "\t\t\tP: \"UpAxisSign\", \"int\", \"Integer\", \"\",1" );
sw.WriteLine( "\t\t\tP: \"FrontAxis\", \"int\", \"Integer\", \"\",1" );
sw.WriteLine( "\t\t\tP: \"FrontAxisSign\", \"int\", \"Integer\", \"\",1" );
sw.WriteLine( "\t\t\tP: \"CoordAxis\", \"int\", \"Integer\", \"\",0" );
sw.WriteLine( "\t\t\tP: \"CoordAxisSign\", \"int\", \"Integer\", \"\",1" );
sw.WriteLine( "\t\t\tP: \"UnitScaleFactor\", \"double\", \"Number\", \"\",1" );
sw.WriteLine( "\t\t}" );
sw.WriteLine( "\t}" );
}
static void WriteGeometry( StreamWriter sw, long id, MeshExport mesh, Dictionary<string, int> matIndexByName )
{
sw.WriteLine( $"\tGeometry: {id}, \"Geometry::{Sanitize( mesh.Name )}\", \"Mesh\" {{" );
// Source → ModelDoc: mirror X (fixes inside-out + left/right). Negating one axis
// flips winding, so keep Source triangle order A,B,C. Match the first good export UV (flip V).
sw.WriteLine( $"\t\tVertices: *{mesh.Vertices.Count * 3} {{" );
sw.Write( "\t\t\ta: " );
for ( var i = 0; i < mesh.Vertices.Count; i++ )
{
var v = mesh.Vertices[i];
if ( i > 0 ) sw.Write( ',' );
sw.Write( string.Format( CultureInfo.InvariantCulture, "{0},{1},{2}", -v.Position.X, v.Position.Y, v.Position.Z ) );
}
sw.WriteLine();
sw.WriteLine( "\t\t}" );
sw.WriteLine( $"\t\tPolygonVertexIndex: *{mesh.Triangles.Count * 3} {{" );
sw.Write( "\t\t\ta: " );
for ( var i = 0; i < mesh.Triangles.Count; i++ )
{
var t = mesh.Triangles[i];
if ( i > 0 ) sw.Write( ',' );
sw.Write( string.Format( CultureInfo.InvariantCulture, "{0},{1},{2}", t.A, t.B, ~t.C ) );
}
sw.WriteLine();
sw.WriteLine( "\t\t}" );
sw.WriteLine( "\t\tGeometryVersion: 124" );
sw.WriteLine( "\t\tLayerElementNormal: 0 {" );
sw.WriteLine( "\t\t\tVersion: 101" );
sw.WriteLine( "\t\t\tName: \"\"" );
sw.WriteLine( "\t\t\tMappingInformationType: \"ByPolygonVertex\"" );
sw.WriteLine( "\t\t\tReferenceInformationType: \"Direct\"" );
sw.WriteLine( $"\t\t\tNormals: *{mesh.Triangles.Count * 9} {{" );
sw.Write( "\t\t\t\ta: " );
var firstN = true;
foreach ( var t in mesh.Triangles )
{
foreach ( var idx in new[] { t.A, t.B, t.C } )
{
var n = (idx >= 0 && idx < mesh.Vertices.Count) ? mesh.Vertices[idx].Normal : new Vec3( 0, 0, 1 );
if ( !firstN ) sw.Write( ',' );
firstN = false;
sw.Write( string.Format( CultureInfo.InvariantCulture, "{0},{1},{2}", -n.X, n.Y, n.Z ) );
}
}
sw.WriteLine();
sw.WriteLine( "\t\t\t}" );
sw.WriteLine( "\t\t}" );
sw.WriteLine( "\t\tLayerElementUV: 0 {" );
sw.WriteLine( "\t\t\tVersion: 101" );
sw.WriteLine( "\t\t\tName: \"UVMap\"" );
sw.WriteLine( "\t\t\tMappingInformationType: \"ByPolygonVertex\"" );
sw.WriteLine( "\t\t\tReferenceInformationType: \"Direct\"" );
sw.WriteLine( $"\t\t\tUV: *{mesh.Triangles.Count * 6} {{" );
sw.Write( "\t\t\t\ta: " );
var firstUv = true;
foreach ( var t in mesh.Triangles )
{
foreach ( var idx in new[] { t.A, t.B, t.C } )
{
var uv = (idx >= 0 && idx < mesh.Vertices.Count) ? mesh.Vertices[idx].Uv : new Vec2();
if ( !firstUv ) sw.Write( ',' );
firstUv = false;
// First good export: flip V for ModelDoc. U stays (X mirror keeps markings readable).
sw.Write( string.Format( CultureInfo.InvariantCulture, "{0},{1}", uv.X, 1.0f - uv.Y ) );
}
}
sw.WriteLine();
sw.WriteLine( "\t\t\t}" );
sw.WriteLine( "\t\t}" );
// Per-polygon material indices — required for DefaultMaterialGroup remaps
sw.WriteLine( "\t\tLayerElementMaterial: 0 {" );
sw.WriteLine( "\t\t\tVersion: 101" );
sw.WriteLine( "\t\t\tName: \"\"" );
sw.WriteLine( "\t\t\tMappingInformationType: \"ByPolygon\"" );
sw.WriteLine( "\t\t\tReferenceInformationType: \"IndexToDirect\"" );
sw.WriteLine( $"\t\t\tMaterials: *{mesh.Triangles.Count} {{" );
sw.Write( "\t\t\t\ta: " );
for ( var i = 0; i < mesh.Triangles.Count; i++ )
{
if ( i > 0 ) sw.Write( ',' );
var key = string.IsNullOrWhiteSpace( mesh.Triangles[i].MaterialName ) ? "default" : mesh.Triangles[i].MaterialName;
sw.Write( matIndexByName.TryGetValue( key, out var mi ) ? mi : 0 );
}
sw.WriteLine();
sw.WriteLine( "\t\t\t}" );
sw.WriteLine( "\t\t}" );
sw.WriteLine( "\t\tLayer: 0 {" );
sw.WriteLine( "\t\t\tVersion: 100" );
sw.WriteLine( "\t\t\tLayerElement: {" );
sw.WriteLine( "\t\t\t\tType: \"LayerElementNormal\"" );
sw.WriteLine( "\t\t\t\tTypedIndex: 0" );
sw.WriteLine( "\t\t\t}" );
sw.WriteLine( "\t\t\tLayerElement: {" );
sw.WriteLine( "\t\t\t\tType: \"LayerElementUV\"" );
sw.WriteLine( "\t\t\t\tTypedIndex: 0" );
sw.WriteLine( "\t\t\t}" );
sw.WriteLine( "\t\t\tLayerElement: {" );
sw.WriteLine( "\t\t\t\tType: \"LayerElementMaterial\"" );
sw.WriteLine( "\t\t\t\tTypedIndex: 0" );
sw.WriteLine( "\t\t\t}" );
sw.WriteLine( "\t\t}" );
sw.WriteLine( "\t}" );
}
static void WriteModel( StreamWriter sw, long id, string name, string type )
{
sw.WriteLine( $"\tModel: {id}, \"Model::{name}\", \"{type}\" {{" );
sw.WriteLine( "\t\tVersion: 232" );
sw.WriteLine( "\t\tProperties70: {" );
sw.WriteLine( "\t\t\tP: \"RotationActive\", \"bool\", \"\", \"\",1" );
sw.WriteLine( "\t\t\tP: \"InheritType\", \"enum\", \"\", \"\",1" );
sw.WriteLine( "\t\t\tP: \"ScalingMax\", \"Vector3D\", \"Vector\", \"\",0,0,0" );
sw.WriteLine( "\t\t\tP: \"Lcl Translation\", \"Lcl Translation\", \"\", \"A\",0,0,0" );
sw.WriteLine( "\t\t\tP: \"Lcl Rotation\", \"Lcl Rotation\", \"\", \"A\",0,0,0" );
sw.WriteLine( "\t\t\tP: \"Lcl Scaling\", \"Lcl Scaling\", \"\", \"A\",1,1,1" );
sw.WriteLine( "\t\t}" );
sw.WriteLine( "\t\tShading: Y" );
sw.WriteLine( "\t\tCulling: \"CullingOff\"" );
sw.WriteLine( "\t}" );
}
static void WriteMaterial( StreamWriter sw, long id, string name )
{
sw.WriteLine( $"\tMaterial: {id}, \"Material::{name}\", \"\" {{" );
sw.WriteLine( "\t\tVersion: 102" );
sw.WriteLine( "\t\tShadingModel: \"phong\"" );
sw.WriteLine( "\t\tMultiLayer: 0" );
sw.WriteLine( "\t\tProperties70: {" );
sw.WriteLine( "\t\t\tP: \"ShadingModel\", \"KString\", \"\", \"\", \"phong\"" );
sw.WriteLine( "\t\t}" );
sw.WriteLine( "\t}" );
}
static long NextId() => ++_id;
static string Sanitize( string name )
{
name = (name ?? "mesh").Trim();
foreach ( var c in Path.GetInvalidFileNameChars() )
name = name.Replace( c, '_' );
return name.Replace( ' ', '_' ).Replace( '.', '_' );
}
}