Editor/WeaponImporter/Core/Output/VmdlBuilder.cs
#nullable enable annotations
using System.Numerics;
using WeaponImporter.Core.Maths;
namespace WeaponImporter.Core.Output;
using Vector3 = System.Numerics.Vector3;
/// <summary>An animation entry of a generated model.</summary>
public sealed record VmdlAnimation
{
public required string Name { get; init; }
/// <summary>Project-relative source file (DMX/FBX).</summary>
public required string File { get; init; }
public int Take { get; init; }
public bool Looping { get; init; }
public int FrameCount { get; init; }
public float Fps { get; init; } = 30f;
public IReadOnlyList<(string Name, float Time)> Events { get; init; } = Array.Empty<(string, float)>();
}
public sealed record VmdlAttachment(string Name, string Bone, Vector3 Position, Quaternion Rotation);
/// <summary>
/// Builds ModelDoc text for a generated weapon: one render mesh file, material remaps,
/// animations with generic events, and attachments.
/// </summary>
public sealed class VmdlBuilder
{
public string MeshFile { get; set; } = "";
public float ImportScale { get; set; } = 1f;
public List<(string From, string To)> MaterialRemaps { get; } = new();
public List<VmdlAnimation> Animations { get; } = new();
public List<VmdlAttachment> Attachments { get; } = new();
public List<string> KeepBones { get; } = new();
public string AnimGraph { get; set; } = "";
public string BaseModel { get; set; } = "";
public string Build()
{
var children = new KvArray();
if (!string.IsNullOrEmpty(MeshFile))
{
var meshFile = new KvObject()
.Add("_class", "RenderMeshFile")
.Add("name", Path.GetFileNameWithoutExtension(MeshFile))
.Add("filename", MeshFile)
.Add("import_translation", 0.0, 0.0, 0.0)
.Add("import_rotation", 0.0, 0.0, 0.0)
.Add("import_scale", (double)ImportScale)
.Add("align_origin_x_type", "None")
.Add("align_origin_y_type", "None")
.Add("align_origin_z_type", "None")
.Add("parent_bone", "")
.Add("import_filter", new KvObject().Add("exclude_by_default", false).Add("exception_list", new KvArray()));
children.Add(new KvObject().Add("_class", "RenderMeshList").Add("children", new KvArray().Add(meshFile)));
}
if (MaterialRemaps.Count > 0)
{
var remaps = new KvArray();
foreach (var (from, to) in MaterialRemaps)
remaps.Add(new KvObject().Add("from", from).Add("to", to));
children.Add(new KvObject().Add("_class", "MaterialGroupList").Add("children", new KvArray().Add(
new KvObject().Add("_class", "DefaultMaterialGroup").Add("remaps", remaps).Add("use_global_default", false).Add("global_default_material", ""))));
}
// Keep moving parts even when no mesh is skinned to them (culled bones break sequences).
var markup = new KvArray();
foreach (var bone in KeepBones.Distinct())
markup.Add(new KvObject().Add("_class", "BoneMarkup").Add("target_bone", bone).Add("ignore_Translation", false).Add("ignore_rotation", false).Add("do_not_discard", true));
children.Add(new KvObject().Add("_class", "BoneMarkupList").Add("children", markup).Add("bone_cull_type", "None"));
if (Animations.Count > 0)
{
var anims = new KvArray();
foreach (var a in Animations)
{
var anim = new KvObject().Add("_class", "AnimFile").Add("name", a.Name);
if (a.Events.Count > 0)
{
var events = new KvArray();
// The engine never dispatches an event on the last (seam) frame.
var last = Math.Max(0, a.FrameCount - 2);
foreach (var (name, time) in a.Events)
{
var frame = Math.Clamp((int)MathF.Round(time * Math.Max(1, a.FrameCount - 1)), 0, last);
events.Add(new KvObject()
.Add("_class", "AnimEvent")
.Add("event_class", "AE_GENERIC_EVENT")
.Add("event_frame", frame)
.Add("event_keys", new KvObject().Add("TypeName", name).Add("Int", frame).Add("Float", (double)(frame / MathF.Max(1f, a.Fps))).Add("StringData", a.Name)));
}
anim.Add("children", events);
}
anim.Add("activity_name", "")
.Add("activity_weight", 1)
.Add("weight_list_name", "")
.Add("fade_in_time", a.Looping ? 0.2 : 0.1)
.Add("fade_out_time", a.Looping ? 0.2 : 0.1)
.Add("looping", a.Looping)
.Add("delta", false)
.Add("worldSpace", false)
.Add("hidden", false)
.Add("anim_markup_ordered", false)
.Add("disable_compression", false)
.Add("disable_interpolation", false)
.Add("enable_scale", false)
.Add("source_filename", a.File)
.Add("start_frame", -1)
.Add("end_frame", -1)
.Add("framerate", -1.0)
.Add("take", a.Take)
.Add("reverse", false);
anims.Add(anim);
}
children.Add(new KvObject().Add("_class", "AnimationList").Add("children", anims).Add("default_root_bone_name", ""));
}
if (Attachments.Count > 0)
{
var list = new KvArray();
foreach (var at in Attachments)
{
var angles = ToAngles(at.Rotation);
list.Add(new KvObject()
.Add("_class", "Attachment")
.Add("name", at.Name)
.Add("parent_bone", at.Bone)
.Add("relative_origin", at.Position.X, at.Position.Y, at.Position.Z)
.Add("relative_angles", angles.X, angles.Y, angles.Z)
.Add("weight", 1.0)
.Add("ignore_rotation", false));
}
children.Add(new KvObject().Add("_class", "AttachmentList").Add("children", list));
}
var rootNode = new KvObject()
.Add("_class", "RootNode")
.Add("children", children)
.Add("model_archetype", "")
.Add("primary_associated_entity", "")
.Add("anim_graph_name", AnimGraph)
.Add("base_model_name", BaseModel);
return Kv3.Serialize(new KvObject().Add("rootNode", rootNode));
}
/// <summary>
/// Quaternion -> Source angles (pitch, yaw, roll in degrees) for a Z-up, X-forward frame:
/// R = Rz(yaw) * Ry(pitch) * Rx(roll).
/// </summary>
public static Vector3 ToAngles(Quaternion q)
{
q = MathQ.Normalize(q);
var m = Matrix4x4.CreateFromQuaternion(q);
// Row-vector matrix: row i = image of axis i. Forward = row 0, left = row 1, up = row 2.
var fx = m.M11; var fy = m.M12; var fz = m.M13;
var lz = m.M23; var uz = m.M33;
var pitch = MathF.Asin(Math.Clamp(-fz, -1f, 1f));
float yaw, roll;
if (MathF.Abs(fz) < 0.9999f)
{
yaw = MathF.Atan2(fy, fx);
roll = MathF.Atan2(lz, uz);
}
else
{
yaw = MathF.Atan2(-m.M21, m.M22);
roll = 0f;
}
const float deg = 180f / MathF.PI;
return new Vector3(pitch * deg, yaw * deg, roll * deg);
}
/// <summary>Inverse of <see cref="ToAngles"/>.</summary>
public static Quaternion FromAngles(Vector3 angles)
{
const float rad = MathF.PI / 180f;
var yaw = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, angles.Y * rad);
var pitch = Quaternion.CreateFromAxisAngle(Vector3.UnitY, angles.X * rad);
var roll = Quaternion.CreateFromAxisAngle(Vector3.UnitX, angles.Z * rad);
return MathQ.Normalize(yaw * pitch * roll);
}
}