Editor/WeaponImporter/Core/Formats/Fbx/FbxLegacy.cs
#nullable enable annotations
namespace WeaponImporter.Core.Formats.Fbx;
/// <summary>
/// Upgrades an FBX 6.x document (FBX 2006-2010, e.g. Blender 2.7x's exporter) to the FBX 7
/// layout the rest of the importer reads. FBX 6 links objects by name ("Model::bone") instead
/// of ids, keeps each mesh inside its model, writes properties as <c>Property</c> entries in a
/// <c>Properties60</c> block, and stores animation as <c>Takes</c> with per-channel key lists.
/// The upgrade gives every object an id, moves mesh data into a Geometry object, rewrites
/// properties as <c>P</c> entries and turns each take into an AnimationStack with a layer,
/// curve nodes and curves, wired with the same connections an FBX 7 file has.
/// </summary>
public static class FbxLegacy
{
/// <summary>FBX version the document declares (FBXHeaderExtension/FBXVersion), 0 when absent.</summary>
public static int Version(FbxNode root)
{
var v = root.Child("FBXHeaderExtension")?.Child("FBXVersion");
if (v is null || v.Properties.Count == 0)
return 0;
try
{
return v.Prop<int>(0);
}
catch (FormatException)
{
return 0;
}
}
public static bool IsLegacy(FbxNode root) => Version(root) is > 0 and < 7000;
/// <summary>The document in FBX 7 layout (a new tree; <paramref name="root"/> is not changed).</summary>
public static FbxNode Upgrade(FbxNode root)
{
var objects6 = root.Child("Objects") ?? new FbxNode("Objects");
var result = new FbxNode("(root)");
foreach (var header in root.Children.Where(c => c.Name is "FBXHeaderExtension" or "Creator" or "CreationTime"))
result.Children.Add(header);
var objects = new FbxNode("Objects");
var connections = new FbxNode("Connections");
long nextId = 1_000_000;
var ids = new Dictionary<string, long>(StringComparer.Ordinal); // "Model::bone" -> id
var geometryOf = new Dictionary<long, long>(); // model id -> geometry id
var kinds = new Dictionary<long, string>(); // id -> node type
long IdOf(string full)
{
if (!ids.TryGetValue(full, out var id))
ids[full] = id = nextId++;
return id;
}
void Connect(string kind, long child, long parent, string? property = null)
{
var c = new FbxNode("C");
c.Properties.Add(kind);
c.Properties.Add(child);
c.Properties.Add(parent);
if (property is not null)
c.Properties.Add(property);
connections.Children.Add(c);
}
// Global settings (FBX 6 keeps them inside Objects).
if (objects6.Child("GlobalSettings") is { } globals)
result.Children.Add(WithProperties70(new FbxNode("GlobalSettings"), globals));
foreach (var node in objects6.Children)
{
if (node.Name is "GlobalSettings" || node.Properties.Count == 0 || node.Properties[0] is not string full)
continue;
var subClass = node.Properties.Count > 1 && node.Properties[1] is string s ? s : "";
var id = IdOf(full);
switch (node.Name)
{
case "Model":
{
var model = NewObject("Model", id, full, subClass == "Limb" ? "LimbNode" : subClass);
WithProperties70(model, node);
// FBX 6 cameras aim with points (in the scene's space) instead of their axes.
foreach (var aim in node.Children.Where(c => c.Name is "Position" or "LookAt" or "Up"))
model.Children.Add(aim);
kinds[id] = "Model";
if (node.Child("Vertices") is not null)
{
var geometryId = nextId++;
var geometry = NewObject("Geometry", geometryId, "Geometry::" + NameOf(full), "Mesh");
foreach (var child in node.Children)
if (IsGeometryChild(child.Name))
geometry.Children.Add(child);
objects.Children.Add(geometry);
geometryOf[id] = geometryId;
kinds[geometryId] = "Geometry";
Connect("OO", geometryId, id);
}
objects.Children.Add(model);
break;
}
case "Deformer":
{
var deformer = NewObject("Deformer", id, full, subClass);
foreach (var child in node.Children)
if (child.Name is not ("Properties60" or "Properties70"))
deformer.Children.Add(child);
objects.Children.Add(deformer);
kinds[id] = "Deformer";
break;
}
case "Pose":
{
var pose = NewObject("Pose", id, full, subClass);
foreach (var child in node.Children)
{
if (child.Name != "PoseNode")
{
pose.Children.Add(child);
continue;
}
var poseNode = new FbxNode("PoseNode");
foreach (var entry in child.Children)
{
if (entry.Name == "Node" && entry.Properties.Count > 0 && entry.Properties[0] is string target)
{
var n = new FbxNode("Node");
n.Properties.Add(IdOf(target));
poseNode.Children.Add(n);
}
else
{
poseNode.Children.Add(entry);
}
}
pose.Children.Add(poseNode);
}
objects.Children.Add(pose);
break;
}
case "Material" or "Texture" or "Video":
{
var obj = NewObject(node.Name, id, full, subClass);
WithProperties70(obj, node);
foreach (var child in node.Children)
if (child.Name is not ("Properties60" or "Properties70"))
obj.Children.Add(child);
objects.Children.Add(obj);
kinds[id] = node.Name;
break;
}
}
}
// Connections by name.
foreach (var c in root.Child("Connections")?.ChildrenNamed("Connect") ?? Enumerable.Empty<FbxNode>())
{
if (c.Properties.Count < 3 || c.Properties[0] is not string kind || c.Properties[1] is not string child || c.Properties[2] is not string parent)
continue;
if (!ids.TryGetValue(child, out var childId))
continue;
var parentId = parent is "Model::Scene" || !ids.TryGetValue(parent, out var p) ? 0L : p;
kinds.TryGetValue(childId, out var childKind);
kinds.TryGetValue(parentId, out var parentKind);
// A skin deforms the mesh's geometry, which FBX 6 kept inside the model.
if (childKind == "Deformer" && parentKind == "Model" && geometryOf.TryGetValue(parentId, out var geometryId))
parentId = geometryId;
// FBX 6 links a texture to its material without naming the slot: it is the colour map.
if (childKind == "Texture" && parentKind == "Material" && kind == "OO")
{
Connect("OP", childId, parentId, "DiffuseColor");
continue;
}
if (kind == "OP" && c.Properties.Count > 3 && c.Properties[3] is string property)
Connect("OP", childId, parentId, property);
else
Connect("OO", childId, parentId);
}
// Takes -> animation stacks.
foreach (var take in root.Child("Takes")?.ChildrenNamed("Take") ?? Enumerable.Empty<FbxNode>())
{
var takeName = take.Properties.Count > 0 && take.Properties[0] is string tn ? tn : "Take";
var stackId = nextId++;
var stack = NewObject("AnimationStack", stackId, "AnimStack::" + takeName, "");
var props = new FbxNode("Properties70");
if (take.Child("LocalTime") is { Properties.Count: >= 2 } local)
{
props.Children.Add(P("LocalStart", "KTime", "Time", "", local.Prop<long>(0)));
props.Children.Add(P("LocalStop", "KTime", "Time", "", local.Prop<long>(1)));
}
stack.Children.Add(props);
objects.Children.Add(stack);
var layerId = nextId++;
objects.Children.Add(NewObject("AnimationLayer", layerId, "AnimLayer::BaseLayer", ""));
Connect("OO", layerId, stackId);
foreach (var modelTake in take.ChildrenNamed("Model"))
{
if (modelTake.Properties.Count == 0 || modelTake.Properties[0] is not string modelName || !ids.TryGetValue(modelName, out var modelId))
continue;
var transform = modelTake.ChildrenNamed("Channel").FirstOrDefault(ch => ch.Properties.Count > 0 && ch.Properties[0] as string == "Transform");
if (transform is null)
continue;
foreach (var channel in transform.ChildrenNamed("Channel"))
{
var which = channel.Properties.Count > 0 ? channel.Properties[0] as string : null;
var property = which switch { "T" => "Lcl Translation", "R" => "Lcl Rotation", "S" => "Lcl Scaling", _ => null };
if (property is null)
continue;
var curveNodeId = nextId++;
var curveNode = NewObject("AnimationCurveNode", curveNodeId, "AnimCurveNode::" + which, "");
var cnProps = new FbxNode("Properties70");
var curves = new List<(string Axis, long Id)>();
foreach (var axisChannel in channel.ChildrenNamed("Channel"))
{
var axis = axisChannel.Properties.Count > 0 ? axisChannel.Properties[0] as string : null;
if (axis is not ("X" or "Y" or "Z"))
continue;
var def = axisChannel.Child("Default") is { Properties.Count: > 0 } d ? d.Prop<double>(0) : 0.0;
cnProps.Children.Add(P("d|" + axis, "Number", "", "A", def));
var (times, values) = ReadKeys(axisChannel.Child("Key"));
if (times.Length == 0)
continue;
var curveId = nextId++;
var curve = NewObject("AnimationCurve", curveId, "AnimCurve::", "");
var kt = new FbxNode("KeyTime");
kt.Properties.Add(times);
var kv = new FbxNode("KeyValueFloat");
kv.Properties.Add(values);
curve.Children.Add(kt);
curve.Children.Add(kv);
objects.Children.Add(curve);
curves.Add((axis, curveId));
}
curveNode.Children.Add(cnProps);
objects.Children.Add(curveNode);
Connect("OO", curveNodeId, layerId);
Connect("OP", curveNodeId, modelId, property);
foreach (var (axis, curveId) in curves)
Connect("OP", curveId, curveNodeId, "d|" + axis);
}
}
}
result.Children.Add(objects);
result.Children.Add(connections);
return result;
}
/// <summary>
/// FBX 6 key list: <c>time, value, interpolation[, extra...]</c> per key. Times are integer
/// ticks that only grow; everything between a value and the next such time (interpolation
/// letters, tangents) is skipped.
/// </summary>
public static (long[] Times, float[] Values) ReadKeys(FbxNode? key)
{
if (key is null)
return (Array.Empty<long>(), Array.Empty<float>());
var p = key.Properties;
var times = new List<long>();
var values = new List<float>();
var i = 0;
while (i < p.Count)
{
if (p[i] is long or int && i + 1 < p.Count && IsNumber(p[i + 1]))
{
var t = Convert.ToInt64(p[i], System.Globalization.CultureInfo.InvariantCulture);
if (times.Count == 0 || t > times[^1])
{
times.Add(t);
values.Add((float)Convert.ToDouble(p[i + 1], System.Globalization.CultureInfo.InvariantCulture));
i += 2;
continue;
}
}
i++;
}
return (times.ToArray(), values.ToArray());
}
private static bool IsNumber(object o) => o is long or int or double or float or short;
private static bool IsGeometryChild(string name)
=> name is "Vertices" or "PolygonVertexIndex" or "Edges" or "GeometryVersion" or "Layer" or "Shape"
|| name.StartsWith("LayerElement", StringComparison.Ordinal);
private static string NameOf(string full)
{
var i = full.IndexOf("::", StringComparison.Ordinal);
return i >= 0 ? full[(i + 2)..] : full;
}
private static FbxNode NewObject(string type, long id, string fullName, string subClass)
{
var node = new FbxNode(type);
node.Properties.Add(id);
node.Properties.Add(fullName);
node.Properties.Add(subClass);
return node;
}
private static FbxNode P(string name, string type, string label, string flags, params object[] values)
{
var p = new FbxNode("P");
p.Properties.Add(name);
p.Properties.Add(type);
p.Properties.Add(label);
p.Properties.Add(flags);
p.Properties.AddRange(values);
return p;
}
/// <summary>Copies <paramref name="source"/>'s Properties60 into a Properties70 block on <paramref name="target"/>.</summary>
private static FbxNode WithProperties70(FbxNode target, FbxNode source)
{
var block = new FbxNode("Properties70");
foreach (var property in source.Child("Properties60")?.ChildrenNamed("Property") ?? Enumerable.Empty<FbxNode>())
{
// FBX 6: name, type, flags, values... FBX 7: name, type, label, flags, values...
if (property.Properties.Count < 2 || property.Properties[0] is not string name)
continue;
var p = new FbxNode("P");
p.Properties.Add(name);
p.Properties.Add(property.Properties[1]);
p.Properties.Add("");
p.Properties.AddRange(property.Properties.Skip(2));
if (property.Properties.Count == 2)
p.Properties.Add("");
block.Children.Add(p);
}
target.Children.Add(block);
return target;
}
}