Editor/Effigy/FbxWriter.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;

namespace Effigy;

/// <summary>
/// ASCII FBX 7.4 export — the format the model compiler is happiest with, because it does not
/// parse it itself.
///
/// WHY FBX AND NOT DMX. ModelDoc takes "FBX, DMX, OBJ, VOX". DmxWriter's header argues that DMX is
/// the only option on the grounds that "FBX is a binary format nobody should hand-write". That is
/// wrong twice over, and the engine ships the proof in bin/win64:
///
///   fbx2dmx.exe     the engine's own FBX importer
///   libfbxsdk.dll   Autodesk's official SDK, which reads ASCII FBX as happily as binary
///
/// So writing FBX means fbx2dmx produces the DMX, and the job of getting DMX exactly right stops
/// being ours. That is the entire argument for this file: not that FBX is a nicer format, but that
/// it hands a decades-hardened importer the work we were otherwise doing by hand from strings
/// scraped out of a DLL. DmxWriter still works and still ships — this is the path that does not
/// depend on our own reading of a format.
///
/// IT ALSO COMES WITH AN ORACLE. Any file this writes can be checked without the editor running:
///
///   fbx2dmx.exe -i out/sample_rigged.fbx -o check.dmx
///
/// A malformed FBX is not a bad-looking model, it is a file that does not load, so a render can
/// never validate this. The converter can, and it names what it did not understand.
///
/// WHAT SURVIVES THE TRIP that SMD could not carry: n-gons. FBX ends a polygon by negating the
/// last index rather than assuming three corners, so the quad cage Catmull-Clark needs leaves the
/// tool intact.
/// </summary>
public static class FbxWriter
{
	/// <summary>Engines index a fixed number of bones per vertex and four is effectively
	/// universal. Unlike DMX, FBX does not store a fixed stride — each cluster lists only the
	/// vertices it actually touches — but pruning to the same number keeps the two writers
	/// producing the same rig rather than two subtly different ones.</summary>
	public const int MaxInfluences = 4;

	/// <summary>7.4 is what Blender and Maya emit and what the SDK is most exercised on. Older
	/// 6.x has a different object model entirely; newer versions gain nothing here.</summary>
	private const int FbxVersion = 7400;

	public static void WriteFile(
		PolyMesh mesh,
		string path,
		Skeleton skeleton = null,
		float smoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees,
		Func<int, string> materialName = null,
		string modelName = null )
	{
		File.WriteAllText( path, Write( mesh, skeleton, smoothingAngleDegrees, materialName, modelName ) );
	}

	public static string Write(
		PolyMesh mesh,
		Skeleton skeleton = null,
		float smoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees,
		Func<int, string> materialName = null,
		string modelName = null )
	{
		if ( mesh is null )
			throw new ArgumentNullException( nameof( mesh ) );

		skeleton ??= Skeleton.SingleRoot();

		if ( skeleton.Count == 0 )
			throw new InvalidOperationException( "FBX needs at least one bone; use Skeleton.SingleRoot for a static model" );

		var skin = mesh.IsRigged ? mesh.Skin : SkinWeights.AllTo( mesh.VertexCount, 0 );
		materialName ??= slot => $"material_{slot}";
		modelName ??= "effigy_model";

		var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );

		var w = new FbxText();

		// Ids are sequential from a fixed base rather than random, so two exports of the same model
		// are byte-identical and a diff shows what actually changed. Zero is reserved: it is the
		// scene root every top-level node connects to.
		var idGeometry = w.NextId();
		var idMeshModel = w.NextId();

		var slots = MaterialSlots( mesh );
		var materialIds = new long[slots.Count];

		for ( var i = 0; i < slots.Count; i++ )
			materialIds[i] = w.NextId();

		var boneModelIds = new long[skeleton.Count];
		var boneAttrIds = new long[skeleton.Count];

		for ( var i = 0; i < skeleton.Count; i++ )
		{
			boneModelIds[i] = w.NextId();
			boneAttrIds[i] = w.NextId();
		}

		var idSkin = w.NextId();
		var clusterIds = new long[skeleton.Count];

		for ( var i = 0; i < skeleton.Count; i++ )
			clusterIds[i] = w.NextId();

		var idPose = w.NextId();

		WriteHeader( w );
		WriteGlobalSettings( w );
		WriteDocuments( w );
		WriteDefinitions( w, slots.Count, skeleton.Count );

		w.Open( "Objects: " );
		{
			WriteGeometry( w, idGeometry, mesh, cornerNormals, normals, slots );
			WriteMeshModel( w, idMeshModel, modelName );

			for ( var i = 0; i < slots.Count; i++ )
				WriteMaterial( w, materialIds[i], materialName( slots[i] ) );

			for ( var i = 0; i < skeleton.Count; i++ )
			{
				WriteLimbAttribute( w, boneAttrIds[i], skeleton.Bones[i] );
				WriteLimbModel( w, boneModelIds[i], skeleton.Bones[i] );
			}

			WriteSkinDeformer( w, idSkin, modelName );

			var clusterVertices = ClusterVertices( mesh, skin, skeleton );

			for ( var i = 0; i < skeleton.Count; i++ )
				WriteCluster( w, clusterIds[i], skeleton, i, clusterVertices[i] );

			WriteBindPose( w, idPose, idMeshModel, boneModelIds, skeleton );
		}
		w.Close();

		WriteConnections( w, idGeometry, idMeshModel, materialIds, boneModelIds, boneAttrIds,
			idSkin, clusterIds, skeleton );

		return w.ToString();
	}

	// --- header and scene-level blocks ------------------------------------------------------------

	static void WriteHeader( FbxText w )
	{
		w.Raw( "; FBX 7.4.0 project file" );
		w.Raw( "; Written by Effigy. Text rather than binary on purpose: a file that can be read," );
		w.Raw( "; diffed and pasted into a bug report is worth more here than a smaller one." );
		w.Raw( "" );

		w.Open( "FBXHeaderExtension: " );
		w.Line( "FBXHeaderVersion: 1003" );
		w.Line( $"FBXVersion: {FbxVersion}" );

		// The SDK reads this block but nothing downstream depends on the values, and a real clock
		// reading would make two exports of the same model differ. A fixed stamp keeps them equal.
		w.Open( "CreationTimeStamp: " );
		w.Line( "Version: 1000" );
		w.Line( "Year: 1970" );
		w.Line( "Month: 1" );
		w.Line( "Day: 1" );
		w.Line( "Hour: 0" );
		w.Line( "Minute: 0" );
		w.Line( "Second: 0" );
		w.Line( "Millisecond: 0" );
		w.Close();

		w.Line( "Creator: \"Effigy\"" );
		w.Close();

		w.Raw( "" );
	}

	/// <summary>
	/// The axis system and units, stated rather than left to the importer to guess.
	///
	/// Effigy builds in Source convention — +x forward, +y left, +z up, one unit one inch — so up
	/// is Z (axis 2) and front is -Y. Declaring it means fbx2dmx converts if it wants a different
	/// handedness, instead of silently reading the numbers as though they were already its own.
	/// </summary>
	static void WriteGlobalSettings( FbxText w )
	{
		w.Open( "GlobalSettings: " );
		w.Line( "Version: 1000" );
		w.Open( "Properties70: " );
		w.Line( "P: \"UpAxis\", \"int\", \"Integer\", \"\",2" );
		w.Line( "P: \"UpAxisSign\", \"int\", \"Integer\", \"\",1" );
		w.Line( "P: \"FrontAxis\", \"int\", \"Integer\", \"\",1" );
		w.Line( "P: \"FrontAxisSign\", \"int\", \"Integer\", \"\",-1" );
		w.Line( "P: \"CoordAxis\", \"int\", \"Integer\", \"\",0" );
		w.Line( "P: \"CoordAxisSign\", \"int\", \"Integer\", \"\",1" );
		w.Line( "P: \"OriginalUpAxis\", \"int\", \"Integer\", \"\",2" );
		w.Line( "P: \"OriginalUpAxisSign\", \"int\", \"Integer\", \"\",1" );
		w.Line( "P: \"UnitScaleFactor\", \"double\", \"Number\", \"\",1" );
		w.Line( "P: \"OriginalUnitScaleFactor\", \"double\", \"Number\", \"\",1" );
		w.Close();
		w.Close();
		w.Raw( "" );
	}

	/// <summary>
	/// The scene document, and the empty References block that follows it.
	///
	/// THIS BLOCK IS NOT OPTIONAL AND ITS ABSENCE IS SILENT. A file without it parses perfectly and
	/// imports as an empty scene: every Objects entry is read, every Connection is read, and then
	/// none of it is attached to anything, because `C: "OO", node, 0` names a root node that the
	/// document is what declares. The first version of this writer left it out and fbx2dmx produced
	/// a DmeModel with an empty children array, an empty jointList and no mesh — no warning, no
	/// error, exit code zero.
	/// </summary>
	static void WriteDocuments( FbxText w )
	{
		var idScene = w.NextId();

		w.Open( "Documents: " );
		w.Line( "Count: 1" );
		w.Open( $"Document: {idScene}, \"\", \"Scene\" " );
		w.Open( "Properties70: " );
		w.Line( "P: \"SourceObject\", \"object\", \"\", \"\"" );
		w.Line( "P: \"ActiveAnimStackName\", \"KString\", \"\", \"\", \"\"" );
		w.Close();
		w.Line( "RootNode: 0" );
		w.Close();
		w.Close();
		w.Raw( "" );

		w.Open( "References: " );
		w.Close();
		w.Raw( "" );
	}

	/// <summary>
	/// A count of what follows, by type. The SDK preallocates from this and tolerates it being
	/// wrong, but a file whose Definitions disagree with its Objects is the kind of thing that
	/// loads in one importer and not the next, so it is written honestly.
	/// </summary>
	static void WriteDefinitions( FbxText w, int materialCount, int boneCount )
	{
		// One Model per bone plus the mesh's; one NodeAttribute per bone; one Skin plus one
		// Cluster per bone; one Geometry; one Pose; GlobalSettings itself.
		var total = 1 + (boneCount + 1) + boneCount + materialCount + (1 + boneCount) + 1 + 1;

		w.Open( "Definitions: " );
		w.Line( "Version: 100" );
		w.Line( $"Count: {total}" );

		ObjectType( w, "GlobalSettings", 1 );
		ObjectType( w, "Model", boneCount + 1 );
		ObjectType( w, "NodeAttribute", boneCount );
		ObjectType( w, "Geometry", 1 );
		ObjectType( w, "Material", materialCount );
		ObjectType( w, "Deformer", boneCount + 1 );
		ObjectType( w, "Pose", 1 );

		w.Close();
		w.Raw( "" );
	}

	static void ObjectType( FbxText w, string name, int count )
	{
		w.Open( $"ObjectType: \"{name}\", " );
		w.Line( $"Count: {count}" );
		w.Close();
	}

	// --- geometry ---------------------------------------------------------------------------------

	/// <summary>
	/// The mesh: shared positions, an n-gon-capable index list, per-corner normals and UVs, and a
	/// material index per polygon.
	/// </summary>
	static void WriteGeometry( FbxText w, long id, PolyMesh mesh, int[][] cornerNormals,
		List<Vec3> normals, List<int> slots )
	{
		w.Open( $"Geometry: {id}, \"Geometry::mesh\", \"Mesh\" " );

		var vertices = new List<double>( mesh.VertexCount * 3 );

		foreach ( var p in mesh.Positions )
		{
			vertices.Add( p.x );
			vertices.Add( p.y );
			vertices.Add( p.z );
		}

		w.NumberArray( "Vertices", vertices );

		// POLYGON BOUNDARIES ARE ENCODED IN THE SIGN. There is no per-face corner count anywhere
		// in an FBX; the last index of each polygon is bitwise-negated (~i, i.e. -i - 1) and that
		// is the only thing marking where one face ends. Getting it wrong does not fail to parse,
		// it produces a single enormous polygon.
		var polygonIndices = new List<long>();
		var normalValues = new List<double>();
		var uvValues = new List<double>();
		var uvIndices = new List<long>();
		var materialIndices = new List<long>();

		var slotToDense = new Dictionary<int, int>();

		for ( var i = 0; i < slots.Count; i++ )
			slotToDense[slots[i]] = i;

		for ( var fi = 0; fi < mesh.FaceCount; fi++ )
		{
			var face = mesh.Faces[fi];

			for ( var c = 0; c < face.Count; c++ )
			{
				var index = face.Indices[c];
				polygonIndices.Add( c == face.Count - 1 ? ~(long)index : index );

				var n = normals[cornerNormals[fi][c]];
				normalValues.Add( n.x );
				normalValues.Add( n.y );
				normalValues.Add( n.z );

				var uv = face.UVs is not null && c < face.UVs.Length ? face.UVs[c] : default;
				uvIndices.Add( uvValues.Count / 2 );

				// FBX puts the UV origin at the bottom-left where Effigy's is top-left, so V is
				// flipped on the way out. This is the same flip DmxWriter expresses by leaving
				// flipVCoordinates off and writing V as-is — the two formats simply disagree about
				// which end of the texture is zero.
				uvValues.Add( uv.x );
				uvValues.Add( 1.0 - uv.y );
			}

			materialIndices.Add( slotToDense.TryGetValue( face.Material, out var dense ) ? dense : 0 );
		}

		w.IndexArray( "PolygonVertexIndex", polygonIndices );
		w.Line( "GeometryVersion: 124" );

		w.Open( "LayerElementNormal: 0 " );
		w.Line( "Version: 102" );
		w.Line( "Name: \"\"" );
		w.Line( "MappingInformationType: \"ByPolygonVertex\"" );
		w.Line( "ReferenceInformationType: \"Direct\"" );
		w.NumberArray( "Normals", normalValues );
		w.Close();

		w.Open( "LayerElementUV: 0 " );
		w.Line( "Version: 101" );
		w.Line( "Name: \"UVMap\"" );
		w.Line( "MappingInformationType: \"ByPolygonVertex\"" );
		w.Line( "ReferenceInformationType: \"IndexToDirect\"" );
		w.NumberArray( "UV", uvValues );
		w.IndexArray( "UVIndex", uvIndices );
		w.Close();

		// ByPolygon, not ByPolygonVertex: a face belongs to exactly one slot in Effigy, which is
		// also what makes clicking a face to assign a material meaningful.
		w.Open( "LayerElementMaterial: 0 " );
		w.Line( "Version: 101" );
		w.Line( "Name: \"\"" );
		w.Line( "MappingInformationType: \"ByPolygon\"" );
		w.Line( "ReferenceInformationType: \"IndexToDirect\"" );
		w.IndexArray( "Materials", materialIndices );
		w.Close();

		// The Layer is what actually turns the three LayerElement blocks above into channels the
		// importer reads. Without it they are present and ignored.
		w.Open( "Layer: 0 " );
		w.Line( "Version: 100" );
		LayerElement( w, "LayerElementNormal" );
		LayerElement( w, "LayerElementUV" );
		LayerElement( w, "LayerElementMaterial" );
		w.Close();

		w.Close();
	}

	static void LayerElement( FbxText w, string type )
	{
		w.Open( "LayerElement: " );
		w.Line( $"Type: \"{type}\"" );
		w.Line( "TypedIndex: 0" );
		w.Close();
	}

	static void WriteMeshModel( FbxText w, long id, string modelName )
	{
		w.Open( $"Model: {id}, \"Model::{Escape( modelName )}\", \"Mesh\" " );
		w.Line( "Version: 232" );
		w.Open( "Properties70: " );
		w.Line( "P: \"Lcl Translation\", \"Lcl Translation\", \"\", \"A\",0,0,0" );
		w.Line( "P: \"Lcl Rotation\", \"Lcl Rotation\", \"\", \"A\",0,0,0" );
		w.Line( "P: \"Lcl Scaling\", \"Lcl Scaling\", \"\", \"A\",1,1,1" );
		w.Close();
		w.Line( "Shading: T" );
		w.Line( "Culling: \"CullingOff\"" );
		w.Close();
	}

	static void WriteMaterial( FbxText w, long id, string name )
	{
		w.Open( $"Material: {id}, \"Material::{Escape( name )}\", \"\" " );
		w.Line( "Version: 102" );
		w.Line( "ShadingModel: \"lambert\"" );
		w.Line( "MultiLayer: 0" );
		w.Open( "Properties70: " );
		w.Line( "P: \"DiffuseColor\", \"Color\", \"\", \"A\",0.8,0.8,0.8" );
		w.Close();
		w.Close();
	}

	// --- skeleton ---------------------------------------------------------------------------------

	/// <summary>A bone is two objects in FBX: a Model that carries the transform and sits in the
	/// node hierarchy, and a NodeAttribute that says the Model is a bone rather than a null.
	/// Omitting the attribute gives you a rig of empties that still deform, which then imports
	/// somewhere else as no skeleton at all.</summary>
	static void WriteLimbAttribute( FbxText w, long id, Bone bone )
	{
		w.Open( $"NodeAttribute: {id}, \"NodeAttribute::{Escape( bone.Name )}\", \"LimbNode\" " );
		w.Line( "TypeFlags: \"Skeleton\"" );
		w.Open( "Properties70: " );
		w.Line( $"P: \"Size\", \"double\", \"Number\", \"\",{Number( bone.Length )}" );
		w.Close();
		w.Close();
	}

	static void WriteLimbModel( FbxText w, long id, Bone bone )
	{
		var (translation, rotationDegrees, scale) = Decompose( bone.Local );

		w.Open( $"Model: {id}, \"Model::{Escape( bone.Name )}\", \"LimbNode\" " );
		w.Line( "Version: 232" );
		w.Open( "Properties70: " );
		w.Line( $"P: \"Lcl Translation\", \"Lcl Translation\", \"\", \"A\",{Number( translation.x )},{Number( translation.y )},{Number( translation.z )}" );
		w.Line( $"P: \"Lcl Rotation\", \"Lcl Rotation\", \"\", \"A\",{Number( rotationDegrees.x )},{Number( rotationDegrees.y )},{Number( rotationDegrees.z )}" );
		w.Line( $"P: \"Lcl Scaling\", \"Lcl Scaling\", \"\", \"A\",{Number( scale.x )},{Number( scale.y )},{Number( scale.z )}" );
		w.Close();
		w.Line( "Shading: T" );
		w.Line( "Culling: \"CullingOff\"" );
		w.Close();
	}

	// --- skinning ---------------------------------------------------------------------------------

	static void WriteSkinDeformer( FbxText w, long id, string modelName )
	{
		w.Open( $"Deformer: {id}, \"Deformer::Skin {Escape( modelName )}\", \"Skin\" " );
		w.Line( "Version: 101" );
		w.Line( "Link_DeformAcuracy: 50" );
		w.Close();
	}

	/// <summary>
	/// One cluster per bone, holding only the vertices that bone actually touches.
	///
	/// This is where FBX differs most from DMX: DMX stores a fixed four influences on every vertex
	/// and pads the unused ones with zero-weight entries, while FBX inverts the relationship and
	/// lists, per bone, which vertices it moves. The pruning happens before either, so both formats
	/// describe the same rig.
	///
	/// TransformLink is the bone's world transform at bind time; Transform is the mesh's, which is
	/// identity here because the mesh model sits at the origin. The importer derives the inverse
	/// bind from the pair, so writing one and not the other silently bakes the bind pose in.
	/// </summary>
	static void WriteCluster( FbxText w, long id, Skeleton skeleton, int bone, List<(int Vertex, float Weight)> influences )
	{
		w.Open( $"Deformer: {id}, \"SubDeformer::Cluster {Escape( skeleton.Bones[bone].Name )}\", \"Cluster\" " );
		w.Line( "Version: 100" );
		w.Line( "UserData: \"\", \"\"" );

		// A cluster with no vertices is still written. Dropping it would leave the bone out of the
		// skin entirely, so it would not appear in the compiled skeleton and anything parented to
		// it in the editor would lose its target.
		var indices = new List<long>( influences.Count );
		var weights = new List<double>( influences.Count );

		foreach ( var (vertex, weight) in influences )
		{
			indices.Add( vertex );
			weights.Add( weight );
		}

		w.IndexArray( "Indexes", indices );
		w.NumberArray( "Weights", weights );
		w.NumberArray( "Transform", MatrixValues( Xform.Identity ) );
		w.NumberArray( "TransformLink", MatrixValues( skeleton.WorldBind( bone ) ) );

		w.Close();
	}

	/// <summary>Per-bone vertex lists, built by inverting the per-vertex influence lists once so
	/// the cluster writer does not walk the whole skin per bone.</summary>
	static List<(int Vertex, float Weight)>[] ClusterVertices( PolyMesh mesh, SkinWeights skin, Skeleton skeleton )
	{
		var byBone = new List<(int, float)>[skeleton.Count];

		for ( var i = 0; i < skeleton.Count; i++ )
			byBone[i] = new List<(int, float)>();

		for ( var v = 0; v < mesh.VertexCount; v++ )
		{
			var influences = v < skin.Count ? skin[v] : Array.Empty<BoneWeight>();

			foreach ( var (boneIndex, weight) in Prune( influences, skeleton.Count ) )
				byBone[boneIndex].Add( (v, weight) );
		}

		return byBone;
	}

	/// <summary>Strongest influences first, capped at MaxInfluences, renormalised, and with
	/// anything pointing outside the skeleton dropped rather than written out to be looked up.
	/// Deliberately the same rule DmxWriter applies, so the two exports rig identically.</summary>
	static List<(int Bone, float Weight)> Prune( BoneWeight[] influences, int boneCount )
	{
		var kept = new List<(int Bone, float Weight)>( MaxInfluences );

		foreach ( var influence in influences )
		{
			if ( influence.Bone < 0 || influence.Bone >= boneCount || influence.Weight <= 0f )
				continue;

			kept.Add( (influence.Bone, influence.Weight) );
		}

		kept.Sort( ( a, b ) => b.Weight.CompareTo( a.Weight ) );

		if ( kept.Count > MaxInfluences )
			kept.RemoveRange( MaxInfluences, kept.Count - MaxInfluences );

		var total = 0f;

		foreach ( var influence in kept )
			total += influence.Weight;

		if ( kept.Count == 0 || total <= 0f )
			return new List<(int, float)> { (0, 1f) };

		for ( var i = 0; i < kept.Count; i++ )
			kept[i] = (kept[i].Bone, kept[i].Weight / total);

		return kept;
	}

	/// <summary>
	/// The bind pose as its own record, in world space, for every node the skin touches.
	///
	/// This duplicates what the clusters already say. FBX wants it anyway: importers that do not
	/// read cluster matrices read this instead, and the two disagreeing is a classic source of a
	/// model that binds correctly in one tool and folds in half in another.
	/// </summary>
	static void WriteBindPose( FbxText w, long id, long meshModelId, long[] boneModelIds, Skeleton skeleton )
	{
		w.Open( $"Pose: {id}, \"Pose::BIND_POSES\", \"BindPose\" " );
		w.Line( "Type: \"BindPose\"" );
		w.Line( "Version: 100" );
		w.Line( $"NbPoseNodes: {skeleton.Count + 1}" );

		w.Open( "PoseNode: " );
		w.Line( $"Node: {meshModelId}" );
		w.NumberArray( "Matrix", MatrixValues( Xform.Identity ) );
		w.Close();

		for ( var i = 0; i < skeleton.Count; i++ )
		{
			w.Open( "PoseNode: " );
			w.Line( $"Node: {boneModelIds[i]}" );
			w.NumberArray( "Matrix", MatrixValues( skeleton.WorldBind( i ) ) );
			w.Close();
		}

		w.Close();
	}

	// --- connections ------------------------------------------------------------------------------

	/// <summary>
	/// What actually assembles the file.
	///
	/// Every object written above is inert until it is wired in here — a mesh with no connection to
	/// a model is not an error, it is a mesh that does not appear. That makes a missing connection
	/// the worst failure mode in the format: silent omission rather than a parse error. Each one
	/// below is listed with the direction it needs, because "OO" is child-then-parent and reversing
	/// a pair is exactly as silent.
	/// </summary>
	static void WriteConnections( FbxText w, long geometryId, long meshModelId, long[] materialIds,
		long[] boneModelIds, long[] boneAttrIds, long skinId, long[] clusterIds, Skeleton skeleton )
	{
		w.Open( "Connections: " );

		// The mesh node hangs off the scene root, which is always id 0.
		Connect( w, meshModelId, 0, $"Model::{skeleton.Count} bone rig -> scene root" );
		Connect( w, geometryId, meshModelId, "Geometry -> Model" );

		foreach ( var materialId in materialIds )
			Connect( w, materialId, meshModelId, "Material -> Model" );

		// Bones: attribute to its model, model to its parent's model, roots to the scene root.
		for ( var i = 0; i < skeleton.Count; i++ )
		{
			Connect( w, boneAttrIds[i], boneModelIds[i], "NodeAttribute -> Model" );

			var parent = skeleton.Bones[i].Parent;
			Connect( w, boneModelIds[i], parent < 0 ? 0 : boneModelIds[parent], "Model -> parent" );
		}

		// The skin sits on the GEOMETRY, not the model, and each cluster on the skin.
		Connect( w, skinId, geometryId, "Deformer -> Geometry" );

		for ( var i = 0; i < skeleton.Count; i++ )
		{
			Connect( w, clusterIds[i], skinId, "SubDeformer -> Deformer" );

			// And the bone connects INTO the cluster, not the other way round: the limb is the
			// cluster's source. This is the one pair that reads backwards to most people.
			Connect( w, boneModelIds[i], clusterIds[i], "Model -> SubDeformer" );
		}

		w.Close();
	}

	static void Connect( FbxText w, long child, long parent, string comment )
	{
		w.Line( $"C: \"OO\",{child},{parent} ; {comment}" );
	}

	// --- shared helpers ---------------------------------------------------------------------------

	/// <summary>The material slots the mesh actually uses, in ascending order. A dense list, so a
	/// mesh using slots 0 and 7 writes two materials rather than eight.</summary>
	static List<int> MaterialSlots( PolyMesh mesh )
	{
		var used = new SortedSet<int>();

		foreach ( var face in mesh.Faces )
			used.Add( face.Material );

		if ( used.Count == 0 )
			used.Add( 0 );

		return new List<int>( used );
	}

	/// <summary>
	/// An Xform as the 16 doubles FBX stores a matrix in: row-major with the translation last,
	/// which is the same layout Xform already has (X, Y and Z are the images of the unit axes, so
	/// they are the rows here, and Origin is the fourth).
	/// </summary>
	static List<double> MatrixValues( Xform x ) => new()
	{
		x.X.x, x.X.y, x.X.z, 0.0,
		x.Y.x, x.Y.y, x.Y.z, 0.0,
		x.Z.x, x.Z.y, x.Z.z, 0.0,
		x.Origin.x, x.Origin.y, x.Origin.z, 1.0,
	};

	/// <summary>
	/// An Xform split into the translation, XYZ Euler in DEGREES and scale that FBX's Lcl
	/// properties want.
	///
	/// The Euler conversion is Xform's own, which composes Rz*Ry*Rx — the same order FBX's default
	/// eEulerXYZ uses — and which has a branch for gimbal lock, where the generic formula collapses.
	/// That matters here and is not hypothetical: rig root bones routinely sit at exactly a quarter
	/// turn, which is the locked case.
	/// </summary>
	static (Vec3 Translation, Vec3 RotationDegrees, Vec3 Scale) Decompose( Xform x )
	{
		var sx = x.X.Length;
		var sy = x.Y.Length;
		var sz = x.Z.Length;

		// A zero-length axis cannot be normalised and cannot be a rotation either; fall back to
		// unit rather than producing NaNs that spread through the whole file.
		var rotation = new Xform(
			sx > 1e-8f ? x.X / sx : new Vec3( 1, 0, 0 ),
			sy > 1e-8f ? x.Y / sy : new Vec3( 0, 1, 0 ),
			sz > 1e-8f ? x.Z / sz : new Vec3( 0, 0, 1 ),
			Vec3.Zero );

		var radians = rotation.ToEulerXyz();
		var toDegrees = 180f / MathF.PI;

		return (x.Origin,
			new Vec3( radians.x * toDegrees, radians.y * toDegrees, radians.z * toDegrees ),
			new Vec3( sx > 1e-8f ? sx : 1f, sy > 1e-8f ? sy : 1f, sz > 1e-8f ? sz : 1f ));
	}

	static string Number( float v ) => v.ToString( "0.######", CultureInfo.InvariantCulture );

	static string Escape( string value ) =>
		string.IsNullOrEmpty( value ) ? "" : value.Replace( "\\", "\\\\" ).Replace( "\"", "\\\"" );
}

/// <summary>
/// The ASCII FBX 7.x block syntax, which is a plain nested `Name: args {` / `}` tree with one
/// special case: an array is written as `*count {` followed by a single `a:` line of values.
/// </summary>
internal sealed class FbxText
{
	private readonly StringBuilder _sb = new();
	private int _depth;
	private long _nextId = 1000000;

	/// <summary>Object ids are int64 and only have to be unique within the file. Counting from a
	/// fixed base keeps two exports of the same model byte-identical; zero is reserved for the
	/// scene root.</summary>
	public long NextId() => _nextId++;

	public void Raw( string text ) => _sb.Append( text ).Append( '\n' );

	public void Line( string text )
	{
		Indent();
		_sb.Append( text ).Append( '\n' );
	}

	public void Open( string header )
	{
		Indent();
		_sb.Append( header ).Append( "{\n" );
		_depth++;
	}

	public void Close()
	{
		_depth--;
		Indent();
		_sb.Append( "}\n" );
	}

	public void NumberArray( string name, List<double> values )
	{
		Array( name, values.Count, () =>
		{
			for ( var i = 0; i < values.Count; i++ )
			{
				if ( i > 0 )
					_sb.Append( ',' );

				_sb.Append( values[i].ToString( "0.######", CultureInfo.InvariantCulture ) );
			}
		} );
	}

	public void IndexArray( string name, List<long> values )
	{
		Array( name, values.Count, () =>
		{
			for ( var i = 0; i < values.Count; i++ )
			{
				if ( i > 0 )
					_sb.Append( ',' );

				_sb.Append( values[i].ToString( CultureInfo.InvariantCulture ) );
			}
		} );
	}

	/// <summary>
	/// `Name: *count { a: v,v,v }`. The count is part of the syntax, not a hint — the SDK
	/// allocates from it, so a count that disagrees with the number of values is a corrupt file
	/// rather than a slow one. Writing it from the list's own Count is what keeps them equal.
	/// </summary>
	void Array( string name, int count, Action writeValues )
	{
		Indent();
		_sb.Append( name ).Append( ": *" ).Append( count.ToString( CultureInfo.InvariantCulture ) ).Append( " {\n" );
		_depth++;

		Indent();
		_sb.Append( "a: " );
		writeValues();
		_sb.Append( '\n' );

		_depth--;
		Indent();
		_sb.Append( "}\n" );
	}

	void Indent() => _sb.Append( '\t', _depth );

	public override string ToString() => _sb.ToString();
}