Editor/Effigy/SmdWriter.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
namespace Effigy;
/// <summary>
/// Studiomdl Data (SMD) export — the skinned path.
///
/// WHY SMD RATHER THAN OBJ. OBJ cannot carry bones or vertex weights, so the moment a model is
/// rigged it stops being an export target. SMD is plain ASCII, carries a bone hierarchy, a bind
/// pose and per-vertex weights, assigns a material per triangle, and is on ModelDoc's documented
/// import list (DMX, SMD, FBX, OBJ, VOX). It is the cheapest format that can express everything
/// this kernel produces.
///
/// It also collapses two export paths into one: a STATIC model is just a mesh weighted entirely to
/// a single root bone. There is no separate static writer to keep in step.
///
/// WHAT IT COSTS. SMD is triangles-only, so the quad cage is triangulated on the way out. That is
/// fine — the cage lives in the tool's own document and export is a bake — but it means an SMD is
/// not a round trip. It also carries exactly one UV channel and no vertex colours; if vertex paint
/// ever matters, that is the day to look at DMX.
///
/// PORTABILITY. This is an s&box-side format and it deliberately knows nothing about the skeleton
/// beyond what Skeleton already exposes. Godot's equivalent writes bones and weights straight into
/// an ArrayMesh; it is a peer of this file, not a replacement for it, and neither should be able to
/// push assumptions back into Skeleton or SkinWeights.
/// </summary>
public static class SmdWriter
{
/// <summary>Engines index a fixed number of bones per vertex, and four is effectively
/// universal. Weights are pruned to this exactly once, here, at the boundary.</summary>
public const int MaxInfluences = 4;
public static void WriteFile(
PolyMesh mesh,
string path,
Skeleton skeleton = null,
float smoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees,
Func<int, string> materialName = null )
{
File.WriteAllText( path, Write( mesh, skeleton, smoothingAngleDegrees, materialName ) );
}
/// <summary>
/// Write the mesh as SMD. With no skeleton — or a mesh carrying no weights — everything is
/// bound to a single root bone, which is what a static prop wants.
/// </summary>
public static string Write(
PolyMesh mesh,
Skeleton skeleton = null,
float smoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees,
Func<int, string> materialName = null )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
skeleton ??= Skeleton.SingleRoot();
if ( skeleton.Count == 0 )
throw new InvalidOperationException( "SMD 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}";
var c = CultureInfo.InvariantCulture;
var sb = new StringBuilder();
sb.Append( "version 1\n" );
// --- nodes: the bone hierarchy ---------------------------------------------------
sb.Append( "nodes\n" );
for ( var i = 0; i < skeleton.Count; i++ )
{
var bone = skeleton.Bones[i];
sb.Append( string.Format( c, "{0} \"{1}\" {2}\n", i, Escape( bone.Name ), bone.Parent ) );
}
sb.Append( "end\n" );
// --- skeleton: one keyframe, the bind pose ---------------------------------------
//
// Positions and rotations here are LOCAL to the parent, which is what Bone.Local already
// holds — no conversion, and no chance of writing world transforms that look right for
// roots and wrong for everything below them.
sb.Append( "skeleton\n" );
sb.Append( "time 0\n" );
for ( var i = 0; i < skeleton.Count; i++ )
{
var local = skeleton.Bones[i].Local;
var t = local.Origin;
var r = local.ToEulerXyz();
sb.Append( string.Format( c,
"{0} {1:0.######} {2:0.######} {3:0.######} {4:0.######} {5:0.######} {6:0.######}\n",
i, t.x, t.y, t.z, r.x, r.y, r.z ) );
}
sb.Append( "end\n" );
// --- triangles -------------------------------------------------------------------
var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );
sb.Append( "triangles\n" );
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
var face = mesh.Faces[fi];
var material = materialName( face.Material );
// Ear clipping, same as the render mesh and the raycaster - an extrude cap is whatever
// region the user drew, and a fan over a concave one exports the notch filled in.
var polygon = new List<Vec3>( face.Count );
for ( var k = 0; k < face.Count; k++ )
polygon.Add( mesh.Positions[face.Indices[k]] );
foreach ( var (ia, ib, ic) in Triangulate.Face( polygon ) )
{
sb.Append( material );
sb.Append( '\n' );
AppendVertex( sb, c, mesh, face, cornerNormals[fi], normals, skin, skeleton, ia );
AppendVertex( sb, c, mesh, face, cornerNormals[fi], normals, skin, skeleton, ib );
AppendVertex( sb, c, mesh, face, cornerNormals[fi], normals, skin, skeleton, ic );
}
}
sb.Append( "end\n" );
return sb.ToString();
}
static void AppendVertex(
StringBuilder sb,
CultureInfo c,
PolyMesh mesh,
Face face,
int[] faceCornerNormals,
List<Vec3> normals,
SkinWeights skin,
Skeleton skeleton,
int corner )
{
var vi = face.Indices[corner];
var p = mesh.Positions[vi];
var n = normals[faceCornerNormals[corner]];
var uv = face.UVs[corner];
var weights = vi < skin.Count ? SkinWeights.Prune( skin[vi], MaxInfluences ) : Array.Empty<BoneWeight>();
// An unweighted vertex is pinned to the first bone rather than written with zero links.
// Studiomdl reads a zero-link vertex as "use the parent bone column", and silently ending up
// with a differently-rigged model is worse than being obviously blunt about it.
if ( weights.Length == 0 )
weights = new[] { new BoneWeight( 0, 1f ) };
// The leading bone index is the vertex's parent bone. Convention is the strongest influence,
// and Prune has already sorted them heaviest first.
var parentBone = weights[0].Bone;
sb.Append( string.Format( c,
"{0} {1:0.######} {2:0.######} {3:0.######} {4:0.######} {5:0.######} {6:0.######} {7:0.######} {8:0.######} {9}",
parentBone, p.x, p.y, p.z, n.x, n.y, n.z, uv.x, uv.y, weights.Length ) );
foreach ( var w in weights )
{
if ( w.Bone < 0 || w.Bone >= skeleton.Count )
throw new InvalidOperationException(
$"Vertex {vi} is weighted to bone {w.Bone}, but the skeleton has {skeleton.Count} bones" );
sb.Append( string.Format( c, " {0} {1:0.######}", w.Bone, w.Weight ) );
}
sb.Append( '\n' );
}
/// <summary>Bone names are written inside quotes, so a quote in a name would end the field
/// early. Nothing in this kernel generates one, but a user typing a name can.</summary>
static string Escape( string name ) => name.Replace( "\"", "'" );
}
/// <summary>
/// Minimal SMD reader, for round-tripping in tests. Understands only what SmdWriter emits — enough
/// to prove the bone hierarchy, the bind pose and the weights survive the trip, which is the part
/// that would otherwise only be discovered inside ModelDoc.
/// </summary>
public static class SmdReader
{
public sealed class Result
{
public Skeleton Skeleton = new();
public List<Vec3> BonePositions = new();
public List<Vec3> BoneRotations = new();
public int TriangleCount;
public List<string> Materials = new();
/// <summary>Per triangle corner, in file order: position, normal, uv and its influences.</summary>
public List<(Vec3 Position, Vec3 Normal, Vec2 UV, BoneWeight[] Weights)> Corners = new();
}
public static Result Read( string text )
{
var r = new Result();
var c = CultureInfo.InvariantCulture;
var lines = text.Split( '\n' );
var mode = "";
var parents = new List<int>();
var names = new List<string>();
foreach ( var raw in lines )
{
var line = raw.Trim();
if ( line.Length == 0 || line.StartsWith( "version" ) )
continue;
if ( line is "nodes" or "skeleton" or "triangles" )
{
mode = line;
continue;
}
if ( line == "end" )
{
mode = "";
continue;
}
if ( mode == "nodes" )
{
var open = line.IndexOf( '"' );
var close = line.LastIndexOf( '"' );
names.Add( line.Substring( open + 1, close - open - 1 ) );
parents.Add( int.Parse( line[(close + 1)..].Trim(), c ) );
continue;
}
if ( mode == "skeleton" )
{
if ( line.StartsWith( "time" ) )
continue;
var p = line.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
r.BonePositions.Add( new Vec3( float.Parse( p[1], c ), float.Parse( p[2], c ), float.Parse( p[3], c ) ) );
r.BoneRotations.Add( new Vec3( float.Parse( p[4], c ), float.Parse( p[5], c ), float.Parse( p[6], c ) ) );
continue;
}
if ( mode != "triangles" )
continue;
var parts = line.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
// A material line is the only line in this block that does not start with a number.
if ( !int.TryParse( parts[0], NumberStyles.Integer, c, out _ ) )
{
r.Materials.Add( line );
r.TriangleCount++;
continue;
}
var position = new Vec3( float.Parse( parts[1], c ), float.Parse( parts[2], c ), float.Parse( parts[3], c ) );
var normal = new Vec3( float.Parse( parts[4], c ), float.Parse( parts[5], c ), float.Parse( parts[6], c ) );
var uv = new Vec2( float.Parse( parts[7], c ), float.Parse( parts[8], c ) );
var links = parts.Length > 9 ? int.Parse( parts[9], c ) : 0;
var weights = new BoneWeight[links];
for ( var i = 0; i < links; i++ )
{
weights[i] = new BoneWeight(
int.Parse( parts[10 + i * 2], c ),
float.Parse( parts[11 + i * 2], c ) );
}
r.Corners.Add( (position, normal, uv, weights) );
}
for ( var i = 0; i < names.Count; i++ )
{
var local = Xform.FromEulerXyz( r.BoneRotations[i] );
r.Skeleton.AddBone( names[i], parents[i],
new Xform( local.X, local.Y, local.Z, r.BonePositions[i] ) );
}
return r;
}
}