s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=notpointless.chomnr_text_to_animation&take=20
Showing code results for query:
*
(188 total matches found)
Editor
library
// Vendored from humanoid-retargeter (Code/HumanoidRetargeter/Formats/Fbx/FbxBinaryWriter.cs).
#nullable enable annotations
using System;
using System.Buffers.Binary;
using System.IO;
using System.Text;
namespace TextToAnimation.Editor.Formats.Fbx;
/// <summary>
/// Serializes an <see cref="FbxNode"/> tree back to binary FBX (version 7400 layout —
/// u32 header fields, universally readable). The inverse of
/// <see cref="FbxTokenizer.Parse"/>: a tree parsed from a 7.x binary file and written
/// here re-parses to an identical tree (arrays are written uncompressed; zlib-encoded
/// inputs therefore round-trip by VALUE, not byte-for-byte).
/// </summary>
/// <remarks>
/// Used by <see cref="FbxBindPoseFixer"/> to persist repaired node transforms. The footer
/// is written the way Blender's exporter does: a fixed 16-byte watermark (importers treat
/// it as opaque), zero padding to a 16-byte boundary, the version echo, 120 zero bytes and
/// the closing magic. The FBX SDK computes a content hash here, but every consumer we
/// target (s&box, Blender, assimp) ignores it.
/// </remarks>
public static class FbxBinaryWriter
{
private const uint Version = 7400;
private static readonly byte[] HeaderMagic =
"Kaydara FBX Binary \0\x1a\0"u8.ToArray();
// Blender's fbx_binary.py FOOT_ID + closing magic bytes.
private static readonly byte[] FooterWatermark =
{
0xfa, 0xbc, 0xab, 0x09, 0xd0, 0xc8, 0xd4, 0x66, 0xb1, 0x76, 0xfb, 0x83, 0x1c, 0xf7, 0x26, 0x7e,
};
private static readonly byte[] FooterMagic =
{
0xf8, 0x5a, 0x8c, 0x6a, 0xde, 0xf5, 0xd9, 0x7e, 0xec, 0xe9, 0x0c, 0x6e, 0x0c, 0xc0, 0x00, 0x00,
};
/// <summary>
/// Serializes <paramref name="root"/> (a virtual root whose children are the top-level
/// document nodes, as produced by <see cref="FbxTokenizer.Parse"/>).
/// </summary>
public static byte[] Write(FbxNode root)
{
ArgumentNullException.ThrowIfNull(root);
using var ms = new MemoryStream();
ms.Write(HeaderMagic);
WriteU32(ms, Version);
foreach (var child in root.Children)
WriteNode(ms, child);
WriteNullRecord(ms);
WriteFooter(ms);
return ms.ToArray();
}
// ------------------------------------------------------------------ nodes
private static void WriteNode(MemoryStream ms, FbxNode node)
{
long headerAt = ms.Position;
// Placeholder header: endOffset, numProps, propListLen (patched after the body).
WriteU32(ms, 0);
WriteU32(ms, (uint)node.Properties.Count);
WriteU32(ms, 0);
var nameBytes = Encoding.ASCII.GetBytes(node.Name);
if (nameBytes.Length > byte.MaxValue)
throw new FormatException($"FBX write: node name too long ({node.Name.Length} chars).");
ms.WriteByte((byte)nameBytes.Length);
ms.Write(nameBytes);
long propsAt = ms.Position;
foreach (var p in node.Properties)
WriteProperty(ms, p, node.Name);
long propsLen = ms.Position - propsAt;
if (node.Children.Count > 0)
{
foreach (var child in node.Children)
WriteNode(ms, child);
WriteNullRecord(ms);
}
long endAt = ms.Position;
ms.Position = headerAt;
WriteU32(ms, checked((uint)endAt));
WriteU32(ms, (uint)node.Properties.Count);
WriteU32(ms, checked((uint)propsLen));
ms.Position = endAt;
}
private static void WriteNullRecord(MemoryStream ms)
{
Span<byte> zeros = stackalloc byte[13];
zeros.Clear();
ms.Write(zeros);
}
// ------------------------------------------------------------------ properties
private static void WriteProperty(MemoryStream ms, object value, string owner)
{
switch (value)
{
case short y:
ms.WriteByte((byte)'Y');
WriteI16(ms, y);
break;
case bool c:
ms.WriteByte((byte)'C');
ms.WriteByte(c ? (byte)1 : (byte)0);
break;
case int i:
ms.WriteByte((byte)'I');
WriteI32(ms, i);
break;
case float f:
ms.WriteByte((byte)'F');
WriteF32(ms, f);
break;
case double d:
ms.WriteByte((byte)'D');
WriteF64(ms, d);
break;
case long l:
ms.WriteByte((byte)'L');
WriteI64(ms, l);
break;
case float[] fa:
WriteArrayHeader(ms, 'f', fa.Length, 4);
foreach (var x in fa)
WriteF32(ms, x);
break;
case double[] da:
WriteArrayHeader(ms, 'd', da.Length, 8);
foreach (var x in da)
WriteF64(ms, x);
break;
case long[] la:
WriteArrayHeader(ms, 'l', la.Length, 8);
foreach (var x in la)
WriteI64(ms, x);
break;
case int[] ia:
WriteArrayHeader(ms, 'i', ia.Length, 4);
foreach (var x in ia)
WriteI32(ms, x);
break;
case bool[] ba:
WriteArrayHeader(ms, 'b', ba.Length, 1);
foreach (var x in ba)
ms.WriteByte(x ? (byte)1 : (byte)0);
break;
case string s:
{
ms.WriteByte((byte)'S');
var bytes = Encoding.UTF8.GetBytes(s);
WriteU32(ms, (uint)bytes.Length);
ms.Write(bytes);
break;
}
case byte[] r:
ms.WriteByte((byte)'R');
WriteU32(ms, (uint)r.Length);
ms.Write(r);
break;
default:
throw new FormatException(
$"FBX write: node '{owner}': unsupported property CLR type {value?.GetType().Name ?? "null"}.");
}
}
private static void WriteArrayHeader(MemoryStream ms, char code, int count, int elemSize)
{
ms.WriteByte((byte)code);
WriteU32(ms, (uint)count);
WriteU32(ms, 0); // encoding 0 = uncompressed
WriteU32(ms, checked((uint)(count * elemSize)));
}
// ------------------------------------------------------------------ footer
private static void WriteFooter(MemoryStream ms)
{
ms.Write(FooterWatermark);
// Zero-pad so the version echo starts 16-aligned (Blender pads at least 1 byte).
int pad = (int)(16 - ms.Position % 16);
for (int i = 0; i < pad; i++)
ms.WriteByte(0);
WriteU32(ms, Version);
Span<byte> zeros = stackalloc byte[120];
zeros.Clear();
ms.Write(zeros);
ms.Write(FooterMagic);
}
// ------------------------------------------------------------------ primitives
private static void WriteU32(MemoryStream ms, uint v)
{
Span<byte> b = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(b, v);
ms.Write(b);
}
private static void WriteI16(MemoryStream ms, short v)
{
Span<byte> b = stackalloc byte[2];
BinaryPrimitives.WriteInt16LittleEndian(b, v);
ms.Write(b);
}
private static void WriteI32(MemoryStream ms, int v)
{
Span<byte> b = stackalloc byte[4];
BinaryPrimitives.WriteInt32LittleEndian(b, v);
ms.Write(b);
}
private static void WriteI64(MemoryStream ms, long v)
{
Span<byte> b = stackalloc byte[8];
BinaryPrimitives.WriteInt64LittleEndian(b, v);
ms.Write(b);
}
private static void WriteF32(MemoryStream ms, float v)
{
Span<byte> b = stackalloc byte[4];
BinaryPrimitives.WriteSingleLittleEndian(b, v);
ms.Write(b);
}
private static void WriteF64(MemoryStream ms, double v)
{
Span<byte> b = stackalloc byte[8];
BinaryPrimitives.WriteDoubleLittleEndian(b, v);
ms.Write(b);
}
}
Editor
library
// Vendored from humanoid-retargeter (Code/HumanoidRetargeter/Formats/Fbx) - its FBX reader, used for material/texture detection.
using System;
using System.Collections.Generic;
using System.Linq;
#nullable enable annotations
using System;
using System.Linq;
namespace TextToAnimation.Editor.Formats.Fbx;
using Vector3 = System.Numerics.Vector3;
/// <summary>Authored FBX colors, independent of material and texture names.</summary>
public static class FbxMaterialColor
{
public static Vector3? Read(FbxNode material)
{
var properties = material.Child("Properties70")?.Children;
var color = properties?.FirstOrDefault(p => p.Properties.FirstOrDefault() is "DiffuseColor")
?? properties?.FirstOrDefault(p => p.Properties.FirstOrDefault() is "Diffuse");
if (color is null || color.Properties.Count < 7)
return null;
var value = new Vector3(color.Prop<float>(4), color.Prop<float>(5), color.Prop<float>(6));
// The legacy Diffuse property already includes DiffuseFactor.
var factor = properties?.FirstOrDefault(p => p.Properties.FirstOrDefault() is "DiffuseFactor");
if (color.Properties[0] is "DiffuseColor" && factor?.Properties.Count >= 5)
value *= factor.Prop<float>(4);
return float.IsFinite(value.X) && float.IsFinite(value.Y) && float.IsFinite(value.Z)
? Vector3.Clamp(value, Vector3.Zero, Vector3.One) : null;
}
public static bool HasVertexColors(FbxNode geometry)
=> geometry.ChildrenNamed("LayerElementColor").Any(layer =>
layer.Child("Colors")?.Properties.FirstOrDefault() is double[] colors
&& colors.Where((_, i) => i % 4 != 3).Any(c => Math.Abs(c - 1) > 1e-6));
}
Editor
library
// Vendored from humanoid-retargeter (Code/HumanoidRetargeter/Formats/Fbx) - its FBX reader, used for material/texture detection.
using System;
using System.Collections.Generic;
using System.Linq;
#nullable enable annotations
namespace TextToAnimation.Editor.Formats.Fbx;
/// <summary>
/// A single node of an FBX document tree (binary or ASCII): a name, a flat list of
/// typed properties, and nested child nodes.
///
/// Property values are stored as the closest CLR type to what the file contained:
/// <list type="bullet">
/// <item><c>short</c> ('Y'), <c>bool</c> ('C'), <c>int</c> ('I'), <c>float</c> ('F'),
/// <c>double</c> ('D'), <c>long</c> ('L')</item>
/// <item><c>float[]</c> ('f'), <c>double[]</c> ('d'), <c>long[]</c> ('l'),
/// <c>int[]</c> ('i'), <c>bool[]</c>-as-<c>byte[]</c> ('b')</item>
/// <item><c>string</c> ('S' — kept raw, may contain the <c>\x00\x01</c> name/class
/// separator; see <see cref="SplitName"/>), <c>byte[]</c> ('R')</item>
/// </list>
/// ASCII files store numbers only as <c>long</c> / <c>double</c> (and arrays as
/// <c>long[]</c> / <c>double[]</c>), so the typed accessors below convert tolerantly.
/// </summary>
public sealed class FbxNode
{
public string Name { get; }
public List<object> Properties { get; } = new();
public List<FbxNode> Children { get; } = new();
public FbxNode(string name) => Name = name;
/// <summary>First child with the given name, or null.</summary>
public FbxNode? Child(string name)
{
foreach (var c in Children)
if (c.Name == name)
return c;
return null;
}
/// <summary>All children with the given name, in document order.</summary>
public IEnumerable<FbxNode> ChildrenNamed(string name)
{
foreach (var c in Children)
if (c.Name == name)
yield return c;
}
/// <summary>
/// Property <paramref name="i"/> converted to <typeparamref name="T"/>.
/// Numeric scalars convert tolerantly across widths (e.g. an 'I' i32 read as long);
/// anything else must match the stored type exactly.
/// </summary>
public T Prop<T>(int i)
{
object v = RawProp(i);
if (v is T t)
return t;
var target = typeof(T);
// s&box whitelist: Type.IsPrimitive is banned; enumerate the convertible targets.
if (v is IConvertible && (ConvertTargets.Contains(target) || target == typeof(string)))
{
try
{
return (T)Convert.ChangeType(v, target, System.Globalization.CultureInfo.InvariantCulture);
}
// ArithmeticException covers OverflowException, which is not s&box-whitelisted
catch (Exception ex) when (ex is InvalidCastException or ArithmeticException or FormatException)
{
throw new FormatException(
$"FBX node '{Name}': property {i} is {v.GetType().Name}, not convertible to {target.Name}.", ex);
}
}
throw new FormatException(
$"FBX node '{Name}': property {i} is {v.GetType().Name}, expected {target.Name}.");
}
/// <summary>Property <paramref name="i"/> as a double array (converts f/l/i/b arrays).</summary>
public double[] AsDoubleArray(int i) => RawProp(i) switch
{
double[] d => d,
float[] f => Array.ConvertAll(f, x => (double)x),
long[] l => Array.ConvertAll(l, x => (double)x),
int[] n => Array.ConvertAll(n, x => (double)x),
byte[] b => Array.ConvertAll(b, x => (double)x),
bool[] o => Array.ConvertAll(o, x => x ? 1.0 : 0.0),
var v => throw TypeError(i, v, "double[]"),
};
/// <summary>Property <paramref name="i"/> as a float array (converts d/l/i/b arrays).</summary>
public float[] AsFloatArray(int i) => RawProp(i) switch
{
float[] f => f,
double[] d => Array.ConvertAll(d, x => (float)x),
long[] l => Array.ConvertAll(l, x => (float)x),
int[] n => Array.ConvertAll(n, x => (float)x),
byte[] b => Array.ConvertAll(b, x => (float)x),
bool[] o => Array.ConvertAll(o, x => x ? 1f : 0f),
var v => throw TypeError(i, v, "float[]"),
};
/// <summary>Property <paramref name="i"/> as a long array (converts i/b; d/f if integral).</summary>
public long[] AsLongArray(int i) => RawProp(i) switch
{
long[] l => l,
int[] n => Array.ConvertAll(n, x => (long)x),
byte[] b => Array.ConvertAll(b, x => (long)x),
bool[] o => Array.ConvertAll(o, x => x ? 1L : 0L),
double[] d => Array.ConvertAll(d, x => checked((long)x)),
float[] f => Array.ConvertAll(f, x => checked((long)x)),
var v => throw TypeError(i, v, "long[]"),
};
/// <summary>Property <paramref name="i"/> as an int array (converts b; l/d/f narrowing-checked).</summary>
public int[] AsIntArray(int i) => RawProp(i) switch
{
int[] n => n,
long[] l => Array.ConvertAll(l, x => checked((int)x)),
byte[] b => Array.ConvertAll(b, x => (int)x),
bool[] o => Array.ConvertAll(o, x => x ? 1 : 0),
double[] d => Array.ConvertAll(d, x => checked((int)x)),
float[] f => Array.ConvertAll(f, x => checked((int)x)),
var v => throw TypeError(i, v, "int[]"),
};
/// <summary>Property <paramref name="i"/> as raw bytes ('R' blobs or 'b' bool arrays).</summary>
public byte[] AsByteArray(int i) => RawProp(i) switch
{
byte[] b => b,
var v => throw TypeError(i, v, "byte[]"),
};
/// <summary>Property <paramref name="i"/> as a string (raw 'S' content, separators intact).</summary>
public string AsString(int i) => RawProp(i) switch
{
string s => s,
var v => throw TypeError(i, v, "string"),
};
/// <summary>
/// Splits an FBX object name into (name, class).
/// Binary files store <c>"Name\x00\x01Class"</c> (e.g. <c>"mixamorig:Hips\x00\x01Model"</c>);
/// ASCII files store <c>"Class::Name"</c> (e.g. <c>"Model::pelvis"</c>).
/// A plain string with neither separator yields (name, "").
/// </summary>
public static (string Name, string Class) SplitName(string raw)
{
int bin = raw.IndexOf("\0\x01", StringComparison.Ordinal);
if (bin >= 0)
return (raw[..bin], raw[(bin + 2)..]);
int ascii = raw.IndexOf("::", StringComparison.Ordinal);
if (ascii >= 0)
return (raw[(ascii + 2)..], raw[..ascii]);
return (raw, "");
}
/// <summary>Primitive scalar types <see cref="Prop{T}"/> converts to (whitelist-safe IsPrimitive substitute).</summary>
private static readonly HashSet<Type> ConvertTargets = new()
{
typeof(bool), typeof(byte), typeof(sbyte), typeof(short), typeof(ushort),
typeof(int), typeof(uint), typeof(long), typeof(ulong),
typeof(float), typeof(double), typeof(char),
};
private object RawProp(int i)
{
if (i < 0 || i >= Properties.Count)
throw new FormatException(
$"FBX node '{Name}': property index {i} out of range (has {Properties.Count}).");
return Properties[i];
}
private FormatException TypeError(int i, object v, string wanted) =>
new($"FBX node '{Name}': property {i} is {v.GetType().Name}, expected {wanted}.");
}
Editor
library
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading;
namespace TextToAnimation.Editor.Inference.Runtime;
/// <summary>Reads tensor data from the pinned PyTorch ZIP checkpoint format without Python,
/// importing modules, invoking pickle callables, or deserializing executable objects.</summary>
public sealed partial class TorchCheckpoint : IDisposable
{
public sealed record TensorInfo(string Name,string Storage,string Dtype,long StorageLength,long Offset,int[] Shape,long[] Stride);
internal sealed record Symbol(string Module,string Name);
internal sealed record StorageRef(string Key,string Dtype,long Count);
internal sealed record TensorRef(StorageRef Storage,long Offset,int[] Shape,long[] Stride);
readonly ZipArchive archive;
readonly string prefix;
readonly Dictionary<string,TensorInfo> tensors=new(StringComparer.Ordinal);
public IReadOnlyDictionary<string,TensorInfo> Tensors=>tensors;
const int MaximumMetadataBytes=32*1024*1024;
const long MaximumTensorElements=1_000_000_000;
public TorchCheckpoint(string path,string? stateKey=null)
{
archive=ZipFile.OpenRead(path);
try
{
var entries=archive.Entries.Where(e=>e.FullName.EndsWith("/data.pkl",StringComparison.Ordinal)).ToArray();
if(entries.Length!=1)throw new InvalidDataException("Expected exactly one PyTorch data.pkl entry.");
var entry=entries[0];prefix=entry.FullName.Substring(0,entry.FullName.Length-8);
if(entry.Length>MaximumMetadataBytes)throw new InvalidDataException("Checkpoint metadata exceeds the 32 MiB limit.");
if(archive.GetEntry(prefix+"byteorder") is { } byteorder)
{using var r=new StreamReader(byteorder.Open());if(r.ReadToEnd().Trim()!="little")throw new NotSupportedException("Only little-endian checkpoints are supported.");}
using var source=entry.Open();using var memory=new MemoryStream();source.CopyTo(memory);
var root=new DataReader(memory.ToArray()).Read() as Dictionary<object,object?> ?? throw new InvalidDataException("Checkpoint root must be a dictionary.");
// Some inference checkpoints (including ACE) keep separately named
// component dictionaries. Select one explicitly; never traverse optimizer state.
var key=stateKey??(root.ContainsKey("state_dict")?"state_dict":null);
var state=key is null?root:root.TryGetValue(key,out var value)?value as Dictionary<object,object?>:null;
if(state is null)throw new InvalidDataException($"Checkpoint component '{key}' is missing or is not a dictionary.");
foreach(var pair in state)
{
if(pair.Value is not TensorRef t)continue;
if(pair.Key is not string name)throw new InvalidDataException("Tensor names must be strings.");
var info=new TensorInfo(name,t.Storage.Key,t.Storage.Dtype,t.Storage.Count,t.Offset,t.Shape,t.Stride);
Validate(info);tensors.Add(name,info);
}
if(tensors.Count==0)throw new InvalidDataException("No tensor weights in checkpoint.");
}
catch{archive.Dispose();throw;}
}
static long Count(TensorInfo tensor)
{long count=1;foreach(var d in tensor.Shape){if(d<0)throw new InvalidDataException("Negative tensor dimension.");count=checked(count*d);if(count>MaximumTensorElements)throw new InvalidDataException("Tensor exceeds element limit.");}return count;}
void Validate(TensorInfo tensor)
{
if(tensor.Shape.Length>8||tensor.Shape.Length!=tensor.Stride.Length||tensor.Offset<0||tensor.StorageLength<0||tensor.StorageLength>MaximumTensorElements)
throw new InvalidDataException("Invalid tensor dimensions/storage.");
if(tensor.Storage.Length==0||tensor.Storage.Any(c=>!char.IsAsciiDigit(c)))throw new InvalidDataException("Unexpected tensor storage key.");
var count=Count(tensor);long last=tensor.Offset;
for(var i=0;i<tensor.Shape.Length;i++)
{if(tensor.Stride[i]<0)throw new InvalidDataException("Negative tensor stride.");last=checked(last+Math.Max(0,tensor.Shape[i]-1)*tensor.Stride[i]);}
if(count>0&&last>=tensor.StorageLength)throw new InvalidDataException("Tensor view exceeds its storage.");
var storage=archive.GetEntry(prefix+"data/"+tensor.Storage)??throw new InvalidDataException("Missing tensor storage.");
if(storage.Length!=checked(tensor.StorageLength*ElementSize(tensor.Dtype)))throw new InvalidDataException("Tensor storage byte length mismatch.");
}
static int ElementSize(string dtype)=>dtype switch
{"FloatStorage" or "IntStorage"=>4,"DoubleStorage" or "LongStorage"=>8,"HalfStorage" or "BFloat16Storage"=>2,"ByteStorage" or "BoolStorage"=>1,_=>throw new NotSupportedException("Unsupported checkpoint dtype: "+dtype)};
/// <summary>Materializes one tensor, respecting noncontiguous views. Never loads the whole
/// checkpoint into memory. Callers choose which model's tensors to retain.</summary>
public float[] ReadFloat(string name,CancellationToken cancellation=default)
{
var tensor=tensors.TryGetValue(name,out var found)?found:throw new KeyNotFoundException(name);
var count=checked((int)Count(tensor));var bytes=checked((int)(tensor.StorageLength*ElementSize(tensor.Dtype)));
cancellation.ThrowIfCancellationRequested();var storage=new byte[bytes];
using(var input=archive.GetEntry(prefix+"data/"+tensor.Storage)!.Open())
{
var offset=0;while(offset<storage.Length){cancellation.ThrowIfCancellationRequested();var n=input.Read(storage,offset,Math.Min(65536,storage.Length-offset));if(n==0)throw new EndOfStreamException();offset+=n;}
}
var output=new float[count];var elementSize=ElementSize(tensor.Dtype);
long contiguousStride=1;var contiguous=true;
for(var dim=tensor.Shape.Length-1;dim>=0;dim--)
{if(tensor.Shape[dim]>1&&tensor.Stride[dim]!=contiguousStride)contiguous=false;contiguousStride*=tensor.Shape[dim];}
if(contiguous&&tensor.Dtype=="FloatStorage"&&BitConverter.IsLittleEndian)
{
cancellation.ThrowIfCancellationRequested();
Buffer.BlockCopy(storage,checked((int)(tensor.Offset*4)),output,0,checked(count*4));
cancellation.ThrowIfCancellationRequested();return output;
}
for(var i=0;i<count;i++)
{
if((i&16383)==0)cancellation.ThrowIfCancellationRequested();
long index=tensor.Offset;var remainder=i;
for(var dim=tensor.Shape.Length-1;dim>=0;dim--){index+=(remainder%tensor.Shape[dim])*tensor.Stride[dim];remainder/=tensor.Shape[dim];}
var at=checked((int)(index*elementSize));
output[i]=tensor.Dtype switch
{
"FloatStorage"=>BitConverter.ToSingle(storage,at),"DoubleStorage"=>(float)BitConverter.ToDouble(storage,at),
"HalfStorage"=>(float)BitConverter.UInt16BitsToHalf(BitConverter.ToUInt16(storage,at)),
"BFloat16Storage"=>BitConverter.Int32BitsToSingle(BitConverter.ToUInt16(storage,at)<<16),
"IntStorage"=>BitConverter.ToInt32(storage,at),"LongStorage"=>BitConverter.ToInt64(storage,at),
"ByteStorage" or "BoolStorage"=>storage[at],_=>throw new NotSupportedException(tensor.Dtype)
};
}
return output;
}
public void Dispose()=>archive.Dispose();
internal sealed class DataReader
{
readonly BinaryReader reader;
readonly List<object?> stack=new();
readonly Dictionary<int,object?> memo=new();
static readonly object Mark=new();
int operations;
public int Position=>checked((int)reader.BaseStream.Position);
public DataReader(byte[] bytes,int offset=0){reader=new BinaryReader(new MemoryStream(bytes),Encoding.UTF8);reader.BaseStream.Position=offset;}
object? Pop(){if(stack.Count==0)throw new InvalidDataException("Empty pickle stack.");var value=stack[^1];stack.RemoveAt(stack.Count-1);return value;}
object? Peek()=>stack.Count==0?throw new InvalidDataException("Empty pickle stack."):stack[^1];
object?[] MarkItems()
{
var index=stack.LastIndexOf(Mark);if(index<0)throw new InvalidDataException("Missing pickle mark.");
var values=stack.Skip(index+1).ToArray();stack.RemoveRange(index,stack.Count-index);return values;
}
string Line()
{var bytes=new List<byte>();byte b;while((b=reader.ReadByte())!=10){if(bytes.Count>=4096)throw new InvalidDataException("Pickle identifier too long.");bytes.Add(b);}return Encoding.UTF8.GetString(bytes.ToArray());}
string Text(int length)
{if(length<0||length>MaximumMetadataBytes||length>reader.BaseStream.Length-reader.BaseStream.Position)throw new InvalidDataException("Invalid pickle string length.");return Encoding.UTF8.GetString(reader.ReadBytes(length));}
static long Integer(object? value)=>value switch{int i=>i,long l=>l,_=>throw new InvalidDataException("Expected integer.")};
static object?[] Tuple(object? value)=>value as object?[]??throw new InvalidDataException("Expected tuple.");
static Dictionary<object,object?> Dict(object? value)=>value as Dictionary<object,object?>??throw new InvalidDataException("Expected dictionary.");
static void Set(Dictionary<object,object?> dictionary,object? key,object? value)
{
// Training checkpoints contain integer-keyed optimizer states. Keep those
// as inert data, with numeric keys distinct from strings such as "0".
var normalized=key switch{string s=>(object)s,int i=>(long)i,long l=>l,_=>throw new InvalidDataException("Only string and integer dictionary keys are supported.")};
dictionary[normalized]=value;
}
public object? Read(bool requireEnd=true)
{
while(reader.BaseStream.Position<reader.BaseStream.Length)
{
if(++operations>2_000_000||stack.Count>100_000||memo.Count>100_000)throw new InvalidDataException("Checkpoint metadata complexity limit.");
var op=reader.ReadByte();
switch(op)
{
case 0x80:var protocol=reader.ReadByte();if(protocol>5)throw new NotSupportedException("Pickle protocol "+protocol);break;
case (byte)'.':if(stack.Count!=1||(requireEnd&&reader.BaseStream.Position!=reader.BaseStream.Length))throw new InvalidDataException("Invalid pickle termination.");return Pop();
case (byte)'(':stack.Add(Mark);break;
case (byte)')':stack.Add(System.Array.Empty<object?>());break;
case (byte)'}':stack.Add(new Dictionary<object,object?>());break;
case (byte)']':stack.Add(new List<object?>());break;
case (byte)'N':stack.Add(null);break;
case 0x88:stack.Add(true);break;
case 0x89:stack.Add(false);break;
case (byte)'K':stack.Add((int)reader.ReadByte());break;
case (byte)'M':stack.Add((int)reader.ReadUInt16());break;
case (byte)'J':stack.Add(reader.ReadInt32());break;
case (byte)'G':var f=reader.ReadBytes(8);if(f.Length!=8)throw new EndOfStreamException();System.Array.Reverse(f);stack.Add(BitConverter.ToDouble(f));break;
case (byte)'X':stack.Add(Text(reader.ReadInt32()));break;
case 0x8c:stack.Add(Text(reader.ReadByte()));break;
case 0x95:var frame=reader.ReadUInt64();if(frame>(ulong)(reader.BaseStream.Length-reader.BaseStream.Position))throw new InvalidDataException("Truncated pickle frame.");break;
case (byte)'q':memo[reader.ReadByte()]=Peek();break;
case (byte)'r':memo[reader.ReadInt32()]=Peek();break;
case 0x94:memo[memo.Count]=Peek();break;
case (byte)'h':stack.Add(memo[reader.ReadByte()]);break;
case (byte)'j':stack.Add(memo[reader.ReadInt32()]);break;
case (byte)'t':stack.Add(MarkItems());break;
case 0x85:stack.Add(new[]{Pop()});break;
case 0x86:var second=Pop();stack.Add(new[]{Pop(),second});break;
case 0x87:var third=Pop();second=Pop();stack.Add(new[]{Pop(),second,third});break;
case (byte)'s':var value=Pop();var key=Pop();Set(Dict(Peek()),key,value);break;
case (byte)'u':var pairs=MarkItems();if(pairs.Length%2!=0)throw new InvalidDataException("Odd dictionary entries.");var d=Dict(Peek());for(var i=0;i<pairs.Length;i+=2)Set(d,pairs[i],pairs[i+1]);break;
case (byte)'a':value=Pop();((List<object?>)Peek()!).Add(value);break;
case (byte)'e':var items=MarkItems();((List<object?>)Peek()!).AddRange(items);break;
case (byte)'c':stack.Add(AllowedSymbol(Line(),Line()));break;
case 0x93:var name=Pop() as string??throw new InvalidDataException();var module=Pop() as string??throw new InvalidDataException();stack.Add(AllowedSymbol(module,name));break;
case (byte)'Q':
var storage=Tuple(Pop());
if(storage.Length<5||storage[0] as string!="storage"||storage[1] is not Symbol type||type.Module!="torch"||storage[2] is not string storageKey)
throw new InvalidDataException("Unsupported persistent pickle reference.");
if(storage.Length>5&&storage[5] is not null)throw new NotSupportedException("Legacy storage aliases are not supported.");
stack.Add(new StorageRef(storageKey,type.Name,Integer(storage[4])));break;
case (byte)'R':
var arguments=Tuple(Pop());var symbol=Pop() as Symbol??throw new InvalidDataException("Unsupported pickle callable.");
if(symbol==new Symbol("collections","OrderedDict")&&arguments.Length==0)stack.Add(new Dictionary<object,object?>());
else if(symbol==new Symbol("collections","Counter")&&arguments.Length<=1)
stack.Add(arguments.Length==0?new Dictionary<object,object?>():new Dictionary<object,object?>(Dict(arguments[0])));
else if(symbol.Module=="torch._utils"&&(symbol.Name=="_rebuild_tensor_v2"||symbol.Name=="_rebuild_tensor")&&arguments.Length>=4&&arguments[0] is StorageRef sr)
stack.Add(new TensorRef(sr,Integer(arguments[1]),Tuple(arguments[2]).Select(x=>checked((int)Integer(x))).ToArray(),Tuple(arguments[3]).Select(Integer).ToArray()));
else throw new InvalidDataException("Unsupported checkpoint construction: "+symbol);
break;
case (byte)'b':
var metadata=Dict(Pop());_ = Dict(Peek());
if(metadata.Keys.Any(k=>!Equals(k,"_metadata")))throw new InvalidDataException("Unsupported checkpoint object state.");
break;
default:throw new NotSupportedException($"Checkpoint pickle opcode 0x{op:X2} at {reader.BaseStream.Position-1} is unsupported.");
}
}
throw new EndOfStreamException("Missing pickle STOP.");
}
static Symbol AllowedSymbol(string module,string name)
{
if(module=="collections"&&(name=="OrderedDict"||name=="Counter")||module=="torch._utils"&&(name=="_rebuild_tensor_v2"||name=="_rebuild_tensor"))return new(module,name);
if(module=="torch"){_ = ElementSize(name);return new(module,name);}
throw new InvalidDataException("Executable/unsupported checkpoint symbol rejected: "+module+"."+name);
}
}
}
Editor
library
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using TextToAnimation.Editor.Inference.Onnx;
namespace TextToAnimation.Editor.Inference.UniMate;
/// <summary>
/// flan-t5-base encoder (ONNX, run by the managed runtime) with UniMate's pooling: the mean of
/// <c>last_hidden_state</c> over the non-padding tokens. The empty prompt pools to zero, which is the
/// unconditional (classifier-free) branch. Results are cached per text.
/// </summary>
public sealed class T5TextEncoder
{
public const int Width = 768;
readonly OnnxSession _session;
readonly T5Tokenizer _tokenizer;
readonly ConcurrentDictionary<string, float[]> _cache = new( StringComparer.Ordinal );
readonly object _runLock = new();
public T5TextEncoder( OnnxSession session, T5Tokenizer tokenizer )
{
_session = session;
_tokenizer = tokenizer;
}
public T5Tokenizer Tokenizer => _tokenizer;
/// <summary>Pooled 768-d embedding of a text.</summary>
public float[] Encode( string text, CancellationToken token = default )
{
text ??= "";
if ( text.Length == 0 ) return new float[Width];
if ( _cache.TryGetValue( text, out var cached ) ) return cached;
var ids = _tokenizer.Encode( text );
var input = Tensor.Int64( new[] { 1, ids.Count }, ids.Select( i => (long)i ).ToArray() );
var mask = Tensor.Int64( new[] { 1, ids.Count }, Enumerable.Repeat( 1L, ids.Count ).ToArray() );
Tensor hidden;
lock ( _runLock )
hidden = _session.Run( new Dictionary<string, Tensor> { ["input_ids"] = input, ["attention_mask"] = mask }, token )["last_hidden_state"];
var pooled = new float[Width];
for ( var t = 0; t < ids.Count; t++ )
for ( var c = 0; c < Width; c++ ) pooled[c] += hidden.F[t * Width + c];
for ( var c = 0; c < Width; c++ ) pooled[c] /= ids.Count;
_cache[text] = pooled;
return pooled;
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using TextToAnimation.Editor.Inference.Onnx;
namespace TextToAnimation.Editor.Inference.UniMate;
/// <summary>Files of an installed UniMate model.</summary>
public sealed record UniMateFiles( string Directory )
{
public string WeightBlob => Path.Combine( Directory, $"unimate_v2_g{UniMateGraphBuilder.FormatVersion}.weights" );
public string T5Encoder => Path.Combine( Directory, "t5_encoder.onnx" );
public string T5Tokenizer => Path.Combine( Directory, "t5_tokenizer.json" );
public string Graphs => Path.Combine( Directory, $"graphs_g{UniMateGraphBuilder.FormatVersion}" );
}
/// <summary>Conditioning tensors of one skeleton, computed once (the prepare graph's outputs).</summary>
public sealed class PreparedSkeleton
{
public required UniMateSkeleton Skeleton { get; init; }
public required Dictionary<string, Tensor> Tensors { get; init; }
}
/// <summary>
/// ODE integrators for the flow: Euler (1 model call per step, 1st order), Heun (2 calls, 2nd order) and
/// Adams-Bashforth 2 (1 call per step, 2nd order: reuses the previous step's velocity).
/// Dopri5: upstream's text-to-motion sampler (Sampler.sample_ode: torchdiffeq dopri5, adaptive, rtol 1e-3,
/// atol 1e-6, t from 0 to 1) - integrates the flow to convergence.
/// </summary>
public enum Integrator { Euler, Heun, AdamsBashforth2, Dopri5 }
/// <summary>Sampling settings.</summary>
public sealed class SampleSettings
{
public int Steps { get; init; } = 24;
public float Guidance { get; init; } = 3f;
public Integrator Method { get; init; } = Integrator.Euler;
/// <summary>
/// Time-grid shift (1 = uniform). Above 1 the steps crowd towards the noisy start of the flow, where the
/// velocity changes fastest: t' = s t / (1 + (s - 1) t).
/// </summary>
public float TimeShift { get; init; } = 1f;
/// <summary>Start time of the flow (0 = pure noise; >0 starts from a noised copy of <see cref="Known"/>: variations).</summary>
public float StartTime { get; init; }
/// <summary>Known normalised motion (J,12,T) for constraints / variations.</summary>
public float[] Known { get; init; }
/// <summary>(J,12,T) mask of values held to <see cref="Known"/> (replacement sampling).</summary>
public bool[] Keep { get; init; }
/// <summary>Dopri5 tolerances (upstream's sample_ode defaults).</summary>
public double RelativeTolerance { get; init; } = 1e-3;
public double AbsoluteTolerance { get; init; } = 1e-6;
}
/// <summary>
/// The UniMate model running on the managed ONNX runtime: the flan-t5 text encoder plus the denoiser's
/// prepare/step graphs (generated in C# per skeleton from the shared weight blob), and the flow-matching
/// sampler with classifier-free guidance and replacement constraints.
/// </summary>
public sealed class UniMateModel
{
public const int Frames = 60;
public const float Fps = 30f;
const int Batch = 2; // conditional + unconditional rows
readonly UniMateFiles _files;
readonly WeightBlob _blob;
readonly UniMateArch _arch = UniMateArch.V2;
public T5TextEncoder Text { get; }
readonly Dictionary<int, (OnnxSession Prepare, OnnxSession Step)> _graphs = new();
readonly object _lock = new();
UniMateModel( UniMateFiles files, WeightBlob blob, T5TextEncoder text )
{
_files = files;
_blob = blob;
Text = text;
}
public static UniMateModel Load( UniMateFiles files, CancellationToken token = default )
{
if ( !WeightBlob.Exists( files.WeightBlob ) ) throw new FileNotFoundException( "The UniMate weights are not prepared.", files.WeightBlob );
var blob = WeightBlob.Open( files.WeightBlob );
token.ThrowIfCancellationRequested();
var text = new T5TextEncoder( OnnxSession.Load( files.T5Encoder, token ), T5Tokenizer.Load( files.T5Tokenizer ) );
return new UniMateModel( files, blob, text );
}
/// <summary>
/// Writes the shared weight blob from checkpoint weights (EMA, PyTorch names): every weight in the layout the
/// graphs use. Done once at install; afterwards graphs for any skeleton are generated without the checkpoint.
/// </summary>
public static void WriteWeights( IReadOnlyDictionary<string, Runtime.WeightTensor> weights, string blobPath )
{
using var blob = WeightBlob.Create( blobPath );
var builder = new UniMateGraphBuilder( weights, UniMateArch.V2, blob );
builder.BuildPrepare( 22, null, null );
builder.BuildStep( Batch, 22, Frames, null, null );
blob.Commit();
}
/// <summary>Builds (or reuses) the graphs for a joint count; graph files are cached on disk.</summary>
(OnnxSession Prepare, OnnxSession Step) Graphs( int joints, CancellationToken token )
{
lock ( _lock )
{
if ( _graphs.TryGetValue( joints, out var g ) ) return g;
Directory.CreateDirectory( _files.Graphs );
var prepPath = Path.Combine( _files.Graphs, $"prepare_j{joints}.onnx" );
var stepPath = Path.Combine( _files.Graphs, $"step_b{Batch}_j{joints}_t{Frames}.onnx" );
var builder = new UniMateGraphBuilder( null, _arch, _blob );
if ( !File.Exists( prepPath ) ) WriteAtomic( prepPath, builder.BuildPrepare( joints, null, null ) );
if ( !File.Exists( stepPath ) ) WriteAtomic( stepPath, builder.BuildStep( Batch, joints, Frames, null, null ) );
OnnxSession Load( string path )
{
var model = OnnxModel.Parse( File.ReadAllBytes( path ) );
model.BaseDirectory = _files.Directory; // the weight blob lives in the model folder
return new OnnxSession( model, token );
}
// keep at most two skeleton sizes in memory (the step graph holds ~300 MB of weights)
if ( _graphs.Count >= 2 )
{
var evicted = _graphs.Keys.First();
_graphs.Remove( evicted );
if ( _gpu.Remove( evicted, out var program ) ) program.Dispose();
}
g = (Load( prepPath ), Load( stepPath ));
_graphs[joints] = g;
return g;
}
}
static void WriteAtomic( string path, byte[] bytes )
{
File.WriteAllBytes( path + ".tmp", bytes );
File.Move( path + ".tmp", path, overwrite: true );
}
/// <summary>Runs the prepare graph for a skeleton (joint names are embedded with T5).</summary>
public PreparedSkeleton Prepare( UniMateSkeleton s, UniMateStats stats, CancellationToken token )
=> Prepare( s, ConditioningInputs( s, stats, token ), token );
/// <summary>Runs the prepare graph on given conditioning inputs (see <see cref="ConditioningInputs"/>).</summary>
public PreparedSkeleton Prepare( UniMateSkeleton s, Dictionary<string, Tensor> conditioning, CancellationToken token )
{
var (prepare, _) = Graphs( s.Count, token );
var outputs = prepare.Run( conditioning, token );
return new PreparedSkeleton { Skeleton = s, Tensors = outputs };
}
/// <summary>
/// The skeleton conditioning the network gets (upstream create_sample_condition: normalised T-pose features with
/// identity rotations, the parents' copies, relations, graph distances, depths, spectral features, joint-name
/// embeddings).
/// </summary>
public Dictionary<string, Tensor> ConditioningInputs( UniMateSkeleton s, UniMateStats stats, CancellationToken token )
{
var J = s.Count;
var tpos = new float[J * 12]; var parentRow = new float[J * 12];
for ( var j = 0; j < J; j++ )
{
var row = new double[12];
row[0] = s.TPose[j].X; row[1] = s.TPose[j].Y; row[2] = s.TPose[j].Z;
row[3] = 1; row[7] = 1; // identity 6D
for ( var c = 0; c < 12; c++ ) tpos[j * 12 + c] = stats.Normalize( j, c, row[c] );
}
for ( var j = 0; j < J; j++ )
{
var src = s.Parents[j] < 0 ? j : s.Parents[j];
Array.Copy( tpos, src * 12, parentRow, j * 12, 12 );
}
var rel = new long[J * J]; var dist = new long[J * J];
for ( var i = 0; i < J; i++ ) for ( var k = 0; k < J; k++ ) { rel[i * J + k] = s.Relations[i, k]; dist[i * J + k] = s.GraphDist[i, k]; }
var spec = new float[J * 8];
for ( var j = 0; j < J; j++ ) for ( var c = 0; c < 8; c++ ) spec[j * 8 + c] = s.Spectral[j, c];
var names = new float[J * T5TextEncoder.Width];
for ( var j = 0; j < J; j++ ) Array.Copy( Text.Encode( s.CleanNames[j], token ), 0, names, j * T5TextEncoder.Width, T5TextEncoder.Width );
return new Dictionary<string, Tensor>
{
["tpos"] = Tensor.Float( new[] { J, 12 }, tpos ),
["tpos_parent"] = Tensor.Float( new[] { J, 12 }, parentRow ),
["joint_rel"] = Tensor.Int64( new[] { J, J }, rel ),
["graph_dist"] = Tensor.Int64( new[] { J, J }, dist ),
["depth"] = Tensor.Int64( new[] { J }, s.Depths.ToArray() ),
["spectral"] = Tensor.Float( new[] { J, 8 }, spec ),
["name_emb"] = Tensor.Float( new[] { J, T5TextEncoder.Width }, names ),
};
}
/// <summary>
/// Flow-matching sample: x(0) = noise, dx/dt = v(x,t) with classifier-free guidance, integrated to t = 1.
/// Values under <see cref="SampleSettings.Keep"/> follow the known motion along the interpolant
/// (replacement sampling, as upstream). Returns the normalised motion (J,12,T).
/// </summary>
public float[] Sample( PreparedSkeleton prep, float[] caption, float[] noise, SampleSettings settings,
Action<float> progress, CancellationToken token )
{
var J = prep.Skeleton.Count;
var n = J * 12 * Frames;
if ( noise.Length != n ) throw new ArgumentException( "Noise has the wrong size." );
var (_, step) = Graphs( J, token );
var feed = new Dictionary<string, Tensor>( prep.Tensors );
var captions = new float[Batch * T5TextEncoder.Width];
Array.Copy( caption, captions, T5TextEncoder.Width ); // row 1 stays zero: unconditional
feed["caption_emb"] = Tensor.Float( new[] { Batch, T5TextEncoder.Width }, captions );
var steps = Math.Max( 1, settings.Steps );
var t0 = Math.Clamp( settings.StartTime, 0f, 0.98f );
var x = new float[n];
if ( t0 > 0 && settings.Known is { } known0 )
for ( var i = 0; i < n; i++ ) x[i] = t0 * known0[i] + (1 - t0) * noise[i];
else Array.Copy( noise, x, n );
Replace( x, t0 );
float[] Velocity( float[] state, float t )
{
var input = new float[Batch * n];
Array.Copy( state, 0, input, 0, n );
Array.Copy( state, 0, input, n, n );
feed["x"] = Tensor.Float( new[] { Batch, J, 12, Frames }, input );
feed["t"] = Tensor.Float( new[] { Batch }, new[] { t, t } );
var v = RunStep( J, step, feed, token );
var result = new float[n];
var g = settings.Guidance;
for ( var i = 0; i < n; i++ ) result[i] = v[n + i] + g * (v[i] - v[n + i]); // v_u + g (v_c - v_u)
return result;
}
void Replace( float[] state, float t )
{
if ( settings.Keep is not { } keep || settings.Known is not { } k ) return;
for ( var i = 0; i < n; i++ )
if ( keep[i] ) state[i] = (1 - t) * noise[i] + t * k[i];
}
if ( settings.Method == Integrator.Dopri5 )
{
if ( settings.Keep is not null || t0 > 0 ) throw new ArgumentException( "Dopri5 samples from noise without constraints (upstream's text-to-motion)." );
return Dopri5( x, Velocity, settings.RelativeTolerance, settings.AbsoluteTolerance, progress, token );
}
// time grid from t0 to 1 (optionally shifted towards the start)
var grid = new float[steps + 1];
var shift = settings.TimeShift > 0f ? settings.TimeShift : 1f;
for ( var k = 0; k <= steps; k++ )
{
var u = (float)k / steps;
var w = shift == 1f ? u : shift * u / (1f + (shift - 1f) * u);
grid[k] = t0 + (1f - t0) * w;
}
float[] previousV = null;
var previousDt = 0f;
for ( var s = 0; s < steps; s++ )
{
token.ThrowIfCancellationRequested();
var t = grid[s];
var dt = grid[s + 1] - t;
var v1 = Velocity( x, t );
if ( settings.Method == Integrator.Heun && s < steps - 1 )
{
var xp = new float[n];
for ( var i = 0; i < n; i++ ) xp[i] = x[i] + dt * v1[i];
Replace( xp, t + dt );
var v2 = Velocity( xp, t + dt );
for ( var i = 0; i < n; i++ ) x[i] += dt * 0.5f * (v1[i] + v2[i]);
}
else if ( settings.Method == Integrator.AdamsBashforth2 && previousV is not null )
{
// variable-step AB2: extrapolate the velocity to the middle of the step from the last two
var r = dt / (2f * previousDt);
for ( var i = 0; i < n; i++ ) x[i] += dt * ((1f + r) * v1[i] - r * previousV[i]);
}
else
{
for ( var i = 0; i < n; i++ ) x[i] += dt * v1[i];
}
previousV = v1;
previousDt = dt;
Replace( x, t + dt );
progress?.Invoke( (s + 1f) / steps );
}
return x;
}
readonly Dictionary<int, IGpuProgram> _gpu = new();
readonly HashSet<int> _gpuRefused = new();
/// <summary>
/// One network call: on the GPU when a program for this skeleton size exists; otherwise on the CPU, and the first
/// CPU call is traced into a GPU plan (shapes are fixed per size) which is then checked against that call's
/// result before later calls use it. Any GPU failure falls back to the CPU for good.
/// </summary>
float[] RunStep( int joints, OnnxSession step, Dictionary<string, Tensor> feed, CancellationToken token )
{
IGpuProgram program;
lock ( _lock ) _gpu.TryGetValue( joints, out program );
if ( program is not null && GpuAcceleration.Enabled )
{
try { return program.Run( feed )["v"]; }
catch ( Exception e ) when ( e is not OperationCanceledException )
{
DisableGpu( joints, $"The GPU run failed ({e.Message}); using the CPU." );
}
}
var tryGpu = GpuAcceleration.Enabled && GpuAcceleration.Compiler is not null;
lock ( _lock ) tryGpu &= !_gpuRefused.Contains( joints ) && !_gpu.ContainsKey( joints );
if ( !tryGpu ) return step.Run( feed, token )["v"].F;
var recorder = new GpuPlan.Recorder();
Dictionary<string, Tensor> cpu;
lock ( step ) // the trace hook belongs to the session
{
step.Trace = recorder.Record;
try { cpu = step.Run( feed, token ); }
finally { step.Trace = null; }
}
var v = cpu["v"].F;
var plan = GpuPlan.Build( step, recorder );
if ( plan is null ) { Refuse( joints, $"No GPU plan: {GpuPlan.LastRefusal}." ); return v; }
try
{
program = GpuAcceleration.Compiler( plan, step );
var check = program.Run( feed )["v"];
var worst = 0f;
for ( var i = 0; i < v.Length; i++ ) worst = MathF.Max( worst, MathF.Abs( check[i] - v[i] ) );
if ( !(worst <= GpuAcceleration.AgreementTolerance) )
{
// find the first kernel that disagrees (one more traced CPU run keeps every value)
var values = new GpuPlan.Recorder { KeepValues = true };
lock ( step )
{
step.Trace = values.Record;
try { step.Run( feed, token ); }
finally { step.Trace = null; }
}
var where = program.Diagnose( feed, values.Values );
program.Dispose();
Refuse( joints, $"The GPU result differs from the CPU by {worst:G3}; using the CPU. First difference: {where ?? "none found"}" );
return v;
}
lock ( _lock ) _gpu[joints] = program;
GpuAcceleration.Status = null;
}
catch ( Exception e ) when ( e is not OperationCanceledException )
{
program?.Dispose();
Refuse( joints, $"The GPU couldn't be used ({e.Message}); using the CPU." );
}
return v;
}
void Refuse( int joints, string why )
{
lock ( _lock ) _gpuRefused.Add( joints );
GpuAcceleration.Status = why;
}
void DisableGpu( int joints, string why )
{
lock ( _lock )
{
if ( _gpu.Remove( joints, out var p ) ) p.Dispose();
_gpuRefused.Add( joints );
}
GpuAcceleration.Status = why;
}
/// <summary>
/// torchdiffeq's dopri5 (Dormand-Prince 5(4) with its step control, initial step and 4th-order interpolation), as
/// upstream's Sampler.sample_ode calls it: from t = 0 to 1, RMS error norm, safety 0.9, growth at most 10x,
/// shrink at most 5x (none after an accepted step). The last step may pass t = 1; the result is interpolated there.
/// </summary>
public static float[] Dopri5( float[] y0, Func<float[], float, float[]> f, double rtol, double atol, Action<float> progress, CancellationToken token )
{
var n = y0.Length;
double[] A = { 1 / 5.0, 3 / 10.0, 4 / 5.0, 8 / 9.0, 1.0, 1.0 };
double[][] B =
{
new[] { 1 / 5.0 },
new[] { 3 / 40.0, 9 / 40.0 },
new[] { 44 / 45.0, -56 / 15.0, 32 / 9.0 },
new[] { 19372 / 6561.0, -25360 / 2187.0, 64448 / 6561.0, -212 / 729.0 },
new[] { 9017 / 3168.0, -355 / 33.0, 46732 / 5247.0, 49 / 176.0, -5103 / 18656.0 },
new[] { 35 / 384.0, 0, 500 / 1113.0, 125 / 192.0, -2187 / 6784.0, 11 / 84.0 },
};
double[] E = { 35 / 384.0 - 1951 / 21600.0, 0, 500 / 1113.0 - 22642 / 50085.0, 125 / 192.0 - 451 / 720.0,
-2187 / 6784.0 - -12231 / 42400.0, 11 / 84.0 - 649 / 6300.0, -1.0 / 60.0 };
double[] Mid = { 6025192743 / 30085553152.0 / 2, 0, 51252292925 / 65400821598.0 / 2, -2691868925 / 45128329728.0 / 2,
187940372067 / 1594534317056.0 / 2, -1776094331 / 19743644256.0 / 2, 11237099 / 235043384.0 / 2 };
const double Safety = 0.9, IFactor = 10, DFactor = 0.2;
const int Order = 5;
double Rms( Func<int, double> v ) { var s = 0.0; for ( var i = 0; i < n; i++ ) { var x = v( i ); s += x * x; } return Math.Sqrt( s / n ); }
// initial step (Hairer, Norsett & Wanner II.4), with the solver's order - 1 as torchdiffeq passes it
var fy0 = f( y0, 0f );
var d0 = Rms( i => y0[i] / (atol + Math.Abs( y0[i] ) * rtol ) );
var d1 = Rms( i => fy0[i] / (atol + Math.Abs( y0[i] ) * rtol ) );
var h0 = d0 < 1e-5 || d1 < 1e-5 ? 1e-6 : 0.01 * d0 / d1;
var probe = new float[n];
for ( var i = 0; i < n; i++ ) probe[i] = (float)(y0[i] + h0 * fy0[i]);
var fProbe = f( probe, (float)h0 );
var d2 = Rms( i => (fProbe[i] - fy0[i]) / (atol + Math.Abs( y0[i] ) * rtol ) ) / h0;
var h1 = d1 <= 1e-15 && d2 <= 1e-15 ? Math.Max( 1e-6, h0 * 1e-3 ) : Math.Pow( 0.01 / Math.Max( d1, d2 ), 1.0 / Order );
var dt = Math.Min( 100 * h0, h1 );
var y = (float[])y0.Clone();
var fy = fy0;
double t = 0;
var k = new float[7][];
while ( true )
{
token.ThrowIfCancellationRequested();
var t1 = t + dt;
k[0] = fy;
var yi = new float[n];
for ( var s = 0; s < 6; s++ )
{
var b = B[s];
for ( var i = 0; i < n; i++ )
{
var acc = 0.0;
for ( var j = 0; j <= s; j++ ) acc += k[j][i] * (b[j] * dt);
yi[i] = (float)(y[i] + acc);
}
var ts = A[s] == 1.0 ? t1 : t + A[s] * dt;
k[s + 1] = f( yi, (float)ts );
if ( s < 5 ) yi = new float[n];
}
// the 6th stage is the 5th-order solution (FSAL: k[6] is its derivative)
var y1 = yi;
var ratio = Rms( i =>
{
var err = 0.0;
for ( var j = 0; j < 7; j++ ) err += k[j][i] * (dt * E[j]);
return err / (atol + rtol * Math.Max( Math.Abs( y[i] ), Math.Abs( y1[i] ) ));
} );
var accept = ratio <= 1;
var factor = ratio == 0 ? IFactor : Math.Min( IFactor, Math.Max( Safety / Math.Pow( ratio, 1.0 / Order ), accept ? 1.0 : DFactor ) );
if ( accept )
{
if ( t1 >= 1.0 )
{
// 4th-order interpolation of this step at t = 1
var x = (1.0 - t) / dt;
var result = new float[n];
for ( var i = 0; i < n; i++ )
{
var mid = 0.0;
for ( var j = 0; j < 7; j++ ) mid += k[j][i] * (dt * Mid[j]);
double ym = y[i] + mid, a0 = y[i], a1 = y1[i], f0 = k[0][i], f1 = k[6][i];
var ca = 2 * dt * (f1 - f0) - 8 * (a1 + a0) + 16 * ym;
var cb = dt * (5 * f0 - 3 * f1) + 18 * a0 + 14 * a1 - 32 * ym;
var cc = dt * (f1 - 4 * f0) - 11 * a0 - 5 * a1 + 16 * ym;
var cd = dt * f0;
result[i] = (float)(a0 + x * cd + x * x * cc + x * x * x * cb + x * x * x * x * ca);
}
progress?.Invoke( 1f );
return result;
}
y = y1;
fy = k[6];
t = t1;
progress?.Invoke( (float)Math.Min( 0.99, t ) );
}
dt *= factor;
}
}
/// <summary>Standard normal noise (J,12,T) from a seed (Box-Muller).</summary>
public static float[] Noise( int joints, int seed )
{
var rng = new Random( seed );
var n = joints * 12 * Frames;
var r = new float[n];
for ( var i = 0; i < n; i += 2 )
{
var u1 = 1.0 - rng.NextDouble(); var u2 = rng.NextDouble();
var mag = Math.Sqrt( -2.0 * Math.Log( u1 ) );
r[i] = (float)(mag * Math.Cos( 2 * Math.PI * u2 ));
if ( i + 1 < n ) r[i + 1] = (float)(mag * Math.Sin( 2 * Math.PI * u2 ));
}
return r;
}
}
Editor
library
namespace TextToAnimation.Editor.Inference.UniMate;
/// <summary>Prompts as UniMate takes them, and clip names from them.</summary>
public static class UniMatePrompt
{
/// <summary>
/// The caption UniMate is conditioned on: the prompt as written, trimmed - as upstream (inference/sample.py encodes
/// <c>prompt.strip()</c> whole, with no rewriting or splitting; FLAN-T5 reads the wording).
/// </summary>
public static string Caption( string prompt )
{
var s = prompt ?? "";
int start = 0, end = s.Length;
while ( start < end && IsPythonSpace( s[start] ) ) start++;
while ( end > start && IsPythonSpace( s[end - 1] ) ) end--;
return s[start..end];
}
/// <summary>Python's <c>str.isspace</c>: .NET's whitespace plus the information separators U+001C..U+001F.</summary>
static bool IsPythonSpace( char c ) => char.IsWhiteSpace( c ) || c is >= '\u001c' and <= '\u001f';
/// <summary>
/// A clip name from a prompt: the prompt itself on one line, capitalised, without a leading "An object"; only a
/// very long one is shortened, at a word boundary.
/// </summary>
public static string ClipName( string prompt )
{
var words = Caption( prompt ).Split( (char[])null, System.StringSplitOptions.RemoveEmptyEntries );
// UniMate's captions all open with "An object" (its README's advice for prompts): the name is what it does
if ( words.Length > 2 && words[0].Equals( "an", System.StringComparison.OrdinalIgnoreCase ) && words[1].Equals( "object", System.StringComparison.OrdinalIgnoreCase ) )
words = words[2..];
var text = string.Join( ' ', words ).TrimEnd( '.', '!', '?' ).Trim();
const int MaxLength = 48;
if ( text.Length > MaxLength )
{
var space = text.LastIndexOf( ' ', MaxLength );
var cut = space > 0 ? space : MaxLength;
if ( char.IsHighSurrogate( text[cut - 1] ) ) cut--; // never half a character (emoji)
text = text[..cut].Trim();
}
if ( text.Length == 0 ) return "Generated";
return char.ToUpperInvariant( text[0] ) + text[1..];
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using TextToAnimation.Rig;
namespace TextToAnimation.Editor.Inference.UniMate;
/// <summary>
/// Skin weights per bone for a skeleton, which upstream's skeleton preparation prunes on ("skinned bones are never
/// prunable"). The engine doesn't expose them; the editor reads them from the model's source FBX and attaches them
/// here when it opens the model. Unknown (nothing attached) means every bone counts as skinned.
/// </summary>
public static class UniMateSkin
{
sealed class Entry
{
public IReadOnlyDictionary<string, (double Max, double Sum)> Weights;
public string ObjectType;
public IReadOnlySet<string> Driven;
}
static readonly ConditionalWeakTable<Skeleton, Entry> _entries = new();
/// <summary>Attaches per-bone (largest single, total) skin weights, and the model's name (upstream's object type).</summary>
/// <param name="driven">Bones the model's own constraints drive at runtime (the engine poses them, not clips).</param>
public static void Attach( Skeleton skeleton, IReadOnlyDictionary<string, (double Max, double Sum)> weights, string objectType,
IReadOnlySet<string> driven = null )
{
if ( skeleton is null ) return;
_entries.Remove( skeleton );
_entries.Add( skeleton, new Entry { Weights = weights, ObjectType = objectType ?? "", Driven = driven } );
}
/// <summary>The bones the model's constraints drive (empty when unknown).</summary>
public static IReadOnlySet<string> DrivenOf( Skeleton skeleton )
=> skeleton is not null && _entries.TryGetValue( skeleton, out var e ) && e.Driven is not null ? e.Driven : new HashSet<string>();
/// <summary>The weights attached to <paramref name="skeleton"/>, or null when unknown.</summary>
public static IReadOnlyDictionary<string, (double Max, double Sum)> WeightsOf( Skeleton skeleton )
=> skeleton is not null && _entries.TryGetValue( skeleton, out var e ) ? e.Weights : null;
/// <summary>The model name attached to <paramref name="skeleton"/> ("" when none).</summary>
public static string ObjectTypeOf( Skeleton skeleton )
=> skeleton is not null && _entries.TryGetValue( skeleton, out var e ) ? e.ObjectType : "";
}
Editor
library
// Vendored from humanoid-retargeter (Code/HumanoidRetargeter/Mapping/MappingResult.cs).
#nullable enable annotations
using System.Collections.Generic;
namespace TextToAnimation.Editor.Mapping;
/// <summary>How a mapping was produced; determines UI flow and preset-learning behavior.</summary>
public enum MappingSource
{
/// <summary>A shipped preset profile matched (mixamo, actorcore_cc, ...).</summary>
Preset,
/// <summary>A user-saved preset (keyed by <see cref="SkeletonSignature"/>) matched.</summary>
UserPreset,
/// <summary>The auto-mapper resolved roles from bone-name tokens (stage A).</summary>
AutoName,
/// <summary>The auto-mapper fell back to pure hierarchy/geometry matching (stage B).</summary>
AutoTopology,
/// <summary>The user assigned roles by hand in the mapping editor.</summary>
Manual,
/// <summary>
/// The source file itself declares the mapping — e.g. a VRM's
/// <c>humanoid.humanBones</c> block (both the 0.x <c>extensions.VRM</c> and the 1.0
/// <c>extensions.VRMC_vrm</c> layouts). Authoritative: consulted before user presets and
/// shipped-profile detection, at confidence 1.0.
/// </summary>
Authored,
}
/// <summary>
/// The outcome of mapping a source skeleton onto canonical <see cref="BoneRole"/>s:
/// role → source bone index, an overall confidence, and human-readable notes
/// (unmapped roles, ignored bones, ambiguities) for the mapping report.
/// </summary>
public sealed class MappingResult
{
/// <summary>Creates an empty result; callers fill <see cref="RoleToBone"/> and
/// <see cref="Confidence"/>.</summary>
public MappingResult(string profileName, MappingSource source)
{
ProfileName = profileName;
Source = source;
}
/// <summary>Resolved roles: canonical role → bone index in the source skeleton.</summary>
public Dictionary<BoneRole, int> RoleToBone { get; } = new();
/// <summary>Mapping confidence in [0, 1]; 1 means every required and optional role
/// resolved unambiguously.</summary>
public float Confidence { get; set; }
/// <summary>Profile that produced the mapping — a preset name, or <c>"auto"</c> /
/// <c>"topology"</c> for the auto-mapper stages.</summary>
public string ProfileName { get; }
/// <summary>Where the mapping came from.</summary>
public MappingSource Source { get; }
/// <summary>Report notes: unmapped roles, ignored source bones, ambiguities.</summary>
public List<string> Notes { get; } = new();
}
Editor
library
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
using TextToAnimation.Animation;
using TextToAnimation.Editor.Engine;
using TextToAnimation.Editor.Session;
using TextToAnimation.Maths;
using TextToAnimation.Processing;
namespace TextToAnimation.Editor.UI;
/// <summary>
/// The live model view: the workspace model posed with the active clip at the playhead, the bone overlay
/// (blue = selected, amber = locked for regeneration), the root trajectory and the floor. Left click picks
/// bones and drags the rotate handle of the selected bone (an undoable key on the current frame); right drag
/// orbits, middle drag pans, the wheel zooms, double click frames the character.
/// </summary>
public sealed class AnimationViewport : SceneRenderingWidget
{
readonly EditorSession _session;
CameraComponent _camera;
SceneModel _sceneModel;
Model _model;
int[] _boneMap = Array.Empty<int>();
Vector3[] _bindScale = Array.Empty<Vector3>();
XForm[] _world = Array.Empty<XForm>();
XForm[] _pose = Array.Empty<XForm>();
bool _dragging;
float _yaw = 35f, _pitch = 12f, _distance = 140f;
Vector3 _target = new( 0, 0, 36 );
Vector3 _pan;
Vector2 _lastMouse;
public bool ShowSkeleton { get; set; } = true;
public bool ShowTrajectory { get; set; } = true;
public bool ShowGround { get; set; } = true;
public bool ShowModel { get; set; } = true;
public bool FollowCharacter { get; set; } = true;
/// <summary>
/// When set, the engine plays this compiled sequence of the model itself (no bone overrides) at the session
/// playhead - the ground truth of what a saved animation looks like in game.
/// </summary>
public string EngineSequence { get; set; }
/// <summary>
/// Bones the model's own constraints drive at runtime (from its vmdl and prefabs). For a clip whose compiled
/// sequence is current, the view takes these from the engine itself, so the preview shows the game's result.
/// </summary>
public IReadOnlySet<string> ConstraintDriven { get; set; } = new HashSet<string>();
/// <summary>The compiled sequence holding the active clip unchanged (set by the window), or null.</summary>
public string SavedSequence { get; set; }
/// <summary>
/// The preview's constraint-driven bones against the engine playing <paramref name="sequence"/> at the playhead,
/// both relative to <paramref name="anchor"/> (the game may extract root motion): the largest position difference.
/// </summary>
public float PreviewVsGameOnDrivenBones( string sequence, string anchor )
{
var rig = _session.Rig;
if ( rig is null || !_sceneModel.IsValid() || _model is null ) return float.NaN;
var s = rig.Skeleton;
var a = s.IndexOf( anchor );
if ( a < 0 || _boneMap[a] < 0 || _session.ActiveFrames is not { Count: > 0 } frames ) return float.NaN;
// the pose at the current playhead (the paint tick may not have run since the last seek)
SamplePose( frames, _session.Playhead, _pose );
FkUtil.ToWorld( _pose, s, _world );
ApplyPose();
var preview = Enumerable.Range( 0, s.Count ).Select( b => _boneMap[b] >= 0 ? _sceneModel.GetBoneWorldTransform( _boneMap[b] ) : default ).ToArray();
var saved = EngineSequence;
EngineSequence = sequence;
ApplyPose();
var game = Enumerable.Range( 0, s.Count ).Select( b => _boneMap[b] >= 0 ? _sceneModel.GetBoneWorldTransform( _boneMap[b] ) : default ).ToArray();
EngineSequence = saved;
ApplyPose();
var worst = 0f; var worstOther = 0f; string worstBone = "", worstOtherBone = "";
for ( var b = 0; b < s.Count; b++ )
{
if ( _boneMap[b] < 0 ) continue;
var d = (preview[a].ToLocal( preview[b] ).Position - game[a].ToLocal( game[b] ).Position).Length;
if ( ConstraintDriven.Contains( s[b].Name ) ) { if ( d > worst ) { worst = d; worstBone = s[b].Name; } }
else if ( d > worstOther ) { worstOther = d; worstOtherBone = s[b].Name; }
}
LastDrivenReport = $"worst {worstBone}; other bones max {worstOther:0.000} in ({worstOtherBone})";
return worst;
}
/// <summary>Which bones the last <see cref="PreviewVsGameOnDrivenBones"/> found furthest apart.</summary>
public string LastDrivenReport { get; private set; } = "";
/// <summary>The sequence the active clip was saved as, when it hasn't changed since (its compiled copy is current).</summary>
string CurrentSavedSequence()
{
var clip = _session.ActiveClip;
return clip?.SavedUtc is not null && ClipListPanel.SavedRevisions.TryGetValue( clip.Id, out var revision ) && revision == clip.Revision
? clip.EffectiveSequenceName : null;
}
/// <summary>True when the last pose took the constraint-driven bones from the engine.</summary>
public bool UsedEngineConstraints { get; private set; }
public AnimationViewport( Widget parent, EditorSession session ) : base( parent )
{
_session = session;
MinimumSize = new Vector2( 320, 260 );
MouseTracking = true;
FocusMode = FocusMode.Click;
Scene = Scene.CreateEditorScene();
using ( Scene.Push() )
{
_camera = new GameObject( true, "camera" ).GetOrAddComponent<CameraComponent>( false );
_camera.BackgroundColor = Theme.ControlBackground;
_camera.ZNear = 1f;
_camera.ZFar = 20000f;
_camera.FieldOfView = 45f;
_camera.Enabled = true;
}
Camera = _camera;
var world = Scene.SceneWorld;
new ScenePointLight( world, new Vector3( 120, 100, 160 ), 900, Color.White * 3.0f ).ShadowsEnabled = false;
new ScenePointLight( world, new Vector3( -140, -100, 110 ), 900, Color.White * 1.6f ).ShadowsEnabled = false;
new ScenePointLight( world, new Vector3( 0, 160, 60 ), 700, Color.White * 0.9f ).ShadowsEnabled = false;
}
/// <summary>Loads the workspace model into the view.</summary>
/// <summary>
/// Shows <paramref name="model"/>. Always rebuilds the scene model and the bone map: a save recompiles the
/// model in place (the same Model object) and the compiler may reorder its bones, so a cached map would pose
/// the wrong bones. The camera is only reframed for a different model.
/// </summary>
public void SetModel( Model model )
{
var reframe = model?.ResourcePath != _model?.ResourcePath;
_model = model;
_sceneModel?.Delete();
_sceneModel = null;
DeleteEngineModel();
if ( model is null || model.IsError || _session.Rig is null ) return;
DeleteEngineModel();
_sceneModel = new SceneModel( Scene.SceneWorld, model, Transform.Zero ) { UseAnimGraph = false };
var skeleton = _session.Rig.Skeleton;
_boneMap = skeleton.Bones.Select( b => model.Bones.GetBone( b.Name )?.Index ?? -1 ).ToArray();
_bindScale = skeleton.Bones.Select( b => model.Bones.GetBone( b.Name )?.LocalTransform.Scale ?? Vector3.One ).ToArray();
_world = new XForm[skeleton.Count];
_pose = new XForm[skeleton.Count];
if ( reframe ) FrameCharacter();
}
/// <summary>Frames the whole character (double click does the same).</summary>
public void FrameCharacter()
{
var rig = _session.Rig;
if ( _model is not null && !_model.IsError && _model.Bounds.Size.Length > 1f )
{
// any creature: frame its whole body (a fox is long and low, a person tall and narrow)
var size = _model.Bounds.Size;
var extent = MathF.Max( size.z, MathF.Max( size.x, size.y ) * 0.75f );
_distance = MathF.Max( extent * 1.9f, 30f );
_target = new Vector3( 0, 0, size.z * 0.5f );
}
else
{
var height = rig is null ? 72f : MathF.Max( rig.HipHeight * 2.1f, 20f );
_distance = height * 1.9f;
_target = new Vector3( 0, 0, height * 0.5f );
}
_pan = Vector3.Zero;
_followCenter = null;
}
public void SetView( float yaw, float pitch ) { _yaw = yaw; _pitch = pitch; }
protected override void PreFrame()
{
var started = FrameProbe.Now;
try { ViewFrame(); }
finally { FrameProbe.Add( "viewport", FrameProbe.Now - started ); }
}
void ViewFrame()
{
Scene.EditorTick( RealTime.Now, RealTime.Delta );
GizmoInstance.Input.IsHovered = IsActiveWindow && IsUnderMouse;
UpdateGizmoInputs( GizmoInstance.Input.IsHovered );
var frames = _session.ActiveFrames;
var rig = _session.Rig;
if ( rig is null || frames is null || frames.Count == 0 )
{
_gizmoHovered = false;
UpdateCamera( _target );
if ( ShowGround ) DrawGround( Vector3.Zero, 0f, 40f );
return;
}
SamplePose( frames, _session.Playhead, _pose );
FkUtil.ToWorld( _pose, rig.Skeleton, _world );
ApplyPose();
var hips = rig.HipsIndex >= 0 ? rig.HipsIndex : rig.RootIndex;
var hipsPos = ToVec( _world[hips].Pos );
var groundZ = GroundHeight( rig );
UpdateCamera( FollowCharacter ? new Vector3( hipsPos.x, hipsPos.y, _target.z ) : _target );
if ( ShowGround ) DrawGround( hipsPos, groundZ, MathF.Max( rig.HipHeight * 2f, 30f ) );
if ( ShowTrajectory ) DrawTrajectory( frames, rig, groundZ );
if ( ShowSkeleton || !ShowModel ) DrawSkeleton( rig );
DrawBonePicking( rig );
DrawPoseGizmo( rig );
_gizmoHovered = Gizmo.HasHovered;
}
// whether a bone dot or pose handle is under the mouse, as of the last drawn frame: Gizmo.HasHovered only works
// while the gizmos are being drawn, not in mouse events
bool _gizmoHovered;
static Vector3 ToVec( System.Numerics.Vector3 v ) => new( v.X, v.Y, v.Z );
static void SamplePose( List<XForm[]> frames, float playhead, XForm[] into )
{
var a = Math.Clamp( (int)MathF.Floor( playhead ), 0, frames.Count - 1 );
var b = Math.Min( a + 1, frames.Count - 1 );
var t = playhead - a;
var fa = frames[a]; var fb = frames[b];
var n = Math.Min( into.Length, fa.Length );
for ( var i = 0; i < n; i++ )
into[i] = t <= 1e-4f ? fa[i] : new XForm( System.Numerics.Vector3.Lerp( fa[i].Pos, fb[i].Pos, t ),
System.Numerics.Quaternion.Normalize( System.Numerics.Quaternion.Slerp( fa[i].Rot, fb[i].Rot, t ) ) );
}
SceneWorld _engineWorld;
SceneModel _engineModel;
string _engineModelSequence;
/// <summary>
/// The engine's own pose of compiled <paramref name="sequence"/> at <paramref name="seconds"/> (its animation and
/// the model's constraints), on a hidden model that only ever plays that sequence: switching sequences on a model
/// crossfades from the previous one, which an editor (that never advances time) would never finish.
/// </summary>
SceneModel EngineModelAt( string sequence, float seconds )
{
// recreated until it really plays the sequence (one made while a save was recompiling the model has none)
if ( !_engineModel.IsValid() || _engineModelSequence != sequence || _engineModel.CurrentSequence.Name != sequence )
{
DeleteEngineModel();
_engineWorld = new SceneWorld();
// the compiled model as it is now (a save recompiles it; the view's instance can lag behind)
var compiled = Model.Load( _model.ResourcePath );
_engineModel = new SceneModel( _engineWorld, compiled is { IsError: false } ? compiled : _model, Transform.Zero ) { UseAnimGraph = false };
_engineModel.CurrentSequence.Name = sequence;
_engineModel.Update( 0f );
_engineModelSequence = sequence;
}
_engineModel.CurrentSequence.Time = seconds;
_engineModel.Update( 0f );
return _engineModel;
}
/// <summary>
/// Every bone's world transform on an engine-played model, chained from the parent-space bones that
/// <see cref="SceneModel.Update"/> evaluates (the world transforms it reports refresh only when the scene renders).
/// </summary>
Transform[] EngineWorld( SceneModel engine )
{
var count = _model.BoneCount;
var world = new Transform[count];
var state = new byte[count]; // 0 todo, 1 in progress (a parent loop cuts there), 2 done
Transform Of( int b )
{
if ( state[b] == 2 ) return world[b];
var local = engine.GetParentSpaceBone( b );
var parent = _model.GetBoneParent( b );
state[b] = 1;
world[b] = parent < 0 || parent >= count || state[parent] == 1 ? local : Of( parent ).ToWorld( local );
state[b] = 2;
return world[b];
}
for ( var b = 0; b < count; b++ ) Of( b );
return world;
}
void DeleteEngineModel()
{
if ( _engineModel.IsValid() ) _engineModel.Delete();
_engineWorld?.Delete();
_engineModel = null;
_engineWorld = null;
_engineModelSequence = null;
}
void ApplyPose()
{
if ( !_sceneModel.IsValid() ) return;
_sceneModel.RenderingEnabled = ShowModel;
if ( !string.IsNullOrEmpty( EngineSequence ) )
{
var engine = EngineWorld( EngineModelAt( EngineSequence, _session.Playhead / (_session.ActiveClip?.Fps ?? 30f) ) );
for ( var b = 0; b < engine.Length; b++ ) _sceneModel.SetBoneOverride( b, engine[b] );
_sceneModel.Update( 0f );
return;
}
var final = new Transform[_boneMap.Length];
for ( var i = 0; i < _boneMap.Length; i++ ) final[i] = ModelBridge.ToTransform( _world[i], _bindScale[i] );
UsedEngineConstraints = false;
var saved = SavedSequence ?? CurrentSavedSequence();
if ( ConstraintDriven.Count > 0 && !string.IsNullOrEmpty( saved ) && _model.AnimationNames.Contains( saved ) )
{
// the engine evaluates the model's constraints only while playing a compiled sequence: play the saved one at
// this time, take each driven bone relative to its parent, and place it on this pose (root motion kept)
var engine = EngineWorld( EngineModelAt( saved, _session.Playhead / (_session.ActiveClip?.Fps ?? 30f) ) );
var skeleton = _session.Rig.Skeleton;
var moved = new bool[skeleton.Count];
for ( var i = 0; i < skeleton.Count; i++ ) // parents come before children
{
var p = skeleton[i].ParentIndex;
if ( _boneMap[i] < 0 || p < 0 || _boneMap[p] < 0 ) continue;
if ( ConstraintDriven.Contains( skeleton[i].Name ) )
{
var local = engine[_boneMap[p]].ToLocal( engine[_boneMap[i]] );
final[i] = final[p].ToWorld( local );
moved[i] = UsedEngineConstraints = true;
}
else if ( moved[p] )
{
// below a driven bone: keep this pose's local transform under the engine-placed parent
final[i] = final[p].ToWorld( ModelBridge.ToTransform( _world[p], _bindScale[p] ).ToLocal( ModelBridge.ToTransform( _world[i], _bindScale[i] ) ) );
moved[i] = true;
}
}
}
for ( var i = 0; i < _boneMap.Length; i++ )
{
if ( _boneMap[i] < 0 ) continue;
_sceneModel.SetBoneOverride( _boneMap[i], final[i] );
}
_sceneModel.Update( 0f ); // flush overrides now (otherwise the pose lags a frame)
}
/// <summary>
/// The engine's own pose while playing compiled <paramref name="sequence"/> (animation + the model's constraints):
/// world transforms of every bone and of the named attachments, at the given frames. Ground truth for the
/// constraint evaluator.
/// </summary>
public object EnginePoses( string sequence, IEnumerable<int> frames, float fps, IReadOnlyList<string> attachments )
{
if ( _model is null || !_sceneModel.IsValid() ) return null;
var saved = EngineSequence;
EngineSequence = null;
var result = new List<object>();
static float[] P( Transform t ) => new[] { t.Position.x, t.Position.y, t.Position.z, t.Rotation.x, t.Rotation.y, t.Rotation.z, t.Rotation.w };
foreach ( var f in frames )
{
var engineModel = EngineModelAt( sequence, f / fps );
var engine = EngineWorld( engineModel );
// the pose the sequence stores (before constraints), in the same model space as the engine's bones
var stored = new Dictionary<string, float[]>();
if ( _session.ActiveFrames is { Count: > 0 } clipFrames && _session.Rig is { } rig )
{
var pose = new XForm[rig.Skeleton.Count]; var world = new XForm[rig.Skeleton.Count];
SamplePose( clipFrames, f, pose );
FkUtil.ToWorld( pose, rig.Skeleton, world );
for ( var i = 0; i < rig.Skeleton.Count; i++ )
if ( _boneMap[i] >= 0 ) stored[rig.Skeleton[i].Name] = P( ModelBridge.ToTransform( world[i], _bindScale[i] ) );
}
result.Add( new
{
frame = f,
stored,
bones = Enumerable.Range( 0, _model.BoneCount ).ToDictionary( b => _model.GetBoneName( b ), b => P( engine[b] ) ),
attachments = attachments.Select( a => (a, t: (object)engineModel.GetAttachment( a, true )) ).Where( x => x.t is Transform ).ToDictionary( x => x.a, x => P( (Transform)x.t ) ),
} );
}
EngineSequence = saved;
return result;
}
/// <summary>The floor the model stands on: z = 0 for normal models, or its lowest rest point when that is below.</summary>
static float GroundHeight( MotionRig rig )
{
var lowest = float.MaxValue;
for ( var i = 0; i < rig.Skeleton.Count; i++ ) lowest = MathF.Min( lowest, rig.Skeleton.RestWorld[i].Pos.Z );
return lowest < 0f ? lowest : 0f;
}
// ---------------------------------------------------------------- camera
Vector3? _followCenter;
void UpdateCamera( Vector3 goal )
{
_followCenter = _followCenter is { } c ? Vector3.Lerp( c, goal, 1f - MathF.Exp( -RealTime.Delta / 0.3f ) ) : goal;
var center = _followCenter.Value + _pan;
var yaw = MathX.DegreeToRadian( _yaw );
var pitch = MathX.DegreeToRadian( _pitch );
var dir = new Vector3( MathF.Cos( yaw ) * MathF.Cos( pitch ), MathF.Sin( yaw ) * MathF.Cos( pitch ), MathF.Sin( pitch ) ).Normal;
_camera.WorldPosition = center + dir * _distance;
_camera.WorldRotation = Rotation.LookAt( -dir, Vector3.Up );
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
_lastMouse = e.LocalPosition;
// left-drag on empty space looks around (on a bone or a gizmo handle it picks or poses instead)
_orbiting = e.LeftMouseButton && !_gizmoHovered;
}
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
if ( e.LeftMouseButton ) _orbiting = false;
}
bool _orbiting;
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
var delta = e.LocalPosition - _lastMouse;
_lastMouse = e.LocalPosition;
if ( (e.ButtonState & MouseButtons.Right) != 0 || (_orbiting && (e.ButtonState & MouseButtons.Left) != 0) )
{
_yaw -= delta.x * 0.4f;
_pitch = Math.Clamp( _pitch + delta.y * 0.3f, -15f, 85f );
}
else if ( (e.ButtonState & MouseButtons.Middle) != 0 )
{
var scale = _distance * 0.0016f;
_pan += (_camera.WorldRotation.Left * delta.x + _camera.WorldRotation.Up * delta.y) * scale;
}
}
protected override void OnWheel( WheelEvent e )
{
base.OnWheel( e );
_distance = Math.Clamp( _distance * (e.Delta > 0 ? 0.9f : 1.1f), 10f, 4000f );
e.Accept();
}
protected override void OnDoubleClick( MouseEvent e )
{
base.OnDoubleClick( e );
if ( e.LeftMouseButton && !_gizmoHovered ) FrameCharacter();
}
// ---------------------------------------------------------------- overlays
void DrawGround( Vector3 center, float z, float size )
{
using var scope = Gizmo.Scope( "t2a-ground" );
Gizmo.Transform = Transform.Zero;
Gizmo.Draw.IgnoreDepth = false;
Gizmo.Draw.LineThickness = 1f;
var step = MathF.Max( size / 6f, 2f );
const int cells = 12;
var block = step * 4f;
var ox = MathF.Round( center.x / block ) * block;
var oy = MathF.Round( center.y / block ) * block;
var extent = cells * step;
for ( var i = -cells; i <= cells; i++ )
{
var fade = 1f - MathF.Abs( i ) / (cells + 1f);
Gizmo.Draw.Color = Color.White.WithAlpha( (i == 0 ? .14f : .06f) * fade + .012f );
var o = i * step;
Gizmo.Draw.Line( new Vector3( ox + o, oy - extent, z ), new Vector3( ox + o, oy + extent, z ) );
Gizmo.Draw.Line( new Vector3( ox - extent, oy + o, z ), new Vector3( ox + extent, oy + o, z ) );
}
}
void DrawTrajectory( List<XForm[]> frames, MotionRig rig, float z )
{
var path = RootTools.HipsTrajectory( frames, rig );
using var scope = Gizmo.Scope( "t2a-trajectory" );
Gizmo.Transform = Transform.Zero;
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.LineThickness = 2f;
var accent = TaStyle.Accent;
var current = _session.CurrentFrame;
for ( var f = 1; f < path.Length; f++ )
{
var past = f <= current;
Gizmo.Draw.Color = accent.WithAlpha( past ? .9f : .35f );
Gizmo.Draw.Line( new Vector3( path[f - 1].X, path[f - 1].Y, z + .2f ), new Vector3( path[f].X, path[f].Y, z + .2f ) );
}
// heading ticks every half second
var every = Math.Max( 1, (int)MathF.Round( (_session.ActiveClip?.Fps ?? 30f) * 0.5f ) );
Gizmo.Draw.LineThickness = 1.5f;
for ( var f = 0; f < frames.Count; f += every )
{
var heading = RootTools.RootHeading( frames, rig, f );
var dir = System.Numerics.Vector3.Transform( rig.Forward, RootTools.Yaw( rig, heading ) );
var p = new Vector3( path[f].X, path[f].Y, z + .2f );
Gizmo.Draw.Color = Color.White.WithAlpha( .35f );
Gizmo.Draw.Line( p, p + new Vector3( dir.X, dir.Y, 0 ) * rig.Cm( 12f ) );
}
// pinned frames (in-between constraints)
if ( _session.ActiveClip is { } clip )
{
Gizmo.Draw.Color = Theme.Yellow.WithAlpha( .9f );
foreach ( var pin in clip.PinnedFrames.Where( p => p < path.Length ) )
Gizmo.Draw.LineCircle( new Vector3( path[pin].X, path[pin].Y, z + .2f ), Vector3.Up, rig.Cm( 6f ), 0, 360, 4 );
}
var at = path[Math.Clamp( current, 0, path.Length - 1 )];
Gizmo.Draw.Color = accent;
Gizmo.Draw.LineCircle( new Vector3( at.X, at.Y, z + .2f ), Vector3.Up, rig.Cm( 10f ), 0, 360, 24 );
}
void DrawSkeleton( MotionRig rig )
{
var clip = _session.ActiveClip;
using var scope = Gizmo.Scope( "t2a-skeleton" );
Gizmo.Transform = Transform.Zero;
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.LineThickness = 2f;
for ( var i = 0; i < rig.Skeleton.Count; i++ )
{
var parent = rig.Skeleton[i].ParentIndex;
if ( parent < 0 || !rig.IsMotionBone( i ) ) continue;
var locked = clip?.LockedBones.Contains( rig.Skeleton[i].Name ) == true;
var selected = _session.SelectedBones.Contains( i );
Gizmo.Draw.Color = selected ? TaStyle.AccentLight : locked ? Theme.Yellow.WithAlpha( .95f ) : TaStyle.Accent.WithAlpha( .55f );
Gizmo.Draw.Line( ToVec( _world[parent].Pos ), ToVec( _world[i].Pos ) );
}
}
void DrawBonePicking( MotionRig rig )
{
var clip = _session.ActiveClip;
var radius = MathF.Max( rig.Cm( 1.6f ), 0.45f );
var clicked = false;
for ( var i = 0; i < rig.Skeleton.Count; i++ )
{
if ( !rig.IsMotionBone( i ) ) continue;
using ( Gizmo.Scope( $"t2a-bone/{i}", new Transform( ToVec( _world[i].Pos ) ) ) )
{
Gizmo.Hitbox.DepthBias = 0.01f;
Gizmo.Hitbox.Sphere( new Sphere( 0, radius ) );
Gizmo.Hitbox.TrySetHovered( 0f );
var selected = _session.SelectedBones.Contains( i );
var locked = clip?.LockedBones.Contains( rig.Skeleton[i].Name ) == true;
if ( ShowSkeleton || selected || Gizmo.IsHovered )
{
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = selected ? TaStyle.AccentLight : Gizmo.IsHovered ? Color.White : locked ? Theme.Yellow : TaStyle.Accent.WithAlpha( .7f );
Gizmo.Draw.LineSphere( new Sphere( 0, radius ), 6 );
}
if ( Gizmo.IsHovered && Gizmo.WasLeftMousePressed && !clicked )
{
clicked = true;
var additive = global::Editor.Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Ctrl ) || global::Editor.Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Shift );
_session.SelectBone( i, additive );
}
}
}
// click on empty space clears the selection (but not while a handle is used)
if ( !clicked && Gizmo.WasLeftMousePressed && !Gizmo.HasHovered && !Gizmo.Pressed.Any && IsUnderMouse )
_session.SelectBone( null );
}
void DrawPoseGizmo( MotionRig rig )
{
if ( _session.PrimaryBone is not int bone || _session.ActiveClip is not { } clip || _session.Busy )
{
EndDrag();
return;
}
var name = rig.Skeleton[bone].Name;
var parent = rig.Skeleton[bone].ParentIndex;
var world = _world[bone];
using ( Gizmo.Scope( $"t2a-pose/{bone}", new Transform( ToVec( world.Pos ), new Rotation( world.Rot.X, world.Rot.Y, world.Rot.Z, world.Rot.W ) ) ) )
{
Gizmo.Hitbox.DepthBias = 0.01f;
var moved = false;
XForm newWorld = world;
if ( Gizmo.Control.Rotate( "rot", Rotation.Identity, out var delta ) )
{
var d = new System.Numerics.Quaternion( delta.x, delta.y, delta.z, delta.w );
newWorld = new XForm( world.Pos, MathQ.Normalize( world.Rot * d ) );
moved = true;
}
// the motion root and the hips can also be moved
if ( (bone == rig.RootIndex || bone == rig.HipsIndex) && Gizmo.Control.Position( "pos", Vector3.Zero, out var move ) )
{
var worldMove = new Rotation( world.Rot.X, world.Rot.Y, world.Rot.Z, world.Rot.W ) * move;
newWorld = new XForm( newWorld.Pos + new System.Numerics.Vector3( worldMove.x, worldMove.y, worldMove.z ), newWorld.Rot );
moved = true;
}
if ( moved )
{
if ( !_dragging )
{
_dragging = true;
_session.BeginInteractiveEdit( $"Pose {name}" );
}
var parentWorld = parent < 0 ? XForm.Identity : _world[parent];
var targetLocal = XForm.ToLocal( parentWorld, newWorld );
_session.SetPoseKey( bone, _session.CurrentFrame, targetLocal );
}
if ( _dragging && !Gizmo.Pressed.Any ) EndDrag();
}
}
void EndDrag()
{
if ( !_dragging ) return;
_dragging = false;
_session.EndInteractiveEdit();
}
public override void OnDestroyed()
{
base.OnDestroyed();
_sceneModel?.Delete();
DeleteEngineModel();
Scene?.Destroy();
Scene = null;
}
/// <summary>Renders the view to PNG bytes (used by the test gate for visual checks).</summary>
public byte[] RenderToPng( int width = 640, int height = 480 )
{
var bitmap = new Bitmap( width, height );
_camera.RenderToBitmap( bitmap );
return bitmap.ToPng();
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace TextToAnimation.Editor.UI;
/// <summary>Which part of an existing animation a text change applies to.</summary>
public enum ChangeScope { WholeBody, UpperBody, LowerBody, Arms, SelectedBones, Unlocked }
/// <summary>The settings behind the prompt. Simple users never need to touch them.</summary>
public sealed class PromptOptions
{
public float Seconds { get; set; } = 4f;
public int Takes { get; set; } = 1;
public int Steps { get; set; } = 24;
public float Guidance { get; set; } = 3f;
/// <summary>Null = a new random seed per generation.</summary>
public int? Seed { get; set; }
public float VariationStrength { get; set; } = 0.5f;
public ChangeScope Scope { get; set; } = ChangeScope.WholeBody;
/// <summary>Smooth the generated motion and lock planted feet (off = the model's raw output).</summary>
public bool CleanUp { get; set; } = true;
}
/// <summary>
/// The prompt row at the top of the timeline dock: what the prompt does (a chip), the text field
/// (Enter sends, Shift+Enter adds a line and the field grows up to three lines), which body part a change
/// applies to, one settings chip (length, takes, quality, advanced) and a send button that becomes stop while
/// generating. Styled like the editor's other inputs so it reads as part of the dock.
/// </summary>
public sealed class PromptComposer : Widget
{
const float LineHeight = 17f;
const float SingleLine = 28f;
readonly Layout _leading;
readonly Field _field;
readonly PromptBox _box;
readonly ChipButton _scopeChip;
readonly ChipButton _settingsChip;
readonly SendButton _send;
string _target;
string _disabledReason;
string _placeholder;
bool _busy;
bool _showScope = true;
bool _showLength = true;
public PromptOptions Options { get; } = new();
/// <summary>Called with the trimmed prompt when the user sends it.</summary>
public Action<string> Submitted { get; set; }
/// <summary>Called when the user presses stop while busy.</summary>
public Action StopRequested { get; set; }
/// <summary>The body parts offered by the scope chip.</summary>
public List<ChangeScope> ScopeChoices { get; set; } = new() { ChangeScope.WholeBody };
/// <summary>Allows sending an empty prompt (variations, in-betweens).</summary>
public bool AllowEmpty { get; set; }
/// <summary>Called when the user clicks the history icon (shows past prompts).</summary>
public Action<Widget> HistoryRequested { get; set; }
public PromptComposer( Widget parent, string placeholder ) : base( parent )
{
_placeholder = placeholder;
Layout = Layout.Row();
Layout.Spacing = 6;
_leading = Layout.AddRow();
_leading.Spacing = 6;
_field = Layout.Add( new Field( this ), 1 );
_box = _field.Box;
_box.Send = Send;
_field.History.OnClick = () => HistoryRequested?.Invoke( _field.History );
_box.TextChanged += _ => Grow();
_scopeChip = Layout.Add( new ChipButton( this, "", "accessibility_new", ScopeMenu, "Which part of the body the change applies to" ) );
_settingsChip = Layout.Add( new ChipButton( this, "", "tune", SettingsMenu, "Length, takes, quality and more" ) );
_send = Layout.Add( new SendButton( this, () => { if ( _busy ) StopRequested?.Invoke(); else Send(); } ) );
RefreshChips();
RefreshPlaceholder();
Grow();
}
public string Text
{
get => _box.PlainText ?? "";
set { _box.PlainText = value ?? ""; Grow(); }
}
public void FocusPrompt() => _box.Focus();
/// <summary>The text input (tests post key events to it).</summary>
public Widget Input => _box;
/// <summary>Sends the prompt as if the user pressed Enter.</summary>
public void Submit() => Send();
/// <summary>The animation a change applies to (for the placeholder), or null for a new animation.</summary>
public void SetTarget( string clipName )
{
_target = clipName;
RefreshChips();
RefreshPlaceholder();
}
public string Target => _target;
/// <summary>Adds a chip before the text field (e.g. what the prompt does).</summary>
public ChipButton AddChip( string text, string icon, Action clicked, string tooltip )
=> _leading.Add( new ChipButton( this, text, icon, clicked, tooltip ) );
/// <summary>Whether the body-part chip is shown.</summary>
public bool ShowScope
{
get => _showScope;
set { _showScope = value; if ( !value ) Options.Scope = ChangeScope.WholeBody; RefreshChips(); }
}
/// <summary>Whether the settings offer a length (new animations).</summary>
public bool ShowLength { get => _showLength; set { _showLength = value; RefreshChips(); } }
/// <summary>Re-reads the options into the chips (after changing <see cref="Options"/> in code).</summary>
public void RefreshOptions() => RefreshChips();
/// <summary>Disables sending, with the reason shown in the field (null enables).</summary>
public void SetDisabledReason( string reason )
{
_disabledReason = reason;
_box.Editable = reason is null;
_send.Enabled = reason is null || _busy;
RefreshPlaceholder();
}
public bool Busy
{
get => _busy;
set { _busy = value; _send.Stop = value; _send.Enabled = value || _disabledReason is null; _send.Update(); }
}
public string Placeholder { get => _placeholder; set { _placeholder = value; RefreshPlaceholder(); } }
void RefreshPlaceholder() => _box.PlaceholderText = _disabledReason ?? _placeholder ?? "Describe a motion…";
void Send()
{
if ( _busy || _disabledReason is not null ) return;
var text = Text.Trim();
if ( text.Length == 0 && !AllowEmpty ) { _box.Focus(); return; }
Submitted?.Invoke( text );
}
/// <summary>One line by default; grows with Shift+Enter lines up to three.</summary>
void Grow()
{
var lines = Math.Clamp( (Text.Count( c => c == '\n' ) + 1), 1, 3 );
var height = SingleLine + (lines - 1) * LineHeight;
_field.FixedHeight = height;
FixedHeight = height;
}
void RefreshChips()
{
_scopeChip.Visible = _showScope && _target is not null;
_scopeChip.Text = ScopeName( Options.Scope );
var parts = new List<string>();
if ( _showLength ) parts.Add( $"{Options.Seconds:0.#} s" );
parts.Add( Options.Takes == 1 ? "1 take" : $"{Options.Takes} takes" );
parts.Add( Options.Steps <= 12 ? "Fast" : Options.Steps >= 40 ? "Best" : "Standard" );
_settingsChip.Text = string.Join( " · ", parts );
}
public static string ScopeName( ChangeScope scope ) => scope switch
{
ChangeScope.UpperBody => "Upper body",
ChangeScope.LowerBody => "Legs",
ChangeScope.Arms => "Arms",
ChangeScope.SelectedBones => "Selected bones",
ChangeScope.Unlocked => "Unlocked bones",
_ => "Whole body",
};
void Choose( Menu menu, string text, bool selected, Action apply )
=> menu.AddOption( text, selected ? "check" : null, () => { apply(); RefreshChips(); } );
void ScopeMenu()
{
var menu = new Menu( this );
menu.AddHeading( "The change applies to" );
foreach ( var s in ScopeChoices )
{
var scope = s;
Choose( menu, ScopeName( scope ), Options.Scope == scope, () => Options.Scope = scope );
}
menu.OpenAtCursor();
}
void SettingsMenu()
{
var menu = new Menu( this );
if ( _showLength )
{
menu.AddHeading( "Length" );
foreach ( var s in new[] { 2f, 3f, 4f, 6f, 8f, 10f } )
Choose( menu, $"{s:0} seconds", MathF.Abs( Options.Seconds - s ) < 0.01f, () => Options.Seconds = s );
menu.AddSeparator();
}
menu.AddHeading( "Takes" );
for ( var n = 1; n <= 4; n++ )
{
var count = n;
Choose( menu, count == 1 ? "1 take" : $"{count} takes", Options.Takes == count, () => Options.Takes = count );
}
menu.AddSeparator();
menu.AddHeading( "Quality" );
Choose( menu, "Fast", Options.Steps == 12, () => Options.Steps = 12 );
Choose( menu, "Standard", Options.Steps == 24, () => Options.Steps = 24 );
Choose( menu, "Best (slowest)", Options.Steps == 40, () => Options.Steps = 40 );
menu.AddSeparator();
var advanced = menu.AddMenu( "Advanced", "more_horiz" );
advanced.AddHeading( "Result" );
Choose( advanced, "Clean up (smooth, lock feet)", Options.CleanUp, () => Options.CleanUp = true );
Choose( advanced, "Raw model output", !Options.CleanUp, () => Options.CleanUp = false );
advanced.AddSeparator();
advanced.AddHeading( "Prompt strength" );
foreach ( var g in new[] { 2f, 3f, 4.5f } )
Choose( advanced, g switch { 2f => "Loose", 3f => "Normal", _ => "Strict" }, MathF.Abs( Options.Guidance - g ) < 0.01f, () => Options.Guidance = g );
advanced.AddSeparator();
advanced.AddHeading( "Variations are" );
foreach ( var v in new[] { 0.3f, 0.5f, 0.75f } )
Choose( advanced, v switch { 0.3f => "Close to the original", 0.5f => "Somewhat different", _ => "Very different" }, MathF.Abs( Options.VariationStrength - v ) < 0.01f, () => Options.VariationStrength = v );
advanced.AddSeparator();
advanced.AddHeading( "Seed" );
Choose( advanced, "Random each time", Options.Seed is null, () => Options.Seed = null );
Choose( advanced, Options.Seed is int fixedSeed ? $"Fixed: {fixedSeed}" : "Fixed (reproducible)", Options.Seed is not null,
() => Options.Seed ??= Random.Shared.Next( 1, 99999 ) );
menu.OpenAtCursor();
}
/// <summary>The input's frame: the editor's field look, a sparkle on the left, blue edge when focused.</summary>
sealed class Field : Widget
{
public PromptBox Box { get; }
public IconButton History { get; }
public Field( Widget parent ) : base( parent )
{
Layout = Layout.Row();
Layout.Margin = new Sandbox.UI.Margin( 26, 0, 2, 0 );
Box = Layout.Add( new PromptBox( this ), 1 );
History = Layout.Add( new IconButton( "history", null, this )
{
FixedSize = 24, IconSize = 16, Background = Color.Transparent, Foreground = Theme.TextLight,
ToolTip = "Recent prompts",
} );
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
var focused = Box.IsFocused;
Paint.SetPen( focused ? TaStyle.Accent : TaStyle.InputEdge, 1 );
Paint.SetBrush( Theme.WindowBackground );
Paint.DrawRect( LocalRect.Shrink( .5f ), TaStyle.Radius );
Paint.SetPen( focused ? TaStyle.AccentLight : TaStyle.Accent );
Paint.DrawIcon( new Rect( 6, 0, 16, MathF.Min( Height, SingleLine ) ), "auto_awesome", 14, TextFlag.Center );
}
}
/// <summary>The text area: Enter sends, Shift+Enter is a new line.</summary>
sealed class PromptBox : TextEdit
{
public Action Send { get; set; }
public PromptBox( Widget parent ) : base( parent )
{
SetStyles( "background-color: transparent; border: none; font-size: 13px; padding-top: 3px;" );
VerticalScrollbarMode = ScrollbarMode.Off;
HorizontalScrollbarMode = ScrollbarMode.Off;
}
protected override void OnKeyPress( KeyEvent e )
{
if ( (e.Key == KeyCode.Enter || e.Key == KeyCode.Return) && !e.HasShift )
{
e.Accepted = true;
Send?.Invoke();
return;
}
base.OnKeyPress( e );
}
protected override void OnFocus( FocusChangeReason reason ) { base.OnFocus( reason ); Parent?.Update(); }
protected override void OnBlur( FocusChangeReason reason ) { base.OnBlur( reason ); Parent?.Update(); }
}
/// <summary>Round blue send button; a stop square while busy.</summary>
sealed class SendButton : Widget
{
readonly Action _clicked;
public bool Stop { get; set; }
public SendButton( Widget parent, Action clicked ) : base( parent )
{
_clicked = clicked;
FixedSize = 28;
Cursor = CursorShape.Finger;
MouseTracking = true;
ToolTip = "Generate (Enter)";
}
protected override void OnMouseEnter() => Update();
protected override void OnMouseLeave() => Update();
protected override void OnMousePress( MouseEvent e )
{
if ( e.LeftMouseButton ) e.Accepted = true;
}
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
if ( Enabled && e.LeftMouseButton && LocalRect.IsInside( e.LocalPosition ) ) _clicked();
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( !Enabled ? Color.White.WithAlpha( .1f ) : Paint.HasMouseOver ? TaStyle.AccentLight : TaStyle.Accent );
Paint.DrawRect( LocalRect.Shrink( 1 ), 14 );
Paint.SetPen( Enabled ? Color.White : Theme.TextDisabled );
Paint.DrawIcon( LocalRect, Stop ? "stop" : "arrow_upward", 17, TextFlag.Center );
ToolTip = Stop ? "Stop" : "Generate (Enter)";
}
}
}
/// <summary>A compact chip (icon, text, ▾) in the editor's button style, for options and modes.</summary>
public sealed class ChipButton : Widget
{
string _text;
readonly string _icon;
public Action Clicked { get; set; }
/// <summary>Draws the dropdown arrow (chips that open a menu); off for plain one-click chips.</summary>
public bool Arrow { get; set; } = true;
float ArrowRoom => Arrow ? 20 : 10;
public ChipButton( Widget parent, string text, string icon, Action clicked, string tooltip ) : base( parent )
{
_icon = icon;
Clicked = clicked;
ToolTip = tooltip;
FixedHeight = 28;
Cursor = CursorShape.Finger;
MouseTracking = true;
Text = text;
}
public string Text
{
get => _text;
set
{
_text = value ?? "";
FixedWidth = MathF.Ceiling( 8 + 16 + 5 + _text.Length * 6.2f + ArrowRoom );
Update();
}
}
protected override void OnMouseEnter() => Update();
protected override void OnMouseLeave() => Update();
protected override void OnMousePress( MouseEvent e )
{
if ( e.LeftMouseButton ) e.Accepted = true;
}
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
if ( Enabled && e.LeftMouseButton && LocalRect.IsInside( e.LocalPosition ) ) Clicked?.Invoke();
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
var hover = Paint.HasMouseOver && Enabled;
Paint.SetPen( Color.Lerp( Theme.ControlBackground.WithAlpha( 1f ), Color.White, hover ? .25f : .15f ), 1 );
Paint.SetBrush( hover ? Color.Lerp( TaStyle.ButtonFill, Color.White, .06f ) : TaStyle.ButtonFill );
Paint.DrawRect( LocalRect.Shrink( .5f ), TaStyle.Radius );
Paint.SetPen( Enabled ? TaStyle.AccentLight : Theme.TextDisabled );
Paint.DrawIcon( new Rect( 8, 0, 16, Height ), _icon, 14, TextFlag.Center );
Paint.SetDefaultFont( 8 );
Paint.SetPen( Enabled ? Theme.Text : Theme.TextDisabled );
var textWidth = Paint.MeasureText( _text ).x;
var wanted = MathF.Ceiling( 8 + 16 + 5 + textWidth + ArrowRoom );
if ( MathF.Abs( wanted - FixedWidth ) > .5f ) FixedWidth = wanted;
Paint.DrawText( new Rect( 29, 0, textWidth + 2, Height ), _text, TextFlag.LeftCenter );
if ( !Arrow ) return;
Paint.SetPen( Theme.TextLight );
Paint.DrawIcon( new Rect( Width - 18, 0, 14, Height ), "expand_more", 13, TextFlag.Center );
}
}
Game
library
#nullable enable annotations // Global usings for the s&box in-engine compiler. // // The plain net8.0 dev harness gets these automatically via <ImplicitUsings>, // but s&box's compiler injects no BCL usings at all - without this file the // library fails to compile inside the editor (CS0246 on List<>, IEnumerable<>, // FormatException, ...). Duplicating the SDK's implicit set is harmless there // (verified: no warnings). global using System; global using System.Collections.Generic; global using System.IO; global using System.Linq; global using System.Threading; global using System.Threading.Tasks; // NOTE on Vector3: s&box declares its own Vector3 in the *global namespace*, // which wins over `using System.Numerics;` imports during name lookup and // breaks this System.Numerics-based core (.X/.Y/.Z, static helpers, delegate // signatures). A global using-alias does NOT fix this (CS0576: alias conflicts // with the global-namespace type at every use site). The working fix is a // *namespace-scoped* alias, declared after the file-scoped namespace line: // // namespace TextToAnimation.Xyz; // using Vector3 = System.Numerics.Vector3; // // Every file in this tree that uses the simple name Vector3 carries that line. // (Quaternion and Matrix4x4 are not global-namespace types in s&box and need // no alias.)
Game
library
#nullable enable annotations
using System.Collections.Generic;
namespace TextToAnimation.Mapping;
/// <summary>How a mapping was produced; determines UI flow and preset-learning behavior.</summary>
public enum MappingSource
{
/// <summary>A shipped preset profile matched (mixamo, actorcore_cc, ...).</summary>
Preset,
/// <summary>A user-saved preset (keyed by <see cref="SkeletonSignature"/>) matched.</summary>
UserPreset,
/// <summary>The auto-mapper resolved roles from bone-name tokens (stage A).</summary>
AutoName,
/// <summary>The auto-mapper fell back to pure hierarchy/geometry matching (stage B).</summary>
AutoTopology,
/// <summary>The user assigned roles by hand in the mapping editor.</summary>
Manual,
/// <summary>
/// The source file itself declares the mapping — e.g. a VRM's
/// <c>humanoid.humanBones</c> block (both the 0.x <c>extensions.VRM</c> and the 1.0
/// <c>extensions.VRMC_vrm</c> layouts). Authoritative: consulted before user presets and
/// shipped-profile detection, at confidence 1.0.
/// </summary>
Authored,
}
/// <summary>
/// The outcome of mapping a source skeleton onto canonical <see cref="BoneRole"/>s:
/// role → source bone index, an overall confidence, and human-readable notes
/// (unmapped roles, ignored bones, ambiguities) for the mapping report.
/// </summary>
public sealed class MappingResult
{
/// <summary>Creates an empty result; callers fill <see cref="RoleToBone"/> and
/// <see cref="Confidence"/>.</summary>
public MappingResult(string profileName, MappingSource source)
{
ProfileName = profileName;
Source = source;
}
/// <summary>Resolved roles: canonical role → bone index in the source skeleton.</summary>
public Dictionary<BoneRole, int> RoleToBone { get; } = new();
/// <summary>Mapping confidence in [0, 1]; 1 means every required and optional role
/// resolved unambiguously.</summary>
public float Confidence { get; set; }
/// <summary>Profile that produced the mapping — a preset name, or <c>"auto"</c> /
/// <c>"topology"</c> for the auto-mapper stages.</summary>
public string ProfileName { get; }
/// <summary>Where the mapping came from.</summary>
public MappingSource Source { get; }
/// <summary>Report notes: unmapped roles, ignored source bones, ambiguities.</summary>
public List<string> Notes { get; } = new();
}
Game
library
#nullable enable annotations
using System;
using System.Numerics;
namespace TextToAnimation.Maths;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/TextToAnimation/Assembly.cs)
/// <summary>
/// Quaternion algebra helpers used throughout the retargeting pipeline.
/// All angles are radians; quaternions are XYZW (<see cref="System.Numerics.Quaternion"/> native);
/// <c>a * b</c> applies <c>b</c> first (column-vector convention).
/// </summary>
public static class MathQ
{
private const float NearZeroLengthSq = 1e-12f;
/// <summary>
/// Defensively normalizes a quaternion. Returns <see cref="Quaternion.Identity"/> when the
/// input has near-zero length or non-finite components instead of producing NaNs.
/// </summary>
public static Quaternion Normalize(Quaternion q)
{
var lengthSq = q.LengthSquared();
if (!float.IsFinite(lengthSq) || lengthSq < NearZeroLengthSq)
return Quaternion.Identity;
return Quaternion.Multiply(q, 1f / MathF.Sqrt(lengthSq));
}
/// <summary>
/// Shortest-arc rotation taking direction <paramref name="a"/> onto direction <paramref name="b"/>
/// (magnitudes are ignored). For antiparallel inputs a 180° rotation about a stable axis
/// perpendicular to <paramref name="a"/> is returned. Near-zero inputs yield identity.
/// </summary>
public static Quaternion FromTo(Vector3 a, Vector3 b)
{
if (a.LengthSquared() < NearZeroLengthSq || b.LengthSquared() < NearZeroLengthSq)
return Quaternion.Identity;
var an = Vector3.Normalize(a);
var bn = Vector3.Normalize(b);
var dot = Vector3.Dot(an, bn);
if (dot >= 1f - 1e-8f)
return Quaternion.Identity;
if (dot <= -1f + 1e-7f)
{
// Antiparallel: any axis perpendicular to a works; pick a stable one.
return Quaternion.CreateFromAxisAngle(Perpendicular(an), MathF.PI);
}
// q = (a x b, 1 + a·b), normalized — the half-angle construction of the shortest arc.
var cross = Vector3.Cross(an, bn);
return Normalize(new Quaternion(cross.X, cross.Y, cross.Z, 1f + dot));
}
/// <summary>
/// Decomposes a rotation into <paramref name="swing"/> and <paramref name="twist"/> so that
/// <c>q == swing * twist</c> (twist applied first). The twist is the projection of the
/// rotation onto <paramref name="twistAxis"/>: its rotation axis is parallel to
/// <paramref name="twistAxis"/>, while the swing's rotation axis is perpendicular to it.
/// When the rotation is a pure 180° swing (no component about the axis), twist is identity.
/// </summary>
/// <exception cref="ArgumentException">Thrown when <paramref name="twistAxis"/> has near-zero length.</exception>
public static void SwingTwist(Quaternion q, Vector3 twistAxis, out Quaternion swing, out Quaternion twist)
{
if (twistAxis.LengthSquared() < NearZeroLengthSq)
throw new ArgumentException("Twist axis must be non-zero.", nameof(twistAxis));
var axis = Vector3.Normalize(twistAxis);
q = Normalize(q);
// Project the rotation's vector part onto the twist axis.
var projection = q.X * axis.X + q.Y * axis.Y + q.Z * axis.Z;
var raw = new Quaternion(axis.X * projection, axis.Y * projection, axis.Z * projection, q.W);
// Degenerate case (q.W ~ 0 and projection ~ 0): pure 180° swing, no twist.
twist = Normalize(raw);
swing = Normalize(q * Quaternion.Conjugate(twist));
}
/// <summary>
/// Geodesic angle between two rotations in radians, in [0, π], invariant under the
/// q / -q double cover (so <c>AngleBetween(q, -q) == 0</c>).
/// </summary>
public static float AngleBetween(Quaternion a, Quaternion b)
{
// Use atan2 on the delta rotation rather than acos on the dot product: acos is
// ill-conditioned near 0°, atan2 is accurate over the whole range.
var delta = Quaternion.Conjugate(Normalize(a)) * Normalize(b);
var sinHalf = MathF.Sqrt(delta.X * delta.X + delta.Y * delta.Y + delta.Z * delta.Z);
var cosHalf = MathF.Abs(delta.W); // |W| folds the q / -q double cover.
return 2f * MathF.Atan2(sinHalf, cosHalf);
}
/// <summary>
/// Angle between two directions in radians, in [0, π] (magnitudes are ignored).
/// Near-zero inputs yield 0. Uses atan2, which stays accurate near 0 and π where
/// acos of the dot product loses precision.
/// </summary>
public static float AngleBetween(Vector3 a, Vector3 b)
{
if (a.LengthSquared() < NearZeroLengthSq || b.LengthSquared() < NearZeroLengthSq)
return 0f;
return MathF.Atan2(Vector3.Cross(a, b).Length(), Vector3.Dot(a, b));
}
/// <summary>
/// Builds an orthonormal right-handed basis rotation from a forward and an up hint.
/// </summary>
/// <remarks>
/// Convention: returns the rotation <c>R</c> such that
/// <list type="bullet">
/// <item><c>rotate(R, +Y) == normalize(forward)</c> (primary axis),</item>
/// <item><c>rotate(R, +Z) == </c> the Gram-Schmidt orthonormalization of <paramref name="up"/>
/// against forward (secondary axis),</item>
/// <item><c>rotate(R, +X) == forwardN x upOrtho</c> (right-handed completion).</item>
/// </list>
/// Degenerate inputs are handled defensively: a near-zero forward yields identity; an up hint
/// (near-)parallel to forward falls back to world +Z, or world +X when forward itself is
/// (near-)parallel to +Z.
/// </remarks>
public static Quaternion BasisFromForwardUp(Vector3 forward, Vector3 up)
{
if (forward.LengthSquared() < NearZeroLengthSq)
return Quaternion.Identity;
var y = Vector3.Normalize(forward);
var z = up - y * Vector3.Dot(up, y);
if (z.LengthSquared() < 1e-8f)
{
// Up hint unusable: pick a stable fallback and orthonormalize it.
var fallback = MathF.Abs(Vector3.Dot(y, Vector3.UnitZ)) > 0.99f ? Vector3.UnitX : Vector3.UnitZ;
z = fallback - y * Vector3.Dot(fallback, y);
}
z = Vector3.Normalize(z);
var x = Vector3.Cross(y, z);
// System.Numerics matrices act on row vectors, so the rows are the images of the
// unit axes under the rotation: row1 = R*X, row2 = R*Y, row3 = R*Z.
var m = new Matrix4x4(
x.X, x.Y, x.Z, 0f,
y.X, y.Y, y.Z, 0f,
z.X, z.Y, z.Z, 0f,
0f, 0f, 0f, 1f);
return Normalize(Quaternion.CreateFromRotationMatrix(m));
}
/// <summary>Stable unit vector perpendicular to <paramref name="unit"/> (assumed normalized).
/// Internal so geometry passes (e.g. <c>TwoBoneIk</c>) reuse one implementation.</summary>
internal static Vector3 Perpendicular(Vector3 unit)
{
var p = Vector3.Cross(unit, Vector3.UnitX);
if (p.LengthSquared() < 1e-6f)
p = Vector3.Cross(unit, Vector3.UnitY);
return Vector3.Normalize(p);
}
}
Game
library
#nullable enable annotations
using System.Text;
using System.Text.RegularExpressions;
namespace TextToAnimation.Rig;
/// <summary>
/// Raw bone name to the "clean" joint vocabulary UniMate was trained on ("mixamorig:LeftUpLeg" -> "Left Thigh",
/// "thigh.L" -> "Left Thigh", "Tail_05" -> "Tail"). A port of the generic and Mixamo paths of UniMate's
/// data_process/joint_annotation/names_clean_rule.py (clean_joint_name + post_process); the dataset-specific
/// maps (Japanese, spider, NPC, elk rigs) are left out. Names are only a hint: <see cref="RigAnalysis"/>
/// labels bones from geometry and uses these for bones it cannot classify.
/// </summary>
public static class JointNames
{
static readonly Regex TrailingToken = new(@"[._](\d+|[LRlr]|x)$", RegexOptions.CultureInvariant);
static readonly Regex SideToken = new(@"[._]([LR])(?=[._]|\d|$)", RegexOptions.CultureInvariant);
static readonly Regex SingleSide = new(@"^[LR](?:[A-Z][a-z]|[A-Z]{0,2}_[A-Za-z])", RegexOptions.CultureInvariant);
static readonly Regex FingerCode = new(@"^[Ff]inger([0-4])\d*(Nub)?$", RegexOptions.CultureInvariant);
static readonly Regex FingerSeg = new(@"^[Ff]inger([1-5])(Metacarpal|Proximal|Medial|Distal|Tip)\d*$", RegexOptions.CultureInvariant);
static readonly Regex ParenDecor = new(@"\s*\([^)]*\)", RegexOptions.CultureInvariant);
static readonly Regex Namespace = new(@"^[A-Za-z][\w .-]*:", RegexOptions.CultureInvariant);
static readonly Regex BipPrefix = new(@"^(?:BN_)?Bip\d+(?:[-_ ]+|(?=[LR][A-Z]))", RegexOptions.CultureInvariant);
static readonly Regex Mixamorig = new(@"^mixamorig\d*[:_]", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
static readonly string[] FingerCodeNames = { "Thumb", "Index", "Middle", "Ring", "Pinky" };
/// <summary>The cleaned, post-processed joint name (never empty; "Bone" when nothing anatomical survives).</summary>
public static string Clean(string raw) => PostProcess(CleanJointName(raw ?? ""));
/// <summary>True when <paramref name="cleaned"/> (side word removed) is a word of the training vocabulary.</summary>
public static bool IsKnown(string cleaned)
{
var (_, baseName) = SplitSide(cleaned);
if (baseName.Length == 0 || baseName == "Bone") return false;
if (baseName.EndsWith(" End", StringComparison.Ordinal)) baseName = baseName[..^4];
return KnownWords.Contains(baseName);
}
/// <summary>("Left", "Thigh") from "Left Thigh"; side is "" for center names.</summary>
public static (string Side, string Base) SplitSide(string cleaned)
{
if (cleaned.StartsWith("Left ", StringComparison.Ordinal)) return ("Left", cleaned[5..]);
if (cleaned.StartsWith("Right ", StringComparison.Ordinal)) return ("Right", cleaned[6..]);
return ("", cleaned);
}
static HashSet<string>? _known;
static HashSet<string> KnownWords
{
get
{
if (_known is not null) return _known;
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var v in Canonical.Values) if (v.Length > 0) set.Add(SplitSide(v).Base);
foreach (var v in MixamoMap.Values) set.Add(SplitSide(v).Base);
foreach (var v in SymmetricPairPriority) set.Add(v);
set.Remove("Bone");
set.Remove("End");
return _known = set;
}
}
static string CleanJointName(string raw)
{
if (raw.Trim().Length == 0) return raw;
var stripped = ParenDecor.Replace(raw, "").Trim();
if (stripped.Length > 0) raw = stripped;
if (Regex.IsMatch(raw, @"^Bone\d+$")) return "Bone";
if (Regex.IsMatch(raw.Trim('_'), @"^_?\d+$")) return raw;
var m = Mixamorig.Match(raw);
if (m.Success) return CleanPrefixed(raw, m.Value, MixamoMap);
return CleanStandard(raw);
}
static string CleanPrefixed(string raw, string prefix, Dictionary<string, string> map)
{
var name = raw[prefix.Length..];
var (side, rest) = ExtractSidePrefix(name);
if (!map.TryGetValue(rest, out var result) && !map.TryGetValue(Regex.Replace(rest, @"\d+$", ""), out result))
{
result = SplitAndMapTokens(rest);
if (result.Length == 0) result = rest;
}
return WithSide(side, result).Trim();
}
static string? Canon(string token)
{
if (Canonical.TryGetValue(token, out var value)) return value;
if (token.Length > 0)
{
var cap = Capitalize(token);
if (cap != token && Canonical.TryGetValue(cap, out value)) return value;
}
return null;
}
/// <summary>Python's str.capitalize(): first character upper, the rest lower.</summary>
static string Capitalize(string s) => s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..].ToLowerInvariant();
static (string Side, string Name) StripTrailingDecorations(string name)
{
var side = "";
while (true)
{
var m = TrailingToken.Match(name);
if (!m.Success) return (side, name);
var tok = m.Groups[1].Value;
if (tok is "L" or "l") side = "Left";
else if (tok is "R" or "r") side = "Right";
name = name[..m.Index];
}
}
static (string Side, string Name) ExtractSidePrefix(string name)
{
var (side, baseName) = StripTrailingDecorations(name);
name = baseName;
if (side.Length > 0)
{
var cut = Regex.Replace(name, @"^(?:Left|Right)(?=[A-Z])|^(?:Left|Right)[_ ]|^[LR][-_]|^[lr]_", "");
return (side, cut.Length > 0 ? cut : name);
}
if (Regex.IsMatch(name, @"^[RL][-_.]")) return (name[0] == 'L' ? "Left" : "Right", name[2..]);
var m = Regex.Match(name, @"^([RL]) +");
if (m.Success) return (m.Groups[1].Value == "L" ? "Left" : "Right", name[m.Length..]);
m = Regex.Match(name, @"^(Left|Right)_", RegexOptions.IgnoreCase);
if (m.Success) return (Capitalize(m.Groups[1].Value), name[m.Length..]);
m = Regex.Match(name, @"^(Left|Right)(?=[A-Z])");
if (m.Success) return (m.Groups[1].Value, name[m.Groups[1].Length..]);
if (name.Length > 1 && (name[0] == 'L' || name[0] == 'R') && char.IsUpper(name[1]))
{
var t = SideToken.Match(name, 1);
if (t.Success)
{
var rest = (name[..t.Index] + name[(t.Index + t.Length)..]).Trim('.', '_');
return (t.Groups[1].Value == "L" ? "Left" : "Right", rest.Length > 0 ? rest : name);
}
if (SingleSide.IsMatch(name)) return (name[0] == 'L' ? "Left" : "Right", name[1..]);
}
m = Regex.Match(name, @"_([LR])_(\d+)$");
if (m.Success) return (m.Groups[1].Value == "L" ? "Left" : "Right", name[..m.Index] + "_" + m.Groups[2].Value);
m = Regex.Match(name, @"_([LR])$");
if (m.Success) return (m.Groups[1].Value == "L" ? "Left" : "Right", name[..m.Index]);
return ("", name);
}
static string SplitAndMapTokens(string name)
{
var mapped = new List<string>();
foreach (var t in Regex.Split(name, @"(?=[A-Z])|_"))
{
if (t.Length == 0) continue;
var baseName = Regex.Replace(t, @"\d+$", "");
var value = Canon(baseName);
if (value is not null) { if (value.Length > 0) mapped.Add(value); }
else if (baseName.Length > 1) mapped.Add(baseName);
}
return string.Join(" ", mapped);
}
static string WithSide(string side, string result)
{
if (side.Length == 0) return result;
var m = Regex.Match(result, @"^(Left|Right)\b\s*");
if (m.Success) result = result[m.Length..];
return (side + " " + result).Trim();
}
static string CleanStandard(string raw)
{
var name = Namespace.Replace(raw, "", 1);
if (name.Length == 0) name = raw;
name = BipPrefix.Replace(name, "", 1);
foreach (var prefix in RemovePrefixes)
if (name.StartsWith(prefix, StringComparison.Ordinal)) { name = name[prefix.Length..]; break; }
name = name.TrimStart('_').Trim();
if (name.Length == 0) return raw.Trim('_');
var (side, rest) = ExtractSidePrefix(name);
name = rest;
var m = FingerSeg.Match(name);
if (m.Success)
{
var label = FingerCodeNames[int.Parse(m.Groups[1].Value) - 1] + " Finger";
if (m.Groups[2].Value == "Tip") label += " End";
return WithSide(side, label);
}
m = FingerCode.Match(name);
if (m.Success)
{
var label = FingerCodeNames[int.Parse(m.Groups[1].Value)] + " Finger";
if (m.Groups[2].Success && m.Groups[2].Value.Length > 0) label += " End";
return WithSide(side, label);
}
m = Regex.Match(name, @"^(.+?)_?(\d+)$");
var baseName = m.Success ? m.Groups[1].Value.TrimEnd('_') : name;
var mappedBase = Canon(baseName);
if (mappedBase is not null)
{
if (mappedBase.Length == 0 || mappedBase == "Bone") return "Bone";
return WithSide(side, mappedBase);
}
var mapped = new List<string>();
foreach (var p in baseName.Split('_'))
{
if (p.Length == 0) continue;
if (p is "L" or "l") { if (side.Length == 0) side = "Left"; continue; }
if (p is "R" or "r") { if (side.Length == 0) side = "Right"; continue; }
var subBase = Regex.Replace(p, @"\d+$", "");
var subMapped = Canon(subBase);
if (subMapped is not null) { if (subMapped.Length > 0) mapped.Add(subMapped); }
else if (subBase.Length > 0)
{
foreach (var st in Regex.Split(subBase, @"(?=[A-Z])"))
{
if (st.Length == 0) continue;
var stMapped = Canon(st);
if (stMapped is not null) { if (stMapped.Length > 0) mapped.Add(stMapped); }
else if (st.Length > 1) mapped.Add(st);
}
}
}
var result = string.Join(" ", mapped);
if (result.Length == 0 || result == "Bone") return "Bone";
var sided = WithSide(side, result).Trim();
return sided.Length > 0 ? sided : raw;
}
static string PostProcess(string name)
{
name = Regex.Replace(name, @"\s+", " ").Trim().TrimEnd('.', '_', ' ');
name = Regex.Replace(name, @"\s+\d+$", "");
name = Regex.Replace(name, @"(\w)\d+\s+End", "$1 End");
name = Regex.Replace(name, @"\bHand (Thumb|Index|Middle|Ring|Pinky) Finger\b", "$1 Finger");
name = Regex.Replace(name, @"\b(Thumb|Index|Middle|Ring|Pinky) Finger Finger\b", "$1 Finger");
name = Regex.Replace(name, @"\bFinger (Thumb|Index|Middle|Ring|Pinky) Finger\b", "$1 Finger");
name = Regex.Replace(name, @"\b(?:Up|Upper) Leg$", "Thigh");
name = Regex.Replace(name, @"\bLower Leg$", "Shin");
name = Regex.Replace(name, @"\b(?:Lower|Fore) Arm$", "Forearm");
name = Regex.Replace(name, @"\bToe Base$", "Toe");
name = Regex.Replace(name, @"^(?!Left |Right )(.+) (Left|Right)$", "$2 $1");
name = Regex.Replace(name, @"^Head Top End$", "Head End");
name = name.Replace(". ", " ");
name = Regex.Replace(name, @"^Spine ?\d* ?Tail$", "Tail");
name = Regex.Replace(name, @"^Head ?\d* ?Jaw$", "Jaw");
name = Regex.Replace(name, @"^Head ?\d* ?Eyelid$", "Eyelid");
name = Regex.Replace(name, @"^Head Muzzle$", "Muzzle");
name = Regex.Replace(name, @"^Head Jaw End$", "Jaw End");
name = Regex.Replace(name, @"^Head Brain$", "Head");
name = Regex.Replace(name, @"Bip \d+ ", "");
name = Regex.Replace(name, @"^(\w+) \1$", "$1");
if (Regex.IsMatch(name, @"^Xtra")) name = "Bone";
name = Regex.Replace(name, @"Ponytail\d*.*", "Appendage");
var words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sb = new StringBuilder();
foreach (var w in words)
{
if (sb.Length > 0) sb.Append(' ');
sb.Append(char.IsLower(w[0]) && w.Length > 1 ? Capitalize(w) : w);
}
name = sb.ToString();
name = Regex.Replace(name, @"^Spine (Left|Right) Wing$", "$1 Wing");
name = Regex.Replace(name, @"\s+\d+$", "");
return name.Length == 0 ? "Bone" : name;
}
// generated from UniMate data_process/joint_annotation/vocab.py and face_select_rule.py
static readonly string[] RemovePrefixes = { "BN_Bip01_", "BN_Bip01 ", "Base Human ", "Bip001_", "Bip001 ", "Bip01_", "Bip01 ", "Preset01_", "Preset01 ", "Mutant:", "Sif:", "BN_", "NPC_", "jt_", "Bn_", "bn_", "b_" };
static readonly Dictionary<string, string> Canonical = new(StringComparer.Ordinal)
{
["Pelvis"] = "Pelvis",
["pelv"] = "Pelvis",
["Spine"] = "Spine",
["Spn"] = "Spine",
["Spline"] = "Spine",
["Ribcage"] = "Ribcage",
["Neck"] = "Neck",
["Nek"] = "Neck",
["Head"] = "Head",
["Scull"] = "Skull",
["Skull"] = "Skull",
["ScullBase"] = "Skull Base",
["SkullBase"] = "Skull Base",
["HeadNub"] = "Head End",
["Nub"] = "End",
["Tip"] = "End",
["Hips"] = "Hips",
["Cog"] = "Root",
["Clavicle"] = "Shoulder",
["Collarbone"] = "Shoulder",
["Scapula"] = "Scapula",
["UpperArm"] = "Upper Arm",
["Upperarm"] = "Upper Arm",
["Humerus"] = "Upper Arm",
["Forearm"] = "Forearm",
["ForeArm"] = "Forearm",
["LowerArm"] = "Forearm",
["Lowerarm"] = "Forearm",
["Radius"] = "Forearm",
["Hand"] = "Hand",
["Hnd"] = "Hand",
["Palm"] = "Palm",
["Wrist"] = "Wrist",
["Elbow"] = "Elbow",
["Shoulder"] = "Shoulder",
["Thigh"] = "Thigh",
["Femur"] = "Thigh",
["UpLeg"] = "Thigh",
["UpperLeg"] = "Thigh",
["Upperleg"] = "Thigh",
["Calf"] = "Shin",
["Tibia"] = "Shin",
["Leg"] = "Leg",
["LowerLeg"] = "Shin",
["Lowerleg"] = "Shin",
["HorseLink"] = "Fetlock",
["LargeCannon"] = "Cannon",
["Metacarpus"] = "Metacarpus",
["PhalangesManus"] = "Phalanges",
["PhalanxPrima"] = "Pastern",
["Foot"] = "Foot",
["Ankle"] = "Ankle",
["Heel"] = "Heel",
["Toe"] = "Toe",
["Toes"] = "Toe",
["ToeBase"] = "Toe",
["Ball"] = "Toe",
["Hoof"] = "Hoof",
["Knee"] = "Knee",
["Foreleg"] = "Front Leg",
["Hindleg"] = "Hind Leg",
["Finger"] = "Finger",
["Thumb"] = "Thumb Finger",
["Pinky"] = "Pinky Finger",
["Little"] = "Pinky Finger",
["Ring"] = "Ring Finger",
["Middle"] = "Middle Finger",
["Index"] = "Index Finger",
["Jaw"] = "Jaw",
["Tongue"] = "Tongue",
["Thouge"] = "Tongue",
["Tone"] = "Tongue",
["tunge"] = "Tongue",
["Tunge"] = "Tongue",
["Ear"] = "Ear",
["Eye"] = "Eye",
["Eyeball"] = "Eyeball",
["EyeBall"] = "Eyeball",
["Eyebrow"] = "Eyebrow",
["Eyelid"] = "Eyelid",
["Eyeleds"] = "Eyelid",
["Mouth"] = "Mouth",
["Lip"] = "Lip",
["Nose"] = "Nose",
["Muzzle"] = "Muzzle",
["Chin"] = "Chin",
["Cheek"] = "Cheek",
["Torso"] = "Body",
["LegAnkle"] = "Ankle",
["Fingers"] = "Finger",
["Tail"] = "Tail",
["Tai"] = "Tail",
["Wing"] = "Wing",
["RWing"] = "Right Wing",
["LWing"] = "Left Wing",
["Feather"] = "Feather",
["Feeler"] = "Antenna",
["Antenna"] = "Antenna",
["Feelers"] = "Barbel",
["Tentacle"] = "Tentacle",
["Tentacles"] = "Tentacle",
["Claw"] = "Claw",
["HandClaw"] = "Hand Claw",
["Fang"] = "Fang",
["Fangs"] = "Fang",
["Mandible"] = "Mandible",
["BigMandible"] = "Large Mandible",
["LowerMandible"] = "Lower Mandible",
["Pincer"] = "Pincer",
["pincers"] = "Pincer",
["Pliers"] = "Pincer",
["Piers"] = "Pincer",
["Mane"] = "Mane",
["Fur"] = "Fur",
["Hair"] = "Mane",
["Beard"] = "Whisker",
["Mascara"] = "Whisker",
["Shell"] = "Shell",
["Stinger"] = "Stinger",
["Horn"] = "Horn",
["dorsal"] = "Dorsal Plate",
["Fin"] = "Fin",
["ponitail"] = "Crest",
["Ponytail"] = "Appendage",
["Downbody"] = "Lower Body",
["Down"] = "Lower Body",
["Body"] = "Body",
["Reins"] = "Reins",
["Halter"] = "Halter",
["Jiggle"] = "Jiggle",
["TwistBone"] = "Twist",
["UpperArmTwist"] = "Upper Arm Twist",
["ForearmTwist"] = "Forearm Twist",
["ThighMuscle"] = "Thigh Muscle",
["NeckMuscle"] = "Neck Muscle",
["Clip"] = "Mandible",
["Wings"] = "Wing",
["Shall"] = "Mandible",
["Trajectory"] = "Root",
["locator"] = "Root",
["locator2"] = "Root",
["center"] = "Center",
["MagicEffectsNode"] = "Bone",
["Handle"] = "Handle",
["IK_Chain"] = "IK Chain",
["Joint"] = "",
["Jnt"] = "",
["Internal"] = "",
["Def"] = "",
["Part"] = "",
["Armature"] = "",
["Mixamorig"] = "",
["Character"] = "",
["Rig"] = "",
["Bip"] = "",
["Base"] = "",
["Human"] = "",
["Mid"] = "",
["Controller"] = "",
["Controler"] = "",
["Ctrl"] = "",
["Quick"] = "",
["Untitled"] = "",
["Pasted"] = "",
["Bind"] = "",
["Skeleton"] = "",
["Reference"] = "",
["Res"] = "",
["Main"] = "",
["Metacarpal"] = "",
["Proximal"] = "",
["Intermediate"] = "",
["Medial"] = "",
["Distal"] = "",
["Digit"] = "Finger",
["FrontLeg"] = "Front Leg",
["MiddleLeg"] = "Middle Leg",
["HindLeg"] = "Hind Leg",
["Crab_pincers"] = "Pincer",
["SpineR"] = "Right Abdomen",
["SpineL"] = "Left Abdomen",
};
static readonly Dictionary<string, string> MixamoMap = new(StringComparer.Ordinal)
{
["Hips"] = "Hips",
["Spine"] = "Spine",
["Spine1"] = "Spine",
["Spine2"] = "Spine",
["Neck"] = "Neck",
["Head"] = "Head",
["HeadTop_End"] = "Head End",
["Eye"] = "Eye",
["Shoulder"] = "Shoulder",
["Arm"] = "Upper Arm",
["ForeArm"] = "Forearm",
["Hand"] = "Hand",
["HandThumb"] = "Thumb Finger",
["HandIndex"] = "Index Finger",
["HandMiddle"] = "Middle Finger",
["HandRing"] = "Ring Finger",
["HandPinky"] = "Pinky Finger",
["UpLeg"] = "Thigh",
["Leg"] = "Shin",
["Foot"] = "Foot",
["ToeBase"] = "Toe",
["Toe_End"] = "Toe End",
};
public static readonly string[] SymmetricPairPriority = { "Thigh", "Shoulder", "Front Shoulder", "Back Hip", "Hip", "Scapula", "Upper Arm", "Arm", "Front Leg", "Hind Leg", "Middle Leg", "Back Leg", "Wing", "Leg", "Pectoral Fin", "Pelvic Fin", "Fin", "Gill", "Pincer", "Mandible", "Large Mandible", "Lower Mandible", "Stinger", "Claw", "Hand Claw", "Antenna", "Forearm", "Shin", "Knee", "Elbow", "Ankle", "Wrist", "Hand", "Palm", "Foot", "Heel", "Front Paw", "Back Paw", "Paw", "Front Hoof", "Rear Hoof", "Hoof", "Fetlock", "Cannon", "Metacarpus", "Pastern", "Toe", "Thumb Finger", "Index Finger", "Middle Finger", "Ring Finger", "Pinky Finger", "Finger", "Neck", "Eye", "Eyeball", "Eyelid", "Eyebrow", "Ear", "Horn", "Cheek", "Whisker", "Fang", "Barbel", "Tentacle", "Feather", "Tail" };
public static readonly string[] TailKeywords = { "Tail", "Tail Twist" };
public static readonly string[] HeadKeywords = { "Chin", "Head", "Head End", "Jaw", "Lower Jaw", "Muzzle", "Nose", "Skull", "Skull Base", "Tongue", "Upper Jaw" };
}
Game
library
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Text.Json;
using TextToAnimation.Mapping;
using TextToAnimation.Maths;
using TextToAnimation.Rig;
using SkeletonModel = TextToAnimation.Rig.Skeleton;
namespace TextToAnimation.Rig;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/TextToAnimation/Assembly.cs)
/// <summary>
/// A humanoid target rig: skeleton plus per-bone <see cref="BoneClass"/> and (for animated
/// bones) canonical <see cref="BoneRole"/> annotations. The shipped s&box default is
/// loaded from the committed <c>Assets/humanoid_retargeter/target_rig_sbox.json</c> produced
/// by <see cref="TargetRigGenerator"/> (see <see cref="SboxDefault"/>); arbitrary user-picked
/// targets are built from any <see cref="SkeletonModel"/> via <see cref="FromSkeleton"/>.
/// This type does no file IO — callers pass JSON text.
/// </summary>
public sealed class TargetRig
{
private readonly BoneClass[] _classes;
private readonly BoneRole?[] _roles;
private readonly Dictionary<BoneRole, int> _boneByRole;
private readonly Vector3?[]? _tailWorld;
private TargetRig(string name, SkeletonModel skeleton, BoneClass[] classes, BoneRole?[] roles,
Dictionary<BoneRole, int> boneByRole, Vector3?[]? tailWorld = null,
bool helpersAreConstraintDriven = true)
{
Name = name;
Skeleton = skeleton;
_classes = classes;
_roles = roles;
_boneByRole = boneByRole;
_tailWorld = tailWorld;
HelpersAreConstraintDriven = helpersAreConstraintDriven;
}
/// <summary>Rig name (e.g. <c>sbox_human_male</c>).</summary>
public string Name { get; }
/// <summary>
/// True when the target model's own vmdl re-drives the
/// <see cref="BoneClass.ConstraintDriven"/> helpers at runtime (the shipped s&box
/// rigs: their AnimConstraintList drives twist/helper bones, so clips exclude those
/// channels). False for rigs built from plain skeletons
/// (<see cref="FromSkeleton"/> — custom FBX targets): the generated vmdl carries NO
/// constraints, so twist-named bones must keep baked channels or they freeze at rest
/// (candy-wrapped wrists on an Auto-Rig Pro export).
/// </summary>
public bool HelpersAreConstraintDriven { get; }
/// <summary>The target skeleton (rest pose in centimeters, parents before children).</summary>
public SkeletonModel Skeleton { get; }
/// <summary>The class of the bone at <paramref name="boneIndex"/>.</summary>
public BoneClass ClassOf(int boneIndex) => _classes[boneIndex];
/// <summary>The canonical role of the bone at <paramref name="boneIndex"/>, or null
/// for role-less (non-animated) bones.</summary>
public BoneRole? RoleOf(int boneIndex) => _roles[boneIndex];
/// <summary>The bone index carrying <paramref name="role"/>, or null when the rig has no
/// bone for it (e.g. <see cref="BoneRole.Spine3"/>, <see cref="BoneRole.ThumbMetaL"/>).</summary>
public int? BoneForRole(BoneRole role) => _boneByRole.TryGetValue(role, out var index) ? index : null;
/// <summary>
/// Rest tail position of the bone in rig world space (cm), when the rig definition
/// carries one (<c>tail_world</c> in the generated JSON — the s&box default rig
/// does). Null for rigs built from plain skeletons (<see cref="FromSkeleton"/>), which
/// have no tail data. Used by the DL solver's end-joint synthesis.
/// </summary>
public Vector3? TailWorldOf(int boneIndex) => _tailWorld?[boneIndex];
/// <summary>Uses an authoritative bind skeleton while retaining this rig's curated
/// roles, helper classes and constraint ownership. Additional bones keep rest channels.</summary>
public TargetRig WithBindPose(SkeletonModel skeleton)
{
ArgumentNullException.ThrowIfNull(skeleton);
var classes = new BoneClass[skeleton.Count];
var roles = new BoneRole?[skeleton.Count];
var byRole = new Dictionary<BoneRole, int>();
var tails = new Vector3?[skeleton.Count];
for (var i = 0; i < skeleton.Count; i++)
{
var old = Skeleton.IndexOf(skeleton[i].Name);
if (old < 0) continue;
classes[i] = _classes[old];
roles[i] = _roles[old];
if (roles[i] is { } role) byRole.Add(role, i);
if (TailWorldOf(old) is { } tail)
{
var relative = Vector3.Transform(tail - Skeleton.RestWorld[old].Pos, Quaternion.Inverse(Skeleton.RestWorld[old].Rot));
tails[i] = skeleton.RestWorld[i].Pos + Vector3.Transform(relative, skeleton.RestWorld[i].Rot);
}
}
foreach (var role in _boneByRole.Keys)
if (!byRole.ContainsKey(role)) throw new ArgumentException($"New bind skeleton is missing role {role}.", nameof(skeleton));
return new TargetRig(Name, skeleton, classes, roles, byRole, tails, HelpersAreConstraintDriven);
}
/// <summary>Indices of all bones of the given class, in skeleton order.</summary>
public IEnumerable<int> BonesOfClass(BoneClass boneClass)
{
for (var i = 0; i < _classes.Length; i++)
{
if (_classes[i] == boneClass)
yield return i;
}
}
/// <summary>
/// Parses a target-rig definition produced by <see cref="TargetRigGenerator"/>.
/// </summary>
/// <exception cref="ArgumentException">Thrown when the JSON does not match the expected
/// schema or violates invariants (e.g. a role on a non-animated bone, duplicate roles).</exception>
public static TargetRig Load(string json)
{
ArgumentNullException.ThrowIfNull(json);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var name = root.GetProperty("name").GetString()
?? throw new ArgumentException("Target rig JSON has no 'name'.");
var bonesJson = root.GetProperty("bones");
var count = bonesJson.GetArrayLength();
var definitions = new List<BoneDefinition>(count);
var classByName = new Dictionary<string, BoneClass>(count, StringComparer.Ordinal);
var roleByName = new Dictionary<string, BoneRole>(count, StringComparer.Ordinal);
var tailByName = new Dictionary<string, Vector3>(count, StringComparer.Ordinal);
foreach (var bone in bonesJson.EnumerateArray())
{
// Shared bone-geometry shape (name/parent/local_pos/local_rot_xyzw) — one reader
// for rig ground-truth JSON and target-rig JSON (RigJson owns it).
var definition = RigJson.ReadBoneDefinition(bone);
var boneName = definition.Name;
definitions.Add(definition);
var boneClass = Enum.Parse<BoneClass>(bone.GetProperty("class").GetString()!);
classByName[boneName] = boneClass;
if (bone.TryGetProperty("role", out var roleProp))
{
if (boneClass != BoneClass.Animated)
throw new ArgumentException(
$"Bone '{boneName}' is {boneClass} but carries a role — only Animated bones may have roles.");
roleByName[boneName] = Enum.Parse<BoneRole>(roleProp.GetString()!);
}
if (bone.TryGetProperty("tail_world", out var tailProp)
&& tailProp.ValueKind == JsonValueKind.Array && tailProp.GetArrayLength() == 3)
{
tailByName[boneName] = new Vector3(
(float)tailProp[0].GetDouble(),
(float)tailProp[1].GetDouble(),
(float)tailProp[2].GetDouble());
}
}
var skeleton = SkeletonModel.Create(definitions);
var classes = new BoneClass[skeleton.Count];
var roles = new BoneRole?[skeleton.Count];
var boneByRole = new Dictionary<BoneRole, int>();
var tails = tailByName.Count > 0 ? new Vector3?[skeleton.Count] : null;
for (var i = 0; i < skeleton.Count; i++)
{
var boneName = skeleton[i].Name;
classes[i] = classByName[boneName];
if (roleByName.TryGetValue(boneName, out var role))
{
roles[i] = role;
if (!boneByRole.TryAdd(role, i))
throw new ArgumentException($"Role {role} is assigned to more than one bone.");
}
if (tails is not null && tailByName.TryGetValue(boneName, out var tail))
tails[i] = tail;
}
return new TargetRig(name, skeleton, classes, roles, boneByRole, tails);
}
/// <summary>
/// Parses the shipped s&box default target rig from the committed
/// <c>Assets/humanoid_retargeter/target_rig_sbox.json</c> text (callers do the file IO).
/// Alias of <see cref="Load"/>, named per design §1 to distinguish the curated default
/// target from <see cref="FromSkeleton"/> custom targets.
/// </summary>
public static TargetRig SboxDefault(string targetRigJson) => Load(targetRigJson);
/// <summary>
/// Builds a target rig from an arbitrary humanoid skeleton (design §1 "Custom targets"):
/// roles come from <paramref name="map"/> (the same detection/mapping used for sources),
/// bone classes from <paramref name="rules"/> name patterns (default
/// <see cref="BoneClassRules"/>). Any bone that carries a mapped role is forced
/// <see cref="BoneClass.Animated"/> — a role always wins over a twist-like name. That is
/// what makes ActorCore rigs work: <c>CC_Base_NeckTwist01</c> matches the twist pattern
/// but IS the neck bone (the actorcore_cc profile maps it to <see cref="BoneRole.Neck"/>),
/// so it ends up Animated.
/// </summary>
/// <exception cref="ArgumentException">Thrown when the mapping references a bone index
/// outside the skeleton or assigns two roles to the same bone.</exception>
public static TargetRig FromSkeleton(SkeletonModel skeleton, MappingResult map, BoneClassRules? rules = null)
{
ArgumentNullException.ThrowIfNull(skeleton);
ArgumentNullException.ThrowIfNull(map);
rules ??= new BoneClassRules();
var roles = new BoneRole?[skeleton.Count];
var boneByRole = new Dictionary<BoneRole, int>(map.RoleToBone.Count);
foreach (var (role, boneIndex) in map.RoleToBone)
{
if (boneIndex < 0 || boneIndex >= skeleton.Count)
throw new ArgumentException(
$"Mapping assigns role {role} to bone index {boneIndex}, outside the skeleton (count {skeleton.Count}).",
nameof(map));
if (roles[boneIndex] is { } existing)
throw new ArgumentException(
$"Bone '{skeleton[boneIndex].Name}' carries both roles {existing} and {role}.", nameof(map));
roles[boneIndex] = role;
boneByRole.Add(role, boneIndex);
}
var classes = new BoneClass[skeleton.Count];
for (var i = 0; i < skeleton.Count; i++)
classes[i] = roles[i] is not null ? BoneClass.Animated : rules.Classify(skeleton[i].Name);
// No curated vmdl backs this rig: whatever we generate for it carries no
// AnimConstraintList, so its helper bones need real baked channels.
return new TargetRig(map.ProfileName, skeleton, classes, roles, boneByRole,
tailWorld: null, helpersAreConstraintDriven: false);
}
}
Game
library
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
namespace TextToAnimation.Vmdl;
/// <summary>
/// One AnimEvent to attach as a child of a vmdl AnimFile node. The node shape replicates the
/// shipped citizen animation list prefab exactly (all 28 shipped events are
/// <c>AE_FOOTSTEP</c>): <c>_class = "AnimEvent"</c>, <c>event_class</c>, <c>event_frame</c>
/// (integer frame on the clip's grid), and an <c>event_keys</c> object carrying
/// <c>Attachment</c> (<c>"foot_L"</c>/<c>"foot_R"</c>), <c>Foot</c> (STRING <c>"0"</c> =
/// left, <c>"1"</c> = right) and <c>Volume</c> (<c>0.7</c> throughout the shipped data).
/// </summary>
public sealed class AnimEventEntry
{
/// <summary>Event class name (e.g. <c>AE_FOOTSTEP</c>).</summary>
public string EventClass { get; set; } = "";
/// <summary>Frame the event fires on (the clip's own frame grid).</summary>
public int Frame { get; set; }
/// <summary>event_keys <c>Attachment</c> value (<c>"foot_L"</c>/<c>"foot_R"</c> in the
/// shipped footstep data); null omits the key.</summary>
public string? Attachment { get; set; }
/// <summary>event_keys <c>Foot</c> value — the shipped data encodes the side as a STRING:
/// <c>"0"</c> = left, <c>"1"</c> = right; null omits the key.</summary>
public string? Foot { get; set; }
/// <summary>event_keys <c>Volume</c> value (<c>0.7</c> in all shipped footstep events);
/// null omits the key.</summary>
public double? Volume { get; set; }
/// <summary>
/// Additional string event_keys emitted after the fixed keys, in insertion order.
/// (W3b addition, southpaw project: punch clips carry semantic contact tags such as
/// <c>Zone</c>/<c>Family</c>/<c>Hand</c> on their contact-frame AnimEvent; the shape
/// stays the shipped citizen AnimEvent node shape, only extra keys appear.) Null or
/// empty adds nothing, so existing callers are unaffected.
/// </summary>
public IReadOnlyDictionary<string, string>? ExtraKeys { get; set; }
}
/// <summary>One animation to register in a vmdl AnimationList.</summary>
public sealed class AnimEntry
{
/// <summary>Sequence name (must be unique within the AnimationList).</summary>
public string Name { get; set; } = "";
/// <summary>Animation source path relative to the assets root
/// (e.g. <c>models/x/animations/walk.dmx</c>).</summary>
public string SourceFilename { get; set; } = "";
/// <summary>Whether the sequence loops.</summary>
public bool Looping { get; set; }
/// <summary>Whether to add an ExtractMotion child node (ground-plane translation
/// extraction, linear, matching the shipped citizen prefab usage).</summary>
public bool ExtractMotion { get; set; }
/// <summary>AnimEvent children to emit on the AnimFile node (e.g. generated
/// <c>AE_FOOTSTEP</c> events); empty = no event nodes.</summary>
public IReadOnlyList<AnimEventEntry> Events { get; set; } = Array.Empty<AnimEventEntry>();
/// <summary>
/// When set, the AnimFile node gets an <c>AnimSubtract</c> child (first child, like the
/// shipped citizen data orders it) making the sequence an additive delta at compile time:
/// <c>anim_name</c> = this value (the sequence whose reference frame is subtracted —
/// the shipped <c>IdleLayer_01_delta</c> names its base sequence; other shipped entries
/// self-reference), <c>frame</c> = <see cref="SubtractFrame"/>. The animation source is
/// reused verbatim (<see cref="SourceFilename"/> points at the SAME file as the base
/// entry) — resourcecompiler performs the per-frame subtraction; no frame math happens
/// here. The AnimFile's own <c>delta</c> attribute stays <c>false</c>, exactly like every
/// shipped <c>_delta</c> sequence. Null = plain (non-additive) sequence.
/// </summary>
public string? SubtractAnimName { get; set; }
/// <summary>Reference frame index the AnimSubtract child subtracts (the shipped data uses
/// 0 for loops and mid-clip indices for aim matrices); ignored while
/// <see cref="SubtractAnimName"/> is null.</summary>
public int SubtractFrame { get; set; }
}
/// <summary>
/// One detected locomotion family to emit as a directional 2D blend: a <c>Folder</c> named by
/// the family stem grouping the member AnimFile entries plus one <c>2DBlend</c> node wired to
/// the citizen pose parameters (<c>move_x</c>/<c>move_y</c>). Produced by
/// <see cref="LocomotionSetDetector"/>; consumed by <see cref="VmdlWriter"/> and
/// <see cref="VmdlAugmenter"/>.
/// </summary>
public sealed class LocomotionSetSpec
{
/// <summary>Name of the Folder node grouping the family (the stem, collision-suffixed).</summary>
public required string FolderName { get; init; }
/// <summary>Name of the 2DBlend node (<c><stem>_2D</c>, collision-suffixed).</summary>
public required string BlendName { get; init; }
/// <summary>Looping flag of the 2DBlend node (true when every member loops; the shipped
/// locomotion blends are all looping).</summary>
public required bool Looping { get; init; }
/// <summary>
/// The 3×3 <c>blend_anim_list</c> grid, <c>[row][col]</c> with rows indexed by
/// <c>move_x</c> (−1, 0, +1) and columns by <c>move_y</c> (−1, 0, +1) — the exact shipped
/// citizen layout: row 0 = [SW, S, SE], row 1 = [W, center, E], row 2 = [NW, N, NE].
/// </summary>
public required string[][] BlendGrid { get; init; }
/// <summary>Names of the batch AnimFile entries grouped under the Folder, in canonical
/// direction order (N, NE, E, SE, S, SW, W, NW; absent diagonals skipped).</summary>
public required IReadOnlyList<string> MemberNames { get; init; }
}
/// <summary>
/// Generates standalone Base-Model vmdl files (KV3 text) that reference an existing model and
/// register retargeted animation DMX files, following the shipped citizen vmdl conventions
/// (field set proven to compile in M0 via <c>m0_test.vmdl</c>).
/// </summary>
public static class VmdlWriter
{
/// <summary>The KV3 header line used by shipped modeldoc vmdl files.</summary>
public const string Kv3Header =
"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->";
/// <summary>
/// Builds a standalone vmdl: RootNode with <c>base_model_name</c> =
/// <paramref name="baseModelPath"/>, an optional ModelModifierList/ScaleAndMirror node
/// (omitted entirely when <paramref name="scale"/> is 1.0 — engine-unit sources need no
/// rescale; 0.3937 converts cm sources like the citizen rig), and an AnimationList with
/// one AnimFile per entry. When <paramref name="locomotionSets"/> is non-empty, each
/// set's member entries are grouped under a Folder node (appended after the loose
/// entries) together with the set's 2DBlend node, replicating the shipped citizen
/// locomotion layout. When <paramref name="meshFilePath"/> is non-empty a
/// RenderMeshList/RenderMeshFile node embeds that mesh source (assets-relative,
/// <paramref name="meshImportScale"/> raw-units→skeleton-units) — this is what gives
/// custom-FBX-target vmdls a skeleton and skin to play on (an empty
/// <c>base_model_name</c> with no mesh compiles into a model with 0 bones and 0
/// sequences).
/// </summary>
public static string GenerateStandalone(string baseModelPath, IEnumerable<AnimEntry> anims,
float scale, string defaultRootBone,
IReadOnlyList<LocomotionSetSpec>? locomotionSets = null,
string meshFilePath = "", float meshImportScale = 1.0f,
IReadOnlyDictionary<string, string>? materialRemaps = null,
IReadOnlyList<string>? meshImportNames = null)
{
ArgumentNullException.ThrowIfNull(baseModelPath);
ArgumentNullException.ThrowIfNull(anims);
ArgumentNullException.ThrowIfNull(defaultRootBone);
var children = new KvArray();
if (!string.IsNullOrEmpty(meshFilePath))
{
children.Items.Add(BuildRenderMeshListNode(meshFilePath, meshImportScale, meshImportNames));
// Retarget channels may drive unweighted root/helper joints. ModelDoc otherwise
// culls them from imported meshes, breaking the hierarchy the animation targets.
children.Items.Add(new KvObject
{
["_class"] = new KvString("BoneMarkupList"),
["children"] = new KvArray(),
["bone_cull_type"] = new KvString("None"),
});
}
if (materialRemaps is { Count: > 0 })
children.Items.Add(BuildMaterialGroupListNode(materialRemaps));
if (scale != 1.0f)
{
var modifier = new KvObject
{
["_class"] = new KvString("ModelModifier_ScaleAndMirror"),
// float -> shortest-round-trip string -> double keeps "0.3937" exact in text.
["scale"] = new KvDouble(
double.Parse(scale.ToString("R", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture)),
["mirror_x"] = new KvBool(false),
["mirror_y"] = new KvBool(false),
["mirror_z"] = new KvBool(false),
["flip_bone_forward"] = new KvBool(false),
["swap_left_and_right_bones"] = new KvBool(false),
};
var modifierChildren = new KvArray();
modifierChildren.Items.Add(modifier);
children.Items.Add(new KvObject
{
["_class"] = new KvString("ModelModifierList"),
["children"] = modifierChildren,
});
}
var sets = locomotionSets ?? Array.Empty<LocomotionSetSpec>();
var grouped = new HashSet<string>(StringComparer.Ordinal);
foreach (var set in sets)
{
foreach (var member in set.MemberNames)
grouped.Add(member);
}
var animList = anims as IReadOnlyList<AnimEntry> ?? new List<AnimEntry>(anims);
var animChildren = new KvArray();
foreach (var anim in animList)
{
if (!grouped.Contains(anim.Name))
animChildren.Items.Add(BuildAnimFileNode(anim, defaultRootBone));
}
foreach (var set in sets)
animChildren.Items.Add(BuildLocomotionFolderNode(set, animList, defaultRootBone));
children.Items.Add(new KvObject
{
["_class"] = new KvString("AnimationList"),
["children"] = animChildren,
["default_root_bone_name"] = new KvString(defaultRootBone),
});
var root = new KvObject
{
["rootNode"] = new KvObject
{
["_class"] = new KvString("RootNode"),
["children"] = children,
["model_archetype"] = new KvString(""),
["primary_associated_entity"] = new KvString(""),
["anim_graph_name"] = new KvString(""),
["base_model_name"] = new KvString(baseModelPath),
},
};
return Kv3.Serialize(new Kv3Document(Kv3Header, root));
}
/// <summary>
/// Builds one AnimFile KV3 node (full attribute set as compiled in M0). When the entry
/// requests motion extraction, an ExtractMotion child extracting ground-plane translation
/// on <paramref name="motionRootBone"/> is included; <see cref="AnimEntry.Events"/>
/// become AnimEvent children (shipped-prefab node shape, see
/// <see cref="AnimEventEntry"/>); <see cref="AnimEntry.SubtractAnimName"/> adds the
/// AnimSubtract FIRST child the shipped <c>_delta</c> sequences carry
/// (<c>_class</c>/<c>anim_name</c>/<c>frame</c>, nothing else).
/// </summary>
internal static KvObject BuildAnimFileNode(AnimEntry entry, string motionRootBone)
{
ArgumentNullException.ThrowIfNull(entry);
var node = new KvObject
{
["_class"] = new KvString("AnimFile"),
["name"] = new KvString(entry.Name),
};
var nodeChildren = new KvArray();
if (entry.SubtractAnimName is not null)
{
nodeChildren.Items.Add(new KvObject
{
["_class"] = new KvString("AnimSubtract"),
["anim_name"] = new KvString(entry.SubtractAnimName),
["frame"] = new KvLong(entry.SubtractFrame),
});
}
if (entry.ExtractMotion)
{
var extract = new KvObject
{
["_class"] = new KvString("ExtractMotion"),
["extract_tx"] = new KvBool(true),
["extract_ty"] = new KvBool(true),
["extract_tz"] = new KvBool(false),
["extract_rz"] = new KvBool(false),
["linear"] = new KvBool(true),
["quadratic"] = new KvBool(false),
["root_bone_name"] = new KvString(motionRootBone),
["motion_type"] = new KvString("Single"),
};
nodeChildren.Items.Add(extract);
}
foreach (var animEvent in entry.Events)
nodeChildren.Items.Add(BuildAnimEventNode(animEvent));
if (nodeChildren.Items.Count > 0)
node["children"] = nodeChildren;
node["activity_name"] = new KvString("");
node["activity_weight"] = new KvLong(1);
node["weight_list_name"] = new KvString("");
node["fade_in_time"] = new KvDouble(0.2);
node["fade_out_time"] = new KvDouble(0.2);
node["looping"] = new KvBool(entry.Looping);
node["delta"] = new KvBool(false);
node["worldSpace"] = new KvBool(false);
node["hidden"] = new KvBool(false);
node["anim_markup_ordered"] = new KvBool(false);
node["disable_compression"] = new KvBool(false);
node["disable_interpolation"] = new KvBool(false);
node["enable_scale"] = new KvBool(false);
node["source_filename"] = new KvString(entry.SourceFilename);
node["start_frame"] = new KvLong(-1);
node["end_frame"] = new KvLong(-1);
node["framerate"] = new KvDouble(-1.0);
node["take"] = new KvLong(0);
node["reverse"] = new KvBool(false);
return node;
}
/// <summary>
/// Builds one AnimEvent KV3 node in the shipped citizen prefab shape:
/// <c>_class</c>/<c>event_class</c>/<c>event_frame</c> plus an <c>event_keys</c> object
/// (key order <c>Attachment</c>, <c>Foot</c>, <c>Volume</c>, exactly like the shipped
/// data; null-valued keys are omitted, and an all-null key set omits the object).
/// </summary>
/// <summary>A <c>RenderMeshList</c> node with one <c>RenderMeshFile</c> child (field
/// set mirrors the shipped citizen_human_male_staging.vmdl). Shared with
/// <see cref="VmdlAugmenter.EnsureMeshFile"/> so accumulated pipeline-owned vmdls that
/// predate mesh embedding can be healed in place.</summary>
internal static KvObject BuildRenderMeshListNode(string meshFilePath, float meshImportScale,
IReadOnlyList<string>? meshImportNames = null)
{
var meshChildren = new KvArray();
// Keep source mesh instances separate: merging a large multi-part FBX into one
// skin buffer can corrupt rendered vertices despite correct bones and weights.
IEnumerable<string?> parts = meshImportNames is { Count: > 1 }
? (IEnumerable<string?>)meshImportNames.Distinct(StringComparer.OrdinalIgnoreCase) : new string?[] { null };
foreach (var part in parts)
{
var names = new KvArray();
if (part is not null)
names.Items.Add(new KvString(part));
// Do not reuse an input mesh name as a ModelDoc node name (self-reference).
var name = Path.GetFileNameWithoutExtension(meshFilePath);
if (part is not null)
name += $"_part_{meshChildren.Items.Count}";
var node = new KvObject
{
["_class"] = new KvString("RenderMeshFile"),
["name"] = new KvString(name),
["filename"] = new KvString(meshFilePath),
["import_translation"] = MakeVector3(0, 0, 0),
["import_rotation"] = MakeVector3(0, 0, 0),
["import_scale"] = new KvDouble(
double.Parse(meshImportScale.ToString("R", CultureInfo.InvariantCulture),
CultureInfo.InvariantCulture)),
["align_origin_x_type"] = new KvString("None"),
["align_origin_y_type"] = new KvString("None"),
["align_origin_z_type"] = new KvString("None"),
["parent_bone"] = new KvString(""),
["import_filter"] = new KvObject
{
["exclude_by_default"] = new KvBool(part is not null),
["exception_list"] = names,
},
};
meshChildren.Items.Add(node);
}
return new KvObject
{
["_class"] = new KvString("RenderMeshList"),
["children"] = meshChildren,
};
}
/// <summary>
/// A <c>MaterialGroupList</c> with one <c>DefaultMaterialGroup</c> carrying material
/// remaps (bare mesh material reference → assets-relative vmat path) — the shipped
/// citizen vmdl's own mechanism. FBX materials are bare names the compiler cannot
/// resolve as resource paths; without the remap every mesh logs
/// 'Missing vmat "<name>.vmat"' and renders placeholder materials.
/// </summary>
internal static KvObject BuildMaterialGroupListNode(IReadOnlyDictionary<string, string> remaps)
{
var remapArray = new KvArray();
foreach (var (from, to) in remaps.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
remapArray.Items.Add(new KvObject
{
["from"] = new KvString(from),
["to"] = new KvString(to),
});
}
var groupChildren = new KvArray();
groupChildren.Items.Add(new KvObject
{
["_class"] = new KvString("DefaultMaterialGroup"),
["remaps"] = remapArray,
["use_global_default"] = new KvBool(false),
["global_default_material"] = new KvString(""),
});
return new KvObject
{
["_class"] = new KvString("MaterialGroupList"),
["children"] = groupChildren,
};
}
/// <summary>KV3 <c>[ x, y, z ]</c> double array (RenderMeshFile import vectors).</summary>
private static KvArray MakeVector3(double x, double y, double z)
{
var array = new KvArray();
array.Items.Add(new KvDouble(x));
array.Items.Add(new KvDouble(y));
array.Items.Add(new KvDouble(z));
return array;
}
private static KvObject BuildAnimEventNode(AnimEventEntry animEvent)
{
var node = new KvObject
{
["_class"] = new KvString("AnimEvent"),
["event_class"] = new KvString(animEvent.EventClass),
["event_frame"] = new KvLong(animEvent.Frame),
};
var keys = new KvObject();
if (animEvent.Attachment is not null)
keys["Attachment"] = new KvString(animEvent.Attachment);
if (animEvent.Foot is not null)
keys["Foot"] = new KvString(animEvent.Foot);
if (animEvent.Volume is { } volume)
keys["Volume"] = new KvDouble(volume);
if (animEvent.ExtraKeys is not null)
{
foreach (var kv in animEvent.ExtraKeys)
keys[kv.Key] = new KvString(kv.Value);
}
if (keys.Count > 0)
node["event_keys"] = keys;
return node;
}
/// <summary>
/// Builds a locomotion Folder node in the shipped citizen shape (<c>_class</c>,
/// <c>name</c>, <c>children</c>): the set's 2DBlend node first, then the member AnimFile
/// nodes (in <see cref="LocomotionSetSpec.MemberNames"/> order) — mirroring how the
/// shipped <c>CrouchWalk</c>/<c>Walk</c>/<c>Run</c> folders lead with their blend nodes.
/// </summary>
internal static KvObject BuildLocomotionFolderNode(
LocomotionSetSpec set, IReadOnlyList<AnimEntry> allEntries, string motionRootBone)
{
ArgumentNullException.ThrowIfNull(set);
ArgumentNullException.ThrowIfNull(allEntries);
var folderChildren = new KvArray();
folderChildren.Items.Add(Build2DBlendNode(set));
foreach (var member in set.MemberNames)
{
AnimEntry? entry = null;
foreach (var candidate in allEntries)
{
if (string.Equals(candidate.Name, member, StringComparison.Ordinal))
{
entry = candidate;
break;
}
}
if (entry is null)
throw new ArgumentException(
$"Locomotion set '{set.FolderName}' references unknown entry '{member}'.");
folderChildren.Items.Add(BuildAnimFileNode(entry, motionRootBone));
}
return new KvObject
{
["_class"] = new KvString("Folder"),
["name"] = new KvString(set.FolderName),
["children"] = folderChildren,
};
}
/// <summary>
/// Builds one 2DBlend KV3 node replicating the shipped citizen locomotion blends
/// (<c>CrouchWalk_Default_2D</c>, <c>Run_Default_2D</c>, …) EXACTLY: the common sequence
/// attribute set (with <c>activity_name</c> left empty — the shipped activity wiring is
/// model-specific), then <c>row_pose_param_name = "move_x"</c>,
/// <c>col_pose_param_name = "move_y"</c>, <c>row_weight_list</c>/<c>col_weight_list</c>
/// of <c>[-1.0, 0.0, 1.0]</c> and the 3×3 <c>blend_anim_list</c> grid. The pose
/// parameters are NOT declared here: the citizen base models pull them in via their
/// PoseParamList prefab (<c>citizen_poseparamlist.vmdl_prefab</c>), which Base-Model
/// children inherit — custom (non-citizen) targets must declare <c>move_x</c>/<c>move_y</c>
/// on their base model for the blend to be drivable.
/// </summary>
internal static KvObject Build2DBlendNode(LocomotionSetSpec set)
{
ArgumentNullException.ThrowIfNull(set);
if (set.BlendGrid.Length != 3 || Array.Exists(set.BlendGrid, row => row.Length != 3))
throw new ArgumentException("Locomotion blend grid must be 3x3.", nameof(set));
var node = new KvObject
{
["_class"] = new KvString("2DBlend"),
["name"] = new KvString(set.BlendName),
["activity_name"] = new KvString(""),
["activity_weight"] = new KvLong(1),
["weight_list_name"] = new KvString(""),
["fade_in_time"] = new KvDouble(0.2),
["fade_out_time"] = new KvDouble(0.2),
["looping"] = new KvBool(set.Looping),
["delta"] = new KvBool(false),
["worldSpace"] = new KvBool(false),
["hidden"] = new KvBool(false),
["anim_markup_ordered"] = new KvBool(false),
["disable_compression"] = new KvBool(false),
["disable_interpolation"] = new KvBool(false),
["enable_scale"] = new KvBool(false),
["row_pose_param_name"] = new KvString("move_x"),
["col_pose_param_name"] = new KvString("move_y"),
["row_weight_list"] = AxisWeights(),
["col_weight_list"] = AxisWeights(),
};
var grid = new KvArray();
foreach (var row in set.BlendGrid)
{
var rowArray = new KvArray();
foreach (var cell in row)
rowArray.Items.Add(new KvString(cell));
grid.Items.Add(rowArray);
}
node["blend_anim_list"] = grid;
return node;
static KvArray AxisWeights()
{
var weights = new KvArray();
weights.Items.Add(new KvDouble(-1.0));
weights.Items.Add(new KvDouble(0.0));
weights.Items.Add(new KvDouble(1.0));
return weights;
}
}
}
Game
library
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Numerics;
using TextToAnimation.Vmdl;
using TextToAnimation.Maths;
using SkeletonModel = TextToAnimation.Rig.Skeleton;
namespace TextToAnimation.Processing;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/TextToAnimation/Assembly.cs)
/// <summary>
/// Generates <c>AE_FOOTSTEP</c> <see cref="AnimEventEntry"/> lists from foot-plant detection
/// on a solved TARGET clip: each settled contact after a lift is the touchdown
/// moment, so it becomes one footstep event for that foot.
/// </summary>
/// <remarks>
/// <para><b>Shipped-data findings</b> (dev fixture
/// <c>citizen_animationlist.vmdl_prefab</c>): every one of the 28 shipped AnimEvent nodes is
/// <c>event_class = "AE_FOOTSTEP"</c> with an <c>event_keys</c> object of exactly
/// <c>Attachment = "foot_L"</c>/<c>"foot_R"</c>, <c>Foot = "0"</c> (left) / <c>"1"</c>
/// (right, a STRING) and <c>Volume = 0.7</c>. The generated events replicate that shape
/// verbatim, including the side encoding.</para>
/// <para><b>Frame-0 plants are skipped</b>: a plant interval that begins on the clip's first
/// frame means the foot was ALREADY planted when the clip starts — no touchdown happened
/// inside the clip, and on looping clips the same physical step would otherwise fire twice
/// (once at the wrap-around touchdown near the clip end, once at frame 0).</para>
/// </remarks>
public static class FootstepEvents
{
/// <summary>The Source 2 footstep anim-event class (the only event class present in the
/// shipped citizen animation data).</summary>
public const string FootstepEventClass = "AE_FOOTSTEP";
/// <summary>The constant volume used by every shipped footstep event.</summary>
public const double FootstepVolume = 0.7;
/// <summary>
/// Detects vertical settling near the ground after a foot lift and returns one
/// <c>AE_FOOTSTEP</c> per contact (frame-0 contacts skipped), merged in frame order.
/// Horizontal sliding is allowed for in-place locomotion; cleanup still uses its
/// stricter world-speed detector. Without explicit options, minimum contact duration
/// scales with sample rate (0.1 seconds).
/// </summary>
/// <param name="frames">Solved per-frame local transforms (target skeleton bone order).</param>
/// <param name="skeleton">Target skeleton the frames are expressed against.</param>
/// <param name="left">Left leg chain (target bone indices).</param>
/// <param name="right">Right leg chain (target bone indices).</param>
/// <param name="up">Target character up direction.</param>
/// <param name="fps">Clip sample rate.</param>
/// <param name="options">Plant-detection tunables; null = defaults (callers rescale the
/// cm-tuned thresholds for engine-space rigs, like the foot-plant cleanup does).</param>
public static List<AnimEventEntry> Generate(
List<XForm[]> frames,
SkeletonModel skeleton,
FootChain left,
FootChain right,
Vector3 up,
float fps,
FootPlantOptions? options = null)
{
ArgumentNullException.ThrowIfNull(frames);
ArgumentNullException.ThrowIfNull(skeleton);
ArgumentNullException.ThrowIfNull(left);
ArgumentNullException.ThrowIfNull(right);
var events = new List<AnimEventEntry>();
if (frames.Count < 2 || !float.IsFinite(fps) || fps <= 0 || !float.IsFinite(up.LengthSquared()) || up.LengthSquared() < 1e-12f)
return events;
up = Vector3.Normalize(up);
options ??= new FootPlantOptions
{
// Same minimum contact time at every sample rate (three frames at 30 Hz).
MinPlantFrames = Math.Max(2, (int)MathF.Ceiling(fps * 0.1f)),
};
var ankleL = FootPlant.AnkleWorldPositions(frames, skeleton, left.Ankle);
var ankleR = FootPlant.AnkleWorldPositions(frames, skeleton, right.Ankle);
var ground = FootPlant.EstimateGround(ankleL, ankleR, up);
AddFoot(events, Contacts(ankleL, up, ground, fps, options), attachment: "foot_L", foot: "0");
AddFoot(events, Contacts(ankleR, up, ground, fps, options), attachment: "foot_R", foot: "1");
// Stable frame ordering across both feet (List.Sort is unstable; the comparer breaks
// frame ties on the side key so the result is deterministic).
events.Sort((a, b) => a.Frame != b.Frame
? a.Frame.CompareTo(b.Frame)
: string.CompareOrdinal(a.Foot, b.Foot));
return events;
}
// A planted foot slides horizontally in an in-place clip. Detect vertical settling
// near the floor instead, but require a real lift before another touchdown. This
// also prevents horizontal speed jitter from generating repeated step sounds.
private static List<FrameRange> Contacts(Vector3[] ankle, Vector3 up, float ground, float fps, FootPlantOptions options)
{
var heights = new Vector3[ankle.Length];
for (var i = 0; i < ankle.Length; i++) heights[i] = up * Vector3.Dot(ankle[i], up);
var candidates = FootPlant.DetectPlants(heights, up, ground, fps, options);
var contacts = new List<FrameRange>();
var previousEnd = 0;
foreach (var candidate in candidates)
{
var lifted = false;
for (var i = previousEnd; i < candidate.Start; i++)
lifted |= Vector3.Dot(ankle[i], up) - ground >= options.HeightThresholdCm * 1.5f;
if (lifted) contacts.Add(candidate);
previousEnd = candidate.End;
}
return contacts;
}
private static void AddFoot(
List<AnimEventEntry> events, List<FrameRange> plants, string attachment, string foot)
{
foreach (var plant in plants)
{
if (plant.Start == 0)
continue; // already planted at clip start: no touchdown inside the clip
events.Add(new AnimEventEntry
{
EventClass = FootstepEventClass,
Frame = plant.Start,
Attachment = attachment,
Foot = foot,
Volume = FootstepVolume,
});
}
}
}
Game
library
#nullable enable annotations using System; using System.Collections.Generic; using System.Numerics; using TextToAnimation.Mapping; using TextToAnimation.Maths; using TextToAnimation.Rig; namespace TextToAnimation.Processing; using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/TextToAnimation/Assembly.cs) /// <summary> /// Bakes real animation channels onto the <see cref="BoneClass.IkBaked"/> helper bones from /// the already-solved <see cref="BoneClass.Animated"/> body bones. The s&box default /// animgraph reads these bones, so every retargeted clip must carry data for them. /// </summary> /// <remarks> /// <para> /// Every rule below was DERIVED from shipped Facepunch clips /// (<c>Citizen@Run_N.fbx</c>, <c>[email protected]</c>, plus the <c>*_AimMatrix</c> clips for /// the aim bones) by measuring per-frame relationships between the IK bones and the solved /// body bones, and is verified by reproducing those clips' IK channels in /// <c>IkBoneBakerTests</c>. Measured residuals on both probe clips (positions cm, FBX scene /// space of the citizen sources, Y-up): /// </para> /// <list type="bullet"> /// <item><c>foot_L/R_IK_target</c>: world transform equals the world transform of /// <c>ankle_L/R</c> exactly (max residual 1e-4 cm / 0.000°).</item> /// <item><c>hand_L/R_IK_target</c>: world transform equals <c>hand_L/R</c> exactly /// (1e-4 cm / 0.000°).</item> /// <item><c>hand_L_to_R_ikrule</c> (parented under <c>hand_R</c>): world transform equals /// <c>hand_L</c> exactly — the bone named <c>hand_X_to_Y_ikrule</c> carries hand X's world /// transform expressed in hand Y's space (1e-4 cm / 0.000°). Symmetrically for /// <c>hand_R_to_L_ikrule</c>.</item> /// <item><c>root_IK</c>: world position equals the pelvis world position projected onto the /// ground plane — the lateral components match the pelvis exactly (0.0000 cm) and the up /// component stays at root_IK's rest height (0). World rotation never animates: it stays at /// root_IK's rest world rotation (0.000° deviation even while the pelvis yaws), which is the /// up-axis frame change quaternion, i.e. identity in engine space. The up axis is derived /// from rest geometry (the dominant component of pelvisRest − rootIkRest), not hardcoded.</item> /// <item><c>hold_L/R</c>: local transform stays at rest (0.0000 cm / 0.000°).</item> /// <item><c>hand_L/R_IK_attach</c>: absent from every probed shipped clip skeleton (the clip /// FBXs parent the hand IK targets directly under <c>root_IK</c>); in the human_male rig its /// rest local under <c>root_IK</c> is identity. Keeping rest reproduces the shipped layout, /// and the hand targets still land on the hands because their locals are solved through the /// attach's world.</item> /// <item><c>aim_matrix_*</c>: absent from all regular shipped clips. Even in the dedicated /// <c>*_AimMatrix</c> clips, <c>aim_matrix_02/03</c> sit exactly at rest local /// (0.0000 cm / 0.000°) and <c>aim_matrix_01</c> holds an animator-authored constant pose /// (not derivable from the body) — these bones are animgraph aim-space references, so /// retargeted clips keep them at rest.</item> /// </list> /// <para> /// Any <see cref="BoneClass.IkBaked"/> bone without a recognized name also keeps its rest /// local (the conservative choice — identical to omitting the channel). /// </para> /// </remarks> public static class IkBoneBaker { /// <summary> /// Overwrites the local transforms of every <see cref="BoneClass.IkBaked"/> bone in every /// frame, deriving them from the already-solved body bones of the same frame. All other /// bones' locals are never touched. Deterministic; mutates <paramref name="frames"/> in /// place. /// </summary> /// <param name="frames">Per-frame local transforms, one <see cref="XForm"/> per bone in /// <paramref name="target"/> skeleton order (e.g. <c>Clip.Frames</c>).</param> /// <param name="target">The target rig supplying skeleton, bone classes and roles.</param> /// <exception cref="ArgumentException">Thrown when a frame's length does not match the /// target skeleton.</exception> public static void Bake(List<XForm[]> frames, TargetRig target) { ArgumentNullException.ThrowIfNull(frames); ArgumentNullException.ThrowIfNull(target); var skeleton = target.Skeleton; var count = skeleton.Count; for (var f = 0; f < frames.Count; f++) { if (frames[f].Length != count) throw new ArgumentException( $"Frame {f} has {frames[f].Length} bones but the target skeleton has {count}.", nameof(frames)); } var rules = BuildRules(target); var world = new XForm[count]; var computed = new bool[count]; foreach (var locals in frames) { Array.Clear(computed); // Pass 1: world transforms of every bone with no IkBaked ancestor (the body). // A bone is skipped exactly when its parent was skipped or is itself IkBaked, // so the skip set is precisely the IK bones and their descendants. for (var i = 0; i < count; i++) { if (target.ClassOf(i) == BoneClass.IkBaked) continue; var parent = skeleton[i].ParentIndex; if (parent >= 0 && !computed[parent]) continue; world[i] = parent < 0 ? locals[i] : XForm.Compose(world[parent], locals[i]); computed[i] = true; } // Pass 2: bake IK bones (and propagate worlds through their subtrees). Bones are // topologically sorted, so a parent's world is always available by the time its // child is processed. for (var i = 0; i < count; i++) { if (computed[i]) continue; var parent = skeleton[i].ParentIndex; if (target.ClassOf(i) == BoneClass.IkBaked) { if (rules[i].Kind == RuleKind.KeepRestLocal) { locals[i] = skeleton[i].RestLocal; world[i] = parent < 0 ? locals[i] : XForm.Compose(world[parent], locals[i]); } else { var desired = Desired(in rules[i], world, computed, skeleton[i].Name); locals[i] = parent < 0 ? desired : XForm.ToLocal(world[parent], desired); world[i] = desired; } } else { // Non-IK descendant of an IK bone: local untouched, world propagated. world[i] = parent < 0 ? locals[i] : XForm.Compose(world[parent], locals[i]); } computed[i] = true; } } } /// <summary> /// Pins the KeepRestLocal-classified IK helper bones (aim_matrix_*, hold_*, /// *_IK_attach and unrecognized IkBaked names) to their REST locals in every frame. /// Used on MIRRORED clips (southpaw G8 mirror fix): these bones are aim-space /// reference CONSTANTS the model's aim/eye constraint setup was authored against /// (measured at rest in every shipped citizen clip); conjugating them like body /// geometry feeds the aim chain an alien reference frame and the head/eye region /// collapses. The body-derived helpers (root_IK, IK targets, ikrule) are NOT /// touched: their mirrored channels stay the exact conjugates of the primary's. /// </summary> public static void PinRestLocalReferenceBones(List<XForm[]> frames, TargetRig target) { ArgumentNullException.ThrowIfNull(frames); ArgumentNullException.ThrowIfNull(target); var skeleton = target.Skeleton; var rules = BuildRules(target); foreach (var locals in frames) { for (var i = 0; i < locals.Length && i < skeleton.Count; i++) { if (target.ClassOf(i) == BoneClass.IkBaked && rules[i].Kind == RuleKind.KeepRestLocal) locals[i] = skeleton[i].RestLocal; } } } // ------------------------------------------------------------------ rules private enum RuleKind { /// <summary>Keep the rest local transform (hold_*, aim_matrix_*, *_IK_attach, unknown).</summary> KeepRestLocal, /// <summary>World transform copies a body bone's world transform (IK targets, ikrule).</summary> FollowBody, /// <summary>root_IK: pelvis world position projected to the ground plane, fixed rest rotation.</summary> GroundProjectedHips, } private readonly struct Rule { public RuleKind Kind { get; init; } public int BodyBone { get; init; } public int UpAxis { get; init; } public float GroundLevel { get; init; } public Quaternion FixedWorldRot { get; init; } } private static Rule[] BuildRules(TargetRig target) { var skeleton = target.Skeleton; var rules = new Rule[skeleton.Count]; foreach (var i in target.BonesOfClass(BoneClass.IkBaked)) { rules[i] = skeleton[i].Name switch { "root_IK" => RootIkRule(target, i), "foot_L_IK_target" => FollowRule(target, BoneRole.FootL), "foot_R_IK_target" => FollowRule(target, BoneRole.FootR), "hand_L_IK_target" => FollowRule(target, BoneRole.HandL), "hand_R_IK_target" => FollowRule(target, BoneRole.HandR), "hand_L_to_R_ikrule" => FollowRule(target, BoneRole.HandL), "hand_R_to_L_ikrule" => FollowRule(target, BoneRole.HandR), _ => default, // KeepRestLocal }; } return rules; } private static Rule FollowRule(TargetRig target, BoneRole role) => target.BoneForRole(role) is { } body ? new Rule { Kind = RuleKind.FollowBody, BodyBone = body } : default; // body bone missing from the rig: keep rest private static Rule RootIkRule(TargetRig target, int rootIk) { if (target.BoneForRole(BoneRole.Hips) is not { } pelvis) return default; var skeleton = target.Skeleton; // Up axis = dominant component of the rest offset pelvis − root_IK. In every probed // rig they share the lateral components exactly and differ only by the hip height. var d = Vector3.Abs(skeleton.RestWorld[pelvis].Pos - skeleton.RestWorld[rootIk].Pos); var up = d.X >= d.Y ? (d.X >= d.Z ? 0 : 2) : (d.Y >= d.Z ? 1 : 2); return new Rule { Kind = RuleKind.GroundProjectedHips, BodyBone = pelvis, UpAxis = up, GroundLevel = Component(skeleton.RestWorld[rootIk].Pos, up), FixedWorldRot = skeleton.RestWorld[rootIk].Rot, }; } private static XForm Desired(in Rule rule, XForm[] world, bool[] computed, string name) { if (!computed[rule.BodyBone]) throw new InvalidOperationException( $"IK bone '{name}' follows a body bone that is itself under an IK bone — unsupported hierarchy."); return rule.Kind == RuleKind.FollowBody ? world[rule.BodyBone] : new XForm( WithComponent(world[rule.BodyBone].Pos, rule.UpAxis, rule.GroundLevel), rule.FixedWorldRot); } private static float Component(Vector3 v, int axis) => axis switch { 0 => v.X, 1 => v.Y, _ => v.Z }; private static Vector3 WithComponent(Vector3 v, int axis, float value) { switch (axis) { case 0: v.X = value; break; case 1: v.Y = value; break; default: v.Z = value; break; } return v; } }
Game
library
#nullable enable annotations
using System;
using TextToAnimation.Maths;
namespace TextToAnimation.Rig;
/// <summary>
/// A single pose of a skeleton: one parent-relative local transform per bone,
/// indexed exactly like the skeleton's bone list.
/// </summary>
public sealed class Pose
{
/// <summary>Parent-relative local transforms, one per bone (skeleton bone order).</summary>
public XForm[] Locals { get; }
/// <summary>Wraps an existing local-transform array (not copied).</summary>
public Pose(XForm[] locals)
{
Locals = locals ?? throw new ArgumentNullException(nameof(locals));
}
/// <summary>Creates a pose initialized to the skeleton's rest local transforms.</summary>
public static Pose Rest(Skeleton skeleton)
{
ArgumentNullException.ThrowIfNull(skeleton);
var locals = new XForm[skeleton.Count];
for (var i = 0; i < locals.Length; i++)
locals[i] = skeleton[i].RestLocal;
return new Pose(locals);
}
/// <summary>
/// Forward kinematics: composes local transforms down the hierarchy and returns
/// one world transform per bone.
/// </summary>
/// <exception cref="ArgumentException">Thrown when the pose length does not match the skeleton.</exception>
public XForm[] ToWorld(Skeleton skeleton)
{
ArgumentNullException.ThrowIfNull(skeleton);
if (Locals.Length != skeleton.Count)
throw new ArgumentException(
$"Pose has {Locals.Length} bones but skeleton has {skeleton.Count}.", nameof(skeleton));
var world = new XForm[Locals.Length];
for (var i = 0; i < Locals.Length; i++)
{
var parent = skeleton[i].ParentIndex;
world[i] = parent < 0 ? Locals[i] : XForm.Compose(world[parent], Locals[i]);
}
return world;
}
}
Debug: View Raw JSON Response
{
"TotalCount": 188,
"Files": [
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Editor/TextToAnimation/Formats/Fbx/FbxBinaryWriter.cs",
"FileName": "FbxBinaryWriter.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "// Vendored from humanoid-retargeter (Code/HumanoidRetargeter/Formats/Fbx/FbxBinaryWriter.cs).\r\n#nullable enable annotations\r\n\r\nusing System;\r\nusing System.Buffers.Binary;\r\nusing System.IO;\r\nusing System.Text;\r\n\r\nnamespace TextToAnimation.Editor.Formats.Fbx;\r\n\r\n/// <summary>\r\n/// Serializes an <see cref=\"FbxNode\"/> tree back to binary FBX (version 7400 layout \u2014\r\n/// u32 header fields, universally readable). The inverse of\r\n/// <see cref=\"FbxTokenizer.Parse\"/>: a tree parsed from a 7.x binary file and written\r\n/// here re-parses to an identical tree (arrays are written uncompressed; zlib-encoded\r\n/// inputs therefore round-trip by VALUE, not byte-for-byte).\r\n/// </summary>\r\n/// <remarks>\r\n/// Used by <see cref=\"FbxBindPoseFixer\"/> to persist repaired node transforms. The footer\r\n/// is written the way Blender's exporter does: a fixed 16-byte watermark (importers treat\r\n/// it as opaque), zero padding to a 16-byte boundary, the version echo, 120 zero bytes and\r\n/// the closing magic. The FBX SDK computes a content hash here, but every consumer we\r\n/// target (s&box, Blender, assimp) ignores it.\r\n/// </remarks>\r\npublic static class FbxBinaryWriter\r\n{\r\n private const uint Version = 7400;\r\n\r\n private static readonly byte[] HeaderMagic =\r\n \"Kaydara FBX Binary \\0\\x1a\\0\"u8.ToArray();\r\n\r\n // Blender's fbx_binary.py FOOT_ID + closing magic bytes.\r\n private static readonly byte[] FooterWatermark =\r\n {\r\n 0xfa, 0xbc, 0xab, 0x09, 0xd0, 0xc8, 0xd4, 0x66, 0xb1, 0x76, 0xfb, 0x83, 0x1c, 0xf7, 0x26, 0x7e,\r\n };\r\n\r\n private static readonly byte[] FooterMagic =\r\n {\r\n 0xf8, 0x5a, 0x8c, 0x6a, 0xde, 0xf5, 0xd9, 0x7e, 0xec, 0xe9, 0x0c, 0x6e, 0x0c, 0xc0, 0x00, 0x00,\r\n };\r\n\r\n /// <summary>\r\n /// Serializes <paramref name=\"root\"/> (a virtual root whose children are the top-level\r\n /// document nodes, as produced by <see cref=\"FbxTokenizer.Parse\"/>).\r\n /// </summary>\r\n public static byte[] Write(FbxNode root)\r\n {\r\n ArgumentNullException.ThrowIfNull(root);\r\n\r\n using var ms = new MemoryStream();\r\n ms.Write(HeaderMagic);\r\n WriteU32(ms, Version);\r\n\r\n foreach (var child in root.Children)\r\n WriteNode(ms, child);\r\n WriteNullRecord(ms);\r\n\r\n WriteFooter(ms);\r\n return ms.ToArray();\r\n }\r\n\r\n // ------------------------------------------------------------------ nodes\r\n\r\n private static void WriteNode(MemoryStream ms, FbxNode node)\r\n {\r\n long headerAt = ms.Position;\r\n // Placeholder header: endOffset, numProps, propListLen (patched after the body).\r\n WriteU32(ms, 0);\r\n WriteU32(ms, (uint)node.Properties.Count);\r\n WriteU32(ms, 0);\r\n var nameBytes = Encoding.ASCII.GetBytes(node.Name);\r\n if (nameBytes.Length > byte.MaxValue)\r\n throw new FormatException($\"FBX write: node name too long ({node.Name.Length} chars).\");\r\n ms.WriteByte((byte)nameBytes.Length);\r\n ms.Write(nameBytes);\r\n\r\n long propsAt = ms.Position;\r\n foreach (var p in node.Properties)\r\n WriteProperty(ms, p, node.Name);\r\n long propsLen = ms.Position - propsAt;\r\n\r\n if (node.Children.Count > 0)\r\n {\r\n foreach (var child in node.Children)\r\n WriteNode(ms, child);\r\n WriteNullRecord(ms);\r\n }\r\n\r\n long endAt = ms.Position;\r\n ms.Position = headerAt;\r\n WriteU32(ms, checked((uint)endAt));\r\n WriteU32(ms, (uint)node.Properties.Count);\r\n WriteU32(ms, checked((uint)propsLen));\r\n ms.Position = endAt;\r\n }\r\n\r\n private static void WriteNullRecord(MemoryStream ms)\r\n {\r\n Span<byte> zeros = stackalloc byte[13];\r\n zeros.Clear();\r\n ms.Write(zeros);\r\n }\r\n\r\n // ------------------------------------------------------------------ properties\r\n\r\n private static void WriteProperty(MemoryStream ms, object value, string owner)\r\n {\r\n switch (value)\r\n {\r\n case short y:\r\n ms.WriteByte((byte)'Y');\r\n WriteI16(ms, y);\r\n break;\r\n case bool c:\r\n ms.WriteByte((byte)'C');\r\n ms.WriteByte(c ? (byte)1 : (byte)0);\r\n break;\r\n case int i:\r\n ms.WriteByte((byte)'I');\r\n WriteI32(ms, i);\r\n break;\r\n case float f:\r\n ms.WriteByte((byte)'F');\r\n WriteF32(ms, f);\r\n break;\r\n case double d:\r\n ms.WriteByte((byte)'D');\r\n WriteF64(ms, d);\r\n break;\r\n case long l:\r\n ms.WriteByte((byte)'L');\r\n WriteI64(ms, l);\r\n break;\r\n\r\n case float[] fa:\r\n WriteArrayHeader(ms, 'f', fa.Length, 4);\r\n foreach (var x in fa)\r\n WriteF32(ms, x);\r\n break;\r\n case double[] da:\r\n WriteArrayHeader(ms, 'd', da.Length, 8);\r\n foreach (var x in da)\r\n WriteF64(ms, x);\r\n break;\r\n case long[] la:\r\n WriteArrayHeader(ms, 'l', la.Length, 8);\r\n foreach (var x in la)\r\n WriteI64(ms, x);\r\n break;\r\n case int[] ia:\r\n WriteArrayHeader(ms, 'i', ia.Length, 4);\r\n foreach (var x in ia)\r\n WriteI32(ms, x);\r\n break;\r\n case bool[] ba:\r\n WriteArrayHeader(ms, 'b', ba.Length, 1);\r\n foreach (var x in ba)\r\n ms.WriteByte(x ? (byte)1 : (byte)0);\r\n break;\r\n\r\n case string s:\r\n {\r\n ms.WriteByte((byte)'S');\r\n var bytes = Encoding.UTF8.GetBytes(s);\r\n WriteU32(ms, (uint)bytes.Length);\r\n ms.Write(bytes);\r\n break;\r\n }\r\n case byte[] r:\r\n ms.WriteByte((byte)'R');\r\n WriteU32(ms, (uint)r.Length);\r\n ms.Write(r);\r\n break;\r\n\r\n default:\r\n throw new FormatException(\r\n $\"FBX write: node '{owner}': unsupported property CLR type {value?.GetType().Name ?? \"null\"}.\");\r\n }\r\n }\r\n\r\n private static void WriteArrayHeader(MemoryStream ms, char code, int count, int elemSize)\r\n {\r\n ms.WriteByte((byte)code);\r\n WriteU32(ms, (uint)count);\r\n WriteU32(ms, 0); // encoding 0 = uncompressed\r\n WriteU32(ms, checked((uint)(count * elemSize)));\r\n }\r\n\r\n // ------------------------------------------------------------------ footer\r\n\r\n private static void WriteFooter(MemoryStream ms)\r\n {\r\n ms.Write(FooterWatermark);\r\n\r\n // Zero-pad so the version echo starts 16-aligned (Blender pads at least 1 byte).\r\n int pad = (int)(16 - ms.Position % 16);\r\n for (int i = 0; i < pad; i++)\r\n ms.WriteByte(0);\r\n\r\n WriteU32(ms, Version);\r\n Span<byte> zeros = stackalloc byte[120];\r\n zeros.Clear();\r\n ms.Write(zeros);\r\n ms.Write(FooterMagic);\r\n }\r\n\r\n // ------------------------------------------------------------------ primitives\r\n\r\n private static void WriteU32(MemoryStream ms, uint v)\r\n {\r\n Span<byte> b = stackalloc byte[4];\r\n BinaryPrimitives.WriteUInt32LittleEndian(b, v);\r\n ms.Write(b);\r\n }\r\n\r\n private static void WriteI16(MemoryStream ms, short v)\r\n {\r\n Span<byte> b = stackalloc byte[2];\r\n BinaryPrimitives.WriteInt16LittleEndian(b, v);\r\n ms.Write(b);\r\n }\r\n\r\n private static void WriteI32(MemoryStream ms, int v)\r\n {\r\n Span<byte> b = stackalloc byte[4];\r\n BinaryPrimitives.WriteInt32LittleEndian(b, v);\r\n ms.Write(b);\r\n }\r\n\r\n private static void WriteI64(MemoryStream ms, long v)\r\n {\r\n Span<byte> b = stackalloc byte[8];\r\n BinaryPrimitives.WriteInt64LittleEndian(b, v);\r\n ms.Write(b);\r\n }\r\n\r\n private static void WriteF32(MemoryStream ms, float v)\r\n {\r\n Span<byte> b = stackalloc byte[4];\r\n BinaryPrimitives.WriteSingleLittleEndian(b, v);\r\n ms.Write(b);\r\n }\r\n\r\n private static void WriteF64(MemoryStream ms, double v)\r\n {\r\n Span<byte> b = stackalloc byte[8];\r\n BinaryPrimitives.WriteDoubleLittleEndian(b, v);\r\n ms.Write(b);\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Editor/TextToAnimation/Formats/Fbx/FbxMaterialColor.cs",
"FileName": "FbxMaterialColor.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "// Vendored from humanoid-retargeter (Code/HumanoidRetargeter/Formats/Fbx) - its FBX reader, used for material/texture detection.\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n#nullable enable annotations\r\n\r\nusing System;\r\nusing System.Linq;\r\n\r\nnamespace TextToAnimation.Editor.Formats.Fbx;\r\n\r\nusing Vector3 = System.Numerics.Vector3;\r\n\r\n/// <summary>Authored FBX colors, independent of material and texture names.</summary>\r\npublic static class FbxMaterialColor\r\n{\r\n public static Vector3? Read(FbxNode material)\r\n {\r\n var properties = material.Child(\"Properties70\")?.Children;\r\n var color = properties?.FirstOrDefault(p => p.Properties.FirstOrDefault() is \"DiffuseColor\")\r\n ?? properties?.FirstOrDefault(p => p.Properties.FirstOrDefault() is \"Diffuse\");\r\n if (color is null || color.Properties.Count < 7)\r\n return null;\r\n var value = new Vector3(color.Prop<float>(4), color.Prop<float>(5), color.Prop<float>(6));\r\n // The legacy Diffuse property already includes DiffuseFactor.\r\n var factor = properties?.FirstOrDefault(p => p.Properties.FirstOrDefault() is \"DiffuseFactor\");\r\n if (color.Properties[0] is \"DiffuseColor\" && factor?.Properties.Count >= 5)\r\n value *= factor.Prop<float>(4);\r\n return float.IsFinite(value.X) && float.IsFinite(value.Y) && float.IsFinite(value.Z)\r\n ? Vector3.Clamp(value, Vector3.Zero, Vector3.One) : null;\r\n }\r\n\r\n public static bool HasVertexColors(FbxNode geometry)\r\n => geometry.ChildrenNamed(\"LayerElementColor\").Any(layer =>\r\n layer.Child(\"Colors\")?.Properties.FirstOrDefault() is double[] colors\r\n && colors.Where((_, i) => i % 4 != 3).Any(c => Math.Abs(c - 1) > 1e-6));\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Editor/TextToAnimation/Formats/Fbx/FbxNode.cs",
"FileName": "FbxNode.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "// Vendored from humanoid-retargeter (Code/HumanoidRetargeter/Formats/Fbx) - its FBX reader, used for material/texture detection.\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n#nullable enable annotations\r\n\r\nnamespace TextToAnimation.Editor.Formats.Fbx;\r\n\r\n/// <summary>\r\n/// A single node of an FBX document tree (binary or ASCII): a name, a flat list of\r\n/// typed properties, and nested child nodes.\r\n///\r\n/// Property values are stored as the closest CLR type to what the file contained:\r\n/// <list type=\"bullet\">\r\n/// <item><c>short</c> ('Y'), <c>bool</c> ('C'), <c>int</c> ('I'), <c>float</c> ('F'),\r\n/// <c>double</c> ('D'), <c>long</c> ('L')</item>\r\n/// <item><c>float[]</c> ('f'), <c>double[]</c> ('d'), <c>long[]</c> ('l'),\r\n/// <c>int[]</c> ('i'), <c>bool[]</c>-as-<c>byte[]</c> ('b')</item>\r\n/// <item><c>string</c> ('S' \u2014 kept raw, may contain the <c>\\x00\\x01</c> name/class\r\n/// separator; see <see cref=\"SplitName\"/>), <c>byte[]</c> ('R')</item>\r\n/// </list>\r\n/// ASCII files store numbers only as <c>long</c> / <c>double</c> (and arrays as\r\n/// <c>long[]</c> / <c>double[]</c>), so the typed accessors below convert tolerantly.\r\n/// </summary>\r\npublic sealed class FbxNode\r\n{\r\n public string Name { get; }\r\n public List<object> Properties { get; } = new();\r\n public List<FbxNode> Children { get; } = new();\r\n\r\n public FbxNode(string name) => Name = name;\r\n\r\n /// <summary>First child with the given name, or null.</summary>\r\n public FbxNode? Child(string name)\r\n {\r\n foreach (var c in Children)\r\n if (c.Name == name)\r\n return c;\r\n return null;\r\n }\r\n\r\n /// <summary>All children with the given name, in document order.</summary>\r\n public IEnumerable<FbxNode> ChildrenNamed(string name)\r\n {\r\n foreach (var c in Children)\r\n if (c.Name == name)\r\n yield return c;\r\n }\r\n\r\n /// <summary>\r\n /// Property <paramref name=\"i\"/> converted to <typeparamref name=\"T\"/>.\r\n /// Numeric scalars convert tolerantly across widths (e.g. an 'I' i32 read as long);\r\n /// anything else must match the stored type exactly.\r\n /// </summary>\r\n public T Prop<T>(int i)\r\n {\r\n object v = RawProp(i);\r\n if (v is T t)\r\n return t;\r\n\r\n var target = typeof(T);\r\n // s&box whitelist: Type.IsPrimitive is banned; enumerate the convertible targets.\r\n if (v is IConvertible && (ConvertTargets.Contains(target) || target == typeof(string)))\r\n {\r\n try\r\n {\r\n return (T)Convert.ChangeType(v, target, System.Globalization.CultureInfo.InvariantCulture);\r\n }\r\n // ArithmeticException covers OverflowException, which is not s&box-whitelisted\r\n catch (Exception ex) when (ex is InvalidCastException or ArithmeticException or FormatException)\r\n {\r\n throw new FormatException(\r\n $\"FBX node '{Name}': property {i} is {v.GetType().Name}, not convertible to {target.Name}.\", ex);\r\n }\r\n }\r\n\r\n throw new FormatException(\r\n $\"FBX node '{Name}': property {i} is {v.GetType().Name}, expected {target.Name}.\");\r\n }\r\n\r\n /// <summary>Property <paramref name=\"i\"/> as a double array (converts f/l/i/b arrays).</summary>\r\n public double[] AsDoubleArray(int i) => RawProp(i) switch\r\n {\r\n double[] d => d,\r\n float[] f => Array.ConvertAll(f, x => (double)x),\r\n long[] l => Array.ConvertAll(l, x => (double)x),\r\n int[] n => Array.ConvertAll(n, x => (double)x),\r\n byte[] b => Array.ConvertAll(b, x => (double)x),\r\n bool[] o => Array.ConvertAll(o, x => x ? 1.0 : 0.0),\r\n var v => throw TypeError(i, v, \"double[]\"),\r\n };\r\n\r\n /// <summary>Property <paramref name=\"i\"/> as a float array (converts d/l/i/b arrays).</summary>\r\n public float[] AsFloatArray(int i) => RawProp(i) switch\r\n {\r\n float[] f => f,\r\n double[] d => Array.ConvertAll(d, x => (float)x),\r\n long[] l => Array.ConvertAll(l, x => (float)x),\r\n int[] n => Array.ConvertAll(n, x => (float)x),\r\n byte[] b => Array.ConvertAll(b, x => (float)x),\r\n bool[] o => Array.ConvertAll(o, x => x ? 1f : 0f),\r\n var v => throw TypeError(i, v, \"float[]\"),\r\n };\r\n\r\n /// <summary>Property <paramref name=\"i\"/> as a long array (converts i/b; d/f if integral).</summary>\r\n public long[] AsLongArray(int i) => RawProp(i) switch\r\n {\r\n long[] l => l,\r\n int[] n => Array.ConvertAll(n, x => (long)x),\r\n byte[] b => Array.ConvertAll(b, x => (long)x),\r\n bool[] o => Array.ConvertAll(o, x => x ? 1L : 0L),\r\n double[] d => Array.ConvertAll(d, x => checked((long)x)),\r\n float[] f => Array.ConvertAll(f, x => checked((long)x)),\r\n var v => throw TypeError(i, v, \"long[]\"),\r\n };\r\n\r\n /// <summary>Property <paramref name=\"i\"/> as an int array (converts b; l/d/f narrowing-checked).</summary>\r\n public int[] AsIntArray(int i) => RawProp(i) switch\r\n {\r\n int[] n => n,\r\n long[] l => Array.ConvertAll(l, x => checked((int)x)),\r\n byte[] b => Array.ConvertAll(b, x => (int)x),\r\n bool[] o => Array.ConvertAll(o, x => x ? 1 : 0),\r\n double[] d => Array.ConvertAll(d, x => checked((int)x)),\r\n float[] f => Array.ConvertAll(f, x => checked((int)x)),\r\n var v => throw TypeError(i, v, \"int[]\"),\r\n };\r\n\r\n /// <summary>Property <paramref name=\"i\"/> as raw bytes ('R' blobs or 'b' bool arrays).</summary>\r\n public byte[] AsByteArray(int i) => RawProp(i) switch\r\n {\r\n byte[] b => b,\r\n var v => throw TypeError(i, v, \"byte[]\"),\r\n };\r\n\r\n /// <summary>Property <paramref name=\"i\"/> as a string (raw 'S' content, separators intact).</summary>\r\n public string AsString(int i) => RawProp(i) switch\r\n {\r\n string s => s,\r\n var v => throw TypeError(i, v, \"string\"),\r\n };\r\n\r\n /// <summary>\r\n /// Splits an FBX object name into (name, class).\r\n /// Binary files store <c>\"Name\\x00\\x01Class\"</c> (e.g. <c>\"mixamorig:Hips\\x00\\x01Model\"</c>);\r\n /// ASCII files store <c>\"Class::Name\"</c> (e.g. <c>\"Model::pelvis\"</c>).\r\n /// A plain string with neither separator yields (name, \"\").\r\n /// </summary>\r\n public static (string Name, string Class) SplitName(string raw)\r\n {\r\n int bin = raw.IndexOf(\"\\0\\x01\", StringComparison.Ordinal);\r\n if (bin >= 0)\r\n return (raw[..bin], raw[(bin + 2)..]);\r\n\r\n int ascii = raw.IndexOf(\"::\", StringComparison.Ordinal);\r\n if (ascii >= 0)\r\n return (raw[(ascii + 2)..], raw[..ascii]);\r\n\r\n return (raw, \"\");\r\n }\r\n\r\n /// <summary>Primitive scalar types <see cref=\"Prop{T}\"/> converts to (whitelist-safe IsPrimitive substitute).</summary>\r\n private static readonly HashSet<Type> ConvertTargets = new()\r\n {\r\n typeof(bool), typeof(byte), typeof(sbyte), typeof(short), typeof(ushort),\r\n typeof(int), typeof(uint), typeof(long), typeof(ulong),\r\n typeof(float), typeof(double), typeof(char),\r\n };\r\n\r\n private object RawProp(int i)\r\n {\r\n if (i < 0 || i >= Properties.Count)\r\n throw new FormatException(\r\n $\"FBX node '{Name}': property index {i} out of range (has {Properties.Count}).\");\r\n return Properties[i];\r\n }\r\n\r\n private FormatException TypeError(int i, object v, string wanted) =>\r\n new($\"FBX node '{Name}': property {i} is {v.GetType().Name}, expected {wanted}.\");\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Editor/TextToAnimation/Inference/Runtime/TorchCheckpoint.cs",
"FileName": "TorchCheckpoint.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "#nullable enable\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.IO.Compression;\r\nusing System.Linq;\r\nusing System.Text;\r\nusing System.Threading;\r\n\r\nnamespace TextToAnimation.Editor.Inference.Runtime;\r\n\r\n/// <summary>Reads tensor data from the pinned PyTorch ZIP checkpoint format without Python,\r\n/// importing modules, invoking pickle callables, or deserializing executable objects.</summary>\r\npublic sealed partial class TorchCheckpoint : IDisposable\r\n{\r\n public sealed record TensorInfo(string Name,string Storage,string Dtype,long StorageLength,long Offset,int[] Shape,long[] Stride);\r\n internal sealed record Symbol(string Module,string Name);\r\n internal sealed record StorageRef(string Key,string Dtype,long Count);\r\n internal sealed record TensorRef(StorageRef Storage,long Offset,int[] Shape,long[] Stride);\r\n readonly ZipArchive archive;\r\n readonly string prefix;\r\n readonly Dictionary<string,TensorInfo> tensors=new(StringComparer.Ordinal);\r\n public IReadOnlyDictionary<string,TensorInfo> Tensors=>tensors;\r\n const int MaximumMetadataBytes=32*1024*1024;\r\n const long MaximumTensorElements=1_000_000_000;\r\n\r\n public TorchCheckpoint(string path,string? stateKey=null)\r\n {\r\n archive=ZipFile.OpenRead(path);\r\n try\r\n {\r\n var entries=archive.Entries.Where(e=>e.FullName.EndsWith(\"/data.pkl\",StringComparison.Ordinal)).ToArray();\r\n if(entries.Length!=1)throw new InvalidDataException(\"Expected exactly one PyTorch data.pkl entry.\");\r\n var entry=entries[0];prefix=entry.FullName.Substring(0,entry.FullName.Length-8);\r\n if(entry.Length>MaximumMetadataBytes)throw new InvalidDataException(\"Checkpoint metadata exceeds the 32 MiB limit.\");\r\n if(archive.GetEntry(prefix+\"byteorder\") is { } byteorder)\r\n {using var r=new StreamReader(byteorder.Open());if(r.ReadToEnd().Trim()!=\"little\")throw new NotSupportedException(\"Only little-endian checkpoints are supported.\");}\r\n using var source=entry.Open();using var memory=new MemoryStream();source.CopyTo(memory);\r\n var root=new DataReader(memory.ToArray()).Read() as Dictionary<object,object?> ?? throw new InvalidDataException(\"Checkpoint root must be a dictionary.\");\r\n // Some inference checkpoints (including ACE) keep separately named\r\n // component dictionaries. Select one explicitly; never traverse optimizer state.\r\n var key=stateKey??(root.ContainsKey(\"state_dict\")?\"state_dict\":null);\r\n var state=key is null?root:root.TryGetValue(key,out var value)?value as Dictionary<object,object?>:null;\r\n if(state is null)throw new InvalidDataException($\"Checkpoint component '{key}' is missing or is not a dictionary.\");\r\n foreach(var pair in state)\r\n {\r\n if(pair.Value is not TensorRef t)continue;\r\n if(pair.Key is not string name)throw new InvalidDataException(\"Tensor names must be strings.\");\r\n var info=new TensorInfo(name,t.Storage.Key,t.Storage.Dtype,t.Storage.Count,t.Offset,t.Shape,t.Stride);\r\n Validate(info);tensors.Add(name,info);\r\n }\r\n if(tensors.Count==0)throw new InvalidDataException(\"No tensor weights in checkpoint.\");\r\n }\r\n catch{archive.Dispose();throw;}\r\n }\r\n static long Count(TensorInfo tensor)\r\n {long count=1;foreach(var d in tensor.Shape){if(d<0)throw new InvalidDataException(\"Negative tensor dimension.\");count=checked(count*d);if(count>MaximumTensorElements)throw new InvalidDataException(\"Tensor exceeds element limit.\");}return count;}\r\n void Validate(TensorInfo tensor)\r\n {\r\n if(tensor.Shape.Length>8||tensor.Shape.Length!=tensor.Stride.Length||tensor.Offset<0||tensor.StorageLength<0||tensor.StorageLength>MaximumTensorElements)\r\n throw new InvalidDataException(\"Invalid tensor dimensions/storage.\");\r\n if(tensor.Storage.Length==0||tensor.Storage.Any(c=>!char.IsAsciiDigit(c)))throw new InvalidDataException(\"Unexpected tensor storage key.\");\r\n var count=Count(tensor);long last=tensor.Offset;\r\n for(var i=0;i<tensor.Shape.Length;i++)\r\n {if(tensor.Stride[i]<0)throw new InvalidDataException(\"Negative tensor stride.\");last=checked(last+Math.Max(0,tensor.Shape[i]-1)*tensor.Stride[i]);}\r\n if(count>0&&last>=tensor.StorageLength)throw new InvalidDataException(\"Tensor view exceeds its storage.\");\r\n var storage=archive.GetEntry(prefix+\"data/\"+tensor.Storage)??throw new InvalidDataException(\"Missing tensor storage.\");\r\n if(storage.Length!=checked(tensor.StorageLength*ElementSize(tensor.Dtype)))throw new InvalidDataException(\"Tensor storage byte length mismatch.\");\r\n }\r\n static int ElementSize(string dtype)=>dtype switch\r\n {\"FloatStorage\" or \"IntStorage\"=>4,\"DoubleStorage\" or \"LongStorage\"=>8,\"HalfStorage\" or \"BFloat16Storage\"=>2,\"ByteStorage\" or \"BoolStorage\"=>1,_=>throw new NotSupportedException(\"Unsupported checkpoint dtype: \"+dtype)};\r\n\r\n /// <summary>Materializes one tensor, respecting noncontiguous views. Never loads the whole\r\n /// checkpoint into memory. Callers choose which model's tensors to retain.</summary>\r\n public float[] ReadFloat(string name,CancellationToken cancellation=default)\r\n {\r\n var tensor=tensors.TryGetValue(name,out var found)?found:throw new KeyNotFoundException(name);\r\n var count=checked((int)Count(tensor));var bytes=checked((int)(tensor.StorageLength*ElementSize(tensor.Dtype)));\r\n cancellation.ThrowIfCancellationRequested();var storage=new byte[bytes];\r\n using(var input=archive.GetEntry(prefix+\"data/\"+tensor.Storage)!.Open())\r\n {\r\n var offset=0;while(offset<storage.Length){cancellation.ThrowIfCancellationRequested();var n=input.Read(storage,offset,Math.Min(65536,storage.Length-offset));if(n==0)throw new EndOfStreamException();offset+=n;}\r\n }\r\n var output=new float[count];var elementSize=ElementSize(tensor.Dtype);\r\n long contiguousStride=1;var contiguous=true;\r\n for(var dim=tensor.Shape.Length-1;dim>=0;dim--)\r\n {if(tensor.Shape[dim]>1&&tensor.Stride[dim]!=contiguousStride)contiguous=false;contiguousStride*=tensor.Shape[dim];}\r\n if(contiguous&&tensor.Dtype==\"FloatStorage\"&&BitConverter.IsLittleEndian)\r\n {\r\n cancellation.ThrowIfCancellationRequested();\r\n Buffer.BlockCopy(storage,checked((int)(tensor.Offset*4)),output,0,checked(count*4));\r\n cancellation.ThrowIfCancellationRequested();return output;\r\n }\r\n for(var i=0;i<count;i++)\r\n {\r\n if((i&16383)==0)cancellation.ThrowIfCancellationRequested();\r\n long index=tensor.Offset;var remainder=i;\r\n for(var dim=tensor.Shape.Length-1;dim>=0;dim--){index+=(remainder%tensor.Shape[dim])*tensor.Stride[dim];remainder/=tensor.Shape[dim];}\r\n var at=checked((int)(index*elementSize));\r\n output[i]=tensor.Dtype switch\r\n {\r\n \"FloatStorage\"=>BitConverter.ToSingle(storage,at),\"DoubleStorage\"=>(float)BitConverter.ToDouble(storage,at),\r\n \"HalfStorage\"=>(float)BitConverter.UInt16BitsToHalf(BitConverter.ToUInt16(storage,at)),\r\n \"BFloat16Storage\"=>BitConverter.Int32BitsToSingle(BitConverter.ToUInt16(storage,at)<<16),\r\n \"IntStorage\"=>BitConverter.ToInt32(storage,at),\"LongStorage\"=>BitConverter.ToInt64(storage,at),\r\n \"ByteStorage\" or \"BoolStorage\"=>storage[at],_=>throw new NotSupportedException(tensor.Dtype)\r\n };\r\n }\r\n return output;\r\n }\r\n public void Dispose()=>archive.Dispose();\r\n\r\n internal sealed class DataReader\r\n {\r\n readonly BinaryReader reader;\r\n readonly List<object?> stack=new();\r\n readonly Dictionary<int,object?> memo=new();\r\n static readonly object Mark=new();\r\n int operations;\r\n public int Position=>checked((int)reader.BaseStream.Position);\r\n public DataReader(byte[] bytes,int offset=0){reader=new BinaryReader(new MemoryStream(bytes),Encoding.UTF8);reader.BaseStream.Position=offset;}\r\n object? Pop(){if(stack.Count==0)throw new InvalidDataException(\"Empty pickle stack.\");var value=stack[^1];stack.RemoveAt(stack.Count-1);return value;}\r\n object? Peek()=>stack.Count==0?throw new InvalidDataException(\"Empty pickle stack.\"):stack[^1];\r\n object?[] MarkItems()\r\n {\r\n var index=stack.LastIndexOf(Mark);if(index<0)throw new InvalidDataException(\"Missing pickle mark.\");\r\n var values=stack.Skip(index+1).ToArray();stack.RemoveRange(index,stack.Count-index);return values;\r\n }\r\n string Line()\r\n {var bytes=new List<byte>();byte b;while((b=reader.ReadByte())!=10){if(bytes.Count>=4096)throw new InvalidDataException(\"Pickle identifier too long.\");bytes.Add(b);}return Encoding.UTF8.GetString(bytes.ToArray());}\r\n string Text(int length)\r\n {if(length<0||length>MaximumMetadataBytes||length>reader.BaseStream.Length-reader.BaseStream.Position)throw new InvalidDataException(\"Invalid pickle string length.\");return Encoding.UTF8.GetString(reader.ReadBytes(length));}\r\n static long Integer(object? value)=>value switch{int i=>i,long l=>l,_=>throw new InvalidDataException(\"Expected integer.\")};\r\n static object?[] Tuple(object? value)=>value as object?[]??throw new InvalidDataException(\"Expected tuple.\");\r\n static Dictionary<object,object?> Dict(object? value)=>value as Dictionary<object,object?>??throw new InvalidDataException(\"Expected dictionary.\");\r\n static void Set(Dictionary<object,object?> dictionary,object? key,object? value)\r\n {\r\n // Training checkpoints contain integer-keyed optimizer states. Keep those\r\n // as inert data, with numeric keys distinct from strings such as \"0\".\r\n var normalized=key switch{string s=>(object)s,int i=>(long)i,long l=>l,_=>throw new InvalidDataException(\"Only string and integer dictionary keys are supported.\")};\r\n dictionary[normalized]=value;\r\n }\r\n public object? Read(bool requireEnd=true)\r\n {\r\n while(reader.BaseStream.Position<reader.BaseStream.Length)\r\n {\r\n if(++operations>2_000_000||stack.Count>100_000||memo.Count>100_000)throw new InvalidDataException(\"Checkpoint metadata complexity limit.\");\r\n var op=reader.ReadByte();\r\n switch(op)\r\n {\r\n case 0x80:var protocol=reader.ReadByte();if(protocol>5)throw new NotSupportedException(\"Pickle protocol \"+protocol);break;\r\n case (byte)'.':if(stack.Count!=1||(requireEnd&&reader.BaseStream.Position!=reader.BaseStream.Length))throw new InvalidDataException(\"Invalid pickle termination.\");return Pop();\r\n case (byte)'(':stack.Add(Mark);break;\r\n case (byte)')':stack.Add(System.Array.Empty<object?>());break;\r\n case (byte)'}':stack.Add(new Dictionary<object,object?>());break;\r\n case (byte)']':stack.Add(new List<object?>());break;\r\n case (byte)'N':stack.Add(null);break;\r\n case 0x88:stack.Add(true);break;\r\n case 0x89:stack.Add(false);break;\r\n case (byte)'K':stack.Add((int)reader.ReadByte());break;\r\n case (byte)'M':stack.Add((int)reader.ReadUInt16());break;\r\n case (byte)'J':stack.Add(reader.ReadInt32());break;\r\n case (byte)'G':var f=reader.ReadBytes(8);if(f.Length!=8)throw new EndOfStreamException();System.Array.Reverse(f);stack.Add(BitConverter.ToDouble(f));break;\r\n case (byte)'X':stack.Add(Text(reader.ReadInt32()));break;\r\n case 0x8c:stack.Add(Text(reader.ReadByte()));break;\r\n case 0x95:var frame=reader.ReadUInt64();if(frame>(ulong)(reader.BaseStream.Length-reader.BaseStream.Position))throw new InvalidDataException(\"Truncated pickle frame.\");break;\r\n case (byte)'q':memo[reader.ReadByte()]=Peek();break;\r\n case (byte)'r':memo[reader.ReadInt32()]=Peek();break;\r\n case 0x94:memo[memo.Count]=Peek();break;\r\n case (byte)'h':stack.Add(memo[reader.ReadByte()]);break;\r\n case (byte)'j':stack.Add(memo[reader.ReadInt32()]);break;\r\n case (byte)'t':stack.Add(MarkItems());break;\r\n case 0x85:stack.Add(new[]{Pop()});break;\r\n case 0x86:var second=Pop();stack.Add(new[]{Pop(),second});break;\r\n case 0x87:var third=Pop();second=Pop();stack.Add(new[]{Pop(),second,third});break;\r\n case (byte)'s':var value=Pop();var key=Pop();Set(Dict(Peek()),key,value);break;\r\n case (byte)'u':var pairs=MarkItems();if(pairs.Length%2!=0)throw new InvalidDataException(\"Odd dictionary entries.\");var d=Dict(Peek());for(var i=0;i<pairs.Length;i+=2)Set(d,pairs[i],pairs[i+1]);break;\r\n case (byte)'a':value=Pop();((List<object?>)Peek()!).Add(value);break;\r\n case (byte)'e':var items=MarkItems();((List<object?>)Peek()!).AddRange(items);break;\r\n case (byte)'c':stack.Add(AllowedSymbol(Line(),Line()));break;\r\n case 0x93:var name=Pop() as string??throw new InvalidDataException();var module=Pop() as string??throw new InvalidDataException();stack.Add(AllowedSymbol(module,name));break;\r\n case (byte)'Q':\r\n var storage=Tuple(Pop());\r\n if(storage.Length<5||storage[0] as string!=\"storage\"||storage[1] is not Symbol type||type.Module!=\"torch\"||storage[2] is not string storageKey)\r\n throw new InvalidDataException(\"Unsupported persistent pickle reference.\");\r\n if(storage.Length>5&&storage[5] is not null)throw new NotSupportedException(\"Legacy storage aliases are not supported.\");\r\n stack.Add(new StorageRef(storageKey,type.Name,Integer(storage[4])));break;\r\n case (byte)'R':\r\n var arguments=Tuple(Pop());var symbol=Pop() as Symbol??throw new InvalidDataException(\"Unsupported pickle callable.\");\r\n if(symbol==new Symbol(\"collections\",\"OrderedDict\")&&arguments.Length==0)stack.Add(new Dictionary<object,object?>());\r\n else if(symbol==new Symbol(\"collections\",\"Counter\")&&arguments.Length<=1)\r\n stack.Add(arguments.Length==0?new Dictionary<object,object?>():new Dictionary<object,object?>(Dict(arguments[0])));\r\n else if(symbol.Module==\"torch._utils\"&&(symbol.Name==\"_rebuild_tensor_v2\"||symbol.Name==\"_rebuild_tensor\")&&arguments.Length>=4&&arguments[0] is StorageRef sr)\r\n stack.Add(new TensorRef(sr,Integer(arguments[1]),Tuple(arguments[2]).Select(x=>checked((int)Integer(x))).ToArray(),Tuple(arguments[3]).Select(Integer).ToArray()));\r\n else throw new InvalidDataException(\"Unsupported checkpoint construction: \"+symbol);\r\n break;\r\n case (byte)'b':\r\n var metadata=Dict(Pop());_ = Dict(Peek());\r\n if(metadata.Keys.Any(k=>!Equals(k,\"_metadata\")))throw new InvalidDataException(\"Unsupported checkpoint object state.\");\r\n break;\r\n default:throw new NotSupportedException($\"Checkpoint pickle opcode 0x{op:X2} at {reader.BaseStream.Position-1} is unsupported.\");\r\n }\r\n }\r\n throw new EndOfStreamException(\"Missing pickle STOP.\");\r\n }\r\n static Symbol AllowedSymbol(string module,string name)\r\n {\r\n if(module==\"collections\"&&(name==\"OrderedDict\"||name==\"Counter\")||module==\"torch._utils\"&&(name==\"_rebuild_tensor_v2\"||name==\"_rebuild_tensor\"))return new(module,name);\r\n if(module==\"torch\"){_ = ElementSize(name);return new(module,name);}\r\n throw new InvalidDataException(\"Executable/unsupported checkpoint symbol rejected: \"+module+\".\"+name);\r\n }\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Editor/TextToAnimation/Inference/UniMate/T5TextEncoder.cs",
"FileName": "T5TextEncoder.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Concurrent;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Threading;\r\nusing TextToAnimation.Editor.Inference.Onnx;\r\n\r\nnamespace TextToAnimation.Editor.Inference.UniMate;\r\n\r\n/// <summary>\r\n/// flan-t5-base encoder (ONNX, run by the managed runtime) with UniMate's pooling: the mean of\r\n/// <c>last_hidden_state</c> over the non-padding tokens. The empty prompt pools to zero, which is the\r\n/// unconditional (classifier-free) branch. Results are cached per text.\r\n/// </summary>\r\npublic sealed class T5TextEncoder\r\n{\r\n\tpublic const int Width = 768;\r\n\treadonly OnnxSession _session;\r\n\treadonly T5Tokenizer _tokenizer;\r\n\treadonly ConcurrentDictionary<string, float[]> _cache = new( StringComparer.Ordinal );\r\n\treadonly object _runLock = new();\r\n\r\n\tpublic T5TextEncoder( OnnxSession session, T5Tokenizer tokenizer )\r\n\t{\r\n\t\t_session = session;\r\n\t\t_tokenizer = tokenizer;\r\n\t}\r\n\r\n\tpublic T5Tokenizer Tokenizer => _tokenizer;\r\n\r\n\t/// <summary>Pooled 768-d embedding of a text.</summary>\r\n\tpublic float[] Encode( string text, CancellationToken token = default )\r\n\t{\r\n\t\ttext ??= \"\";\r\n\t\tif ( text.Length == 0 ) return new float[Width];\r\n\t\tif ( _cache.TryGetValue( text, out var cached ) ) return cached;\r\n\t\tvar ids = _tokenizer.Encode( text );\r\n\t\tvar input = Tensor.Int64( new[] { 1, ids.Count }, ids.Select( i => (long)i ).ToArray() );\r\n\t\tvar mask = Tensor.Int64( new[] { 1, ids.Count }, Enumerable.Repeat( 1L, ids.Count ).ToArray() );\r\n\t\tTensor hidden;\r\n\t\tlock ( _runLock )\r\n\t\t\thidden = _session.Run( new Dictionary<string, Tensor> { [\"input_ids\"] = input, [\"attention_mask\"] = mask }, token )[\"last_hidden_state\"];\r\n\t\tvar pooled = new float[Width];\r\n\t\tfor ( var t = 0; t < ids.Count; t++ )\r\n\t\t\tfor ( var c = 0; c < Width; c++ ) pooled[c] += hidden.F[t * Width + c];\r\n\t\tfor ( var c = 0; c < Width; c++ ) pooled[c] /= ids.Count;\r\n\t\t_cache[text] = pooled;\r\n\t\treturn pooled;\r\n\t}\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Editor/TextToAnimation/Inference/UniMate/UniMateModel.cs",
"FileName": "UniMateModel.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Threading;\r\nusing TextToAnimation.Editor.Inference.Onnx;\r\n\r\nnamespace TextToAnimation.Editor.Inference.UniMate;\r\n\r\n/// <summary>Files of an installed UniMate model.</summary>\r\npublic sealed record UniMateFiles( string Directory )\r\n{\r\n\tpublic string WeightBlob => Path.Combine( Directory, $\"unimate_v2_g{UniMateGraphBuilder.FormatVersion}.weights\" );\r\n\tpublic string T5Encoder => Path.Combine( Directory, \"t5_encoder.onnx\" );\r\n\tpublic string T5Tokenizer => Path.Combine( Directory, \"t5_tokenizer.json\" );\r\n\tpublic string Graphs => Path.Combine( Directory, $\"graphs_g{UniMateGraphBuilder.FormatVersion}\" );\r\n}\r\n\r\n/// <summary>Conditioning tensors of one skeleton, computed once (the prepare graph's outputs).</summary>\r\npublic sealed class PreparedSkeleton\r\n{\r\n\tpublic required UniMateSkeleton Skeleton { get; init; }\r\n\tpublic required Dictionary<string, Tensor> Tensors { get; init; }\r\n}\r\n\r\n/// <summary>\r\n/// ODE integrators for the flow: Euler (1 model call per step, 1st order), Heun (2 calls, 2nd order) and\r\n/// Adams-Bashforth 2 (1 call per step, 2nd order: reuses the previous step's velocity).\r\n/// Dopri5: upstream's text-to-motion sampler (Sampler.sample_ode: torchdiffeq dopri5, adaptive, rtol 1e-3,\r\n/// atol 1e-6, t from 0 to 1) - integrates the flow to convergence.\r\n/// </summary>\r\npublic enum Integrator { Euler, Heun, AdamsBashforth2, Dopri5 }\r\n\r\n/// <summary>Sampling settings.</summary>\r\npublic sealed class SampleSettings\r\n{\r\n\tpublic int Steps { get; init; } = 24;\r\n\tpublic float Guidance { get; init; } = 3f;\r\n\tpublic Integrator Method { get; init; } = Integrator.Euler;\r\n\t/// <summary>\r\n\t/// Time-grid shift (1 = uniform). Above 1 the steps crowd towards the noisy start of the flow, where the\r\n\t/// velocity changes fastest: t' = s t / (1 + (s - 1) t).\r\n\t/// </summary>\r\n\tpublic float TimeShift { get; init; } = 1f;\r\n\t/// <summary>Start time of the flow (0 = pure noise; >0 starts from a noised copy of <see cref=\"Known\"/>: variations).</summary>\r\n\tpublic float StartTime { get; init; }\r\n\t/// <summary>Known normalised motion (J,12,T) for constraints / variations.</summary>\r\n\tpublic float[] Known { get; init; }\r\n\t/// <summary>(J,12,T) mask of values held to <see cref=\"Known\"/> (replacement sampling).</summary>\r\n\tpublic bool[] Keep { get; init; }\r\n\t/// <summary>Dopri5 tolerances (upstream's sample_ode defaults).</summary>\r\n\tpublic double RelativeTolerance { get; init; } = 1e-3;\r\n\tpublic double AbsoluteTolerance { get; init; } = 1e-6;\r\n}\r\n\r\n/// <summary>\r\n/// The UniMate model running on the managed ONNX runtime: the flan-t5 text encoder plus the denoiser's\r\n/// prepare/step graphs (generated in C# per skeleton from the shared weight blob), and the flow-matching\r\n/// sampler with classifier-free guidance and replacement constraints.\r\n/// </summary>\r\npublic sealed class UniMateModel\r\n{\r\n\tpublic const int Frames = 60;\r\n\tpublic const float Fps = 30f;\r\n\tconst int Batch = 2; // conditional + unconditional rows\r\n\r\n\treadonly UniMateFiles _files;\r\n\treadonly WeightBlob _blob;\r\n\treadonly UniMateArch _arch = UniMateArch.V2;\r\n\tpublic T5TextEncoder Text { get; }\r\n\treadonly Dictionary<int, (OnnxSession Prepare, OnnxSession Step)> _graphs = new();\r\n\treadonly object _lock = new();\r\n\r\n\tUniMateModel( UniMateFiles files, WeightBlob blob, T5TextEncoder text )\r\n\t{\r\n\t\t_files = files;\r\n\t\t_blob = blob;\r\n\t\tText = text;\r\n\t}\r\n\r\n\tpublic static UniMateModel Load( UniMateFiles files, CancellationToken token = default )\r\n\t{\r\n\t\tif ( !WeightBlob.Exists( files.WeightBlob ) ) throw new FileNotFoundException( \"The UniMate weights are not prepared.\", files.WeightBlob );\r\n\t\tvar blob = WeightBlob.Open( files.WeightBlob );\r\n\t\ttoken.ThrowIfCancellationRequested();\r\n\t\tvar text = new T5TextEncoder( OnnxSession.Load( files.T5Encoder, token ), T5Tokenizer.Load( files.T5Tokenizer ) );\r\n\t\treturn new UniMateModel( files, blob, text );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Writes the shared weight blob from checkpoint weights (EMA, PyTorch names): every weight in the layout the\r\n\t/// graphs use. Done once at install; afterwards graphs for any skeleton are generated without the checkpoint.\r\n\t/// </summary>\r\n\tpublic static void WriteWeights( IReadOnlyDictionary<string, Runtime.WeightTensor> weights, string blobPath )\r\n\t{\r\n\t\tusing var blob = WeightBlob.Create( blobPath );\r\n\t\tvar builder = new UniMateGraphBuilder( weights, UniMateArch.V2, blob );\r\n\t\tbuilder.BuildPrepare( 22, null, null );\r\n\t\tbuilder.BuildStep( Batch, 22, Frames, null, null );\r\n\t\tblob.Commit();\r\n\t}\r\n\r\n\t/// <summary>Builds (or reuses) the graphs for a joint count; graph files are cached on disk.</summary>\r\n\t(OnnxSession Prepare, OnnxSession Step) Graphs( int joints, CancellationToken token )\r\n\t{\r\n\t\tlock ( _lock )\r\n\t\t{\r\n\t\t\tif ( _graphs.TryGetValue( joints, out var g ) ) return g;\r\n\t\t\tDirectory.CreateDirectory( _files.Graphs );\r\n\t\t\tvar prepPath = Path.Combine( _files.Graphs, $\"prepare_j{joints}.onnx\" );\r\n\t\t\tvar stepPath = Path.Combine( _files.Graphs, $\"step_b{Batch}_j{joints}_t{Frames}.onnx\" );\r\n\t\t\tvar builder = new UniMateGraphBuilder( null, _arch, _blob );\r\n\t\t\tif ( !File.Exists( prepPath ) ) WriteAtomic( prepPath, builder.BuildPrepare( joints, null, null ) );\r\n\t\t\tif ( !File.Exists( stepPath ) ) WriteAtomic( stepPath, builder.BuildStep( Batch, joints, Frames, null, null ) );\r\n\t\t\tOnnxSession Load( string path )\r\n\t\t\t{\r\n\t\t\t\tvar model = OnnxModel.Parse( File.ReadAllBytes( path ) );\r\n\t\t\t\tmodel.BaseDirectory = _files.Directory; // the weight blob lives in the model folder\r\n\t\t\t\treturn new OnnxSession( model, token );\r\n\t\t\t}\r\n\t\t\t// keep at most two skeleton sizes in memory (the step graph holds ~300 MB of weights)\r\n\t\t\tif ( _graphs.Count >= 2 )\r\n\t\t\t{\r\n\t\t\t\tvar evicted = _graphs.Keys.First();\r\n\t\t\t\t_graphs.Remove( evicted );\r\n\t\t\t\tif ( _gpu.Remove( evicted, out var program ) ) program.Dispose();\r\n\t\t\t}\r\n\t\t\tg = (Load( prepPath ), Load( stepPath ));\r\n\t\t\t_graphs[joints] = g;\r\n\t\t\treturn g;\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void WriteAtomic( string path, byte[] bytes )\r\n\t{\r\n\t\tFile.WriteAllBytes( path + \".tmp\", bytes );\r\n\t\tFile.Move( path + \".tmp\", path, overwrite: true );\r\n\t}\r\n\r\n\t/// <summary>Runs the prepare graph for a skeleton (joint names are embedded with T5).</summary>\r\n\tpublic PreparedSkeleton Prepare( UniMateSkeleton s, UniMateStats stats, CancellationToken token )\r\n\t\t=> Prepare( s, ConditioningInputs( s, stats, token ), token );\r\n\r\n\t/// <summary>Runs the prepare graph on given conditioning inputs (see <see cref=\"ConditioningInputs\"/>).</summary>\r\n\tpublic PreparedSkeleton Prepare( UniMateSkeleton s, Dictionary<string, Tensor> conditioning, CancellationToken token )\r\n\t{\r\n\t\tvar (prepare, _) = Graphs( s.Count, token );\r\n\t\tvar outputs = prepare.Run( conditioning, token );\r\n\t\treturn new PreparedSkeleton { Skeleton = s, Tensors = outputs };\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The skeleton conditioning the network gets (upstream create_sample_condition: normalised T-pose features with\r\n\t/// identity rotations, the parents' copies, relations, graph distances, depths, spectral features, joint-name\r\n\t/// embeddings).\r\n\t/// </summary>\r\n\tpublic Dictionary<string, Tensor> ConditioningInputs( UniMateSkeleton s, UniMateStats stats, CancellationToken token )\r\n\t{\r\n\t\tvar J = s.Count;\r\n\t\tvar tpos = new float[J * 12]; var parentRow = new float[J * 12];\r\n\t\tfor ( var j = 0; j < J; j++ )\r\n\t\t{\r\n\t\t\tvar row = new double[12];\r\n\t\t\trow[0] = s.TPose[j].X; row[1] = s.TPose[j].Y; row[2] = s.TPose[j].Z;\r\n\t\t\trow[3] = 1; row[7] = 1; // identity 6D\r\n\t\t\tfor ( var c = 0; c < 12; c++ ) tpos[j * 12 + c] = stats.Normalize( j, c, row[c] );\r\n\t\t}\r\n\t\tfor ( var j = 0; j < J; j++ )\r\n\t\t{\r\n\t\t\tvar src = s.Parents[j] < 0 ? j : s.Parents[j];\r\n\t\t\tArray.Copy( tpos, src * 12, parentRow, j * 12, 12 );\r\n\t\t}\r\n\t\tvar rel = new long[J * J]; var dist = new long[J * J];\r\n\t\tfor ( var i = 0; i < J; i++ ) for ( var k = 0; k < J; k++ ) { rel[i * J + k] = s.Relations[i, k]; dist[i * J + k] = s.GraphDist[i, k]; }\r\n\t\tvar spec = new float[J * 8];\r\n\t\tfor ( var j = 0; j < J; j++ ) for ( var c = 0; c < 8; c++ ) spec[j * 8 + c] = s.Spectral[j, c];\r\n\t\tvar names = new float[J * T5TextEncoder.Width];\r\n\t\tfor ( var j = 0; j < J; j++ ) Array.Copy( Text.Encode( s.CleanNames[j], token ), 0, names, j * T5TextEncoder.Width, T5TextEncoder.Width );\r\n\t\treturn new Dictionary<string, Tensor>\r\n\t\t{\r\n\t\t\t[\"tpos\"] = Tensor.Float( new[] { J, 12 }, tpos ),\r\n\t\t\t[\"tpos_parent\"] = Tensor.Float( new[] { J, 12 }, parentRow ),\r\n\t\t\t[\"joint_rel\"] = Tensor.Int64( new[] { J, J }, rel ),\r\n\t\t\t[\"graph_dist\"] = Tensor.Int64( new[] { J, J }, dist ),\r\n\t\t\t[\"depth\"] = Tensor.Int64( new[] { J }, s.Depths.ToArray() ),\r\n\t\t\t[\"spectral\"] = Tensor.Float( new[] { J, 8 }, spec ),\r\n\t\t\t[\"name_emb\"] = Tensor.Float( new[] { J, T5TextEncoder.Width }, names ),\r\n\t\t};\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Flow-matching sample: x(0) = noise, dx/dt = v(x,t) with classifier-free guidance, integrated to t = 1.\r\n\t/// Values under <see cref=\"SampleSettings.Keep\"/> follow the known motion along the interpolant\r\n\t/// (replacement sampling, as upstream). Returns the normalised motion (J,12,T).\r\n\t/// </summary>\r\n\tpublic float[] Sample( PreparedSkeleton prep, float[] caption, float[] noise, SampleSettings settings,\r\n\t\tAction<float> progress, CancellationToken token )\r\n\t{\r\n\t\tvar J = prep.Skeleton.Count;\r\n\t\tvar n = J * 12 * Frames;\r\n\t\tif ( noise.Length != n ) throw new ArgumentException( \"Noise has the wrong size.\" );\r\n\t\tvar (_, step) = Graphs( J, token );\r\n\t\tvar feed = new Dictionary<string, Tensor>( prep.Tensors );\r\n\t\tvar captions = new float[Batch * T5TextEncoder.Width];\r\n\t\tArray.Copy( caption, captions, T5TextEncoder.Width ); // row 1 stays zero: unconditional\r\n\t\tfeed[\"caption_emb\"] = Tensor.Float( new[] { Batch, T5TextEncoder.Width }, captions );\r\n\r\n\t\tvar steps = Math.Max( 1, settings.Steps );\r\n\t\tvar t0 = Math.Clamp( settings.StartTime, 0f, 0.98f );\r\n\t\tvar x = new float[n];\r\n\t\tif ( t0 > 0 && settings.Known is { } known0 )\r\n\t\t\tfor ( var i = 0; i < n; i++ ) x[i] = t0 * known0[i] + (1 - t0) * noise[i];\r\n\t\telse Array.Copy( noise, x, n );\r\n\t\tReplace( x, t0 );\r\n\r\n\t\tfloat[] Velocity( float[] state, float t )\r\n\t\t{\r\n\t\t\tvar input = new float[Batch * n];\r\n\t\t\tArray.Copy( state, 0, input, 0, n );\r\n\t\t\tArray.Copy( state, 0, input, n, n );\r\n\t\t\tfeed[\"x\"] = Tensor.Float( new[] { Batch, J, 12, Frames }, input );\r\n\t\t\tfeed[\"t\"] = Tensor.Float( new[] { Batch }, new[] { t, t } );\r\n\t\t\tvar v = RunStep( J, step, feed, token );\r\n\t\t\tvar result = new float[n];\r\n\t\t\tvar g = settings.Guidance;\r\n\t\t\tfor ( var i = 0; i < n; i++ ) result[i] = v[n + i] + g * (v[i] - v[n + i]); // v_u + g (v_c - v_u)\r\n\t\t\treturn result;\r\n\t\t}\r\n\r\n\t\tvoid Replace( float[] state, float t )\r\n\t\t{\r\n\t\t\tif ( settings.Keep is not { } keep || settings.Known is not { } k ) return;\r\n\t\t\tfor ( var i = 0; i < n; i++ )\r\n\t\t\t\tif ( keep[i] ) state[i] = (1 - t) * noise[i] + t * k[i];\r\n\t\t}\r\n\r\n\t\tif ( settings.Method == Integrator.Dopri5 )\r\n\t\t{\r\n\t\t\tif ( settings.Keep is not null || t0 > 0 ) throw new ArgumentException( \"Dopri5 samples from noise without constraints (upstream's text-to-motion).\" );\r\n\t\t\treturn Dopri5( x, Velocity, settings.RelativeTolerance, settings.AbsoluteTolerance, progress, token );\r\n\t\t}\r\n\r\n\t\t// time grid from t0 to 1 (optionally shifted towards the start)\r\n\t\tvar grid = new float[steps + 1];\r\n\t\tvar shift = settings.TimeShift > 0f ? settings.TimeShift : 1f;\r\n\t\tfor ( var k = 0; k <= steps; k++ )\r\n\t\t{\r\n\t\t\tvar u = (float)k / steps;\r\n\t\t\tvar w = shift == 1f ? u : shift * u / (1f + (shift - 1f) * u);\r\n\t\t\tgrid[k] = t0 + (1f - t0) * w;\r\n\t\t}\r\n\t\tfloat[] previousV = null;\r\n\t\tvar previousDt = 0f;\r\n\t\tfor ( var s = 0; s < steps; s++ )\r\n\t\t{\r\n\t\t\ttoken.ThrowIfCancellationRequested();\r\n\t\t\tvar t = grid[s];\r\n\t\t\tvar dt = grid[s + 1] - t;\r\n\t\t\tvar v1 = Velocity( x, t );\r\n\t\t\tif ( settings.Method == Integrator.Heun && s < steps - 1 )\r\n\t\t\t{\r\n\t\t\t\tvar xp = new float[n];\r\n\t\t\t\tfor ( var i = 0; i < n; i++ ) xp[i] = x[i] + dt * v1[i];\r\n\t\t\t\tReplace( xp, t + dt );\r\n\t\t\t\tvar v2 = Velocity( xp, t + dt );\r\n\t\t\t\tfor ( var i = 0; i < n; i++ ) x[i] += dt * 0.5f * (v1[i] + v2[i]);\r\n\t\t\t}\r\n\t\t\telse if ( settings.Method == Integrator.AdamsBashforth2 && previousV is not null )\r\n\t\t\t{\r\n\t\t\t\t// variable-step AB2: extrapolate the velocity to the middle of the step from the last two\r\n\t\t\t\tvar r = dt / (2f * previousDt);\r\n\t\t\t\tfor ( var i = 0; i < n; i++ ) x[i] += dt * ((1f + r) * v1[i] - r * previousV[i]);\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tfor ( var i = 0; i < n; i++ ) x[i] += dt * v1[i];\r\n\t\t\t}\r\n\t\t\tpreviousV = v1;\r\n\t\t\tpreviousDt = dt;\r\n\t\t\tReplace( x, t + dt );\r\n\t\t\tprogress?.Invoke( (s + 1f) / steps );\r\n\t\t}\r\n\t\treturn x;\r\n\t}\r\n\r\n\treadonly Dictionary<int, IGpuProgram> _gpu = new();\r\n\treadonly HashSet<int> _gpuRefused = new();\r\n\r\n\t/// <summary>\r\n\t/// One network call: on the GPU when a program for this skeleton size exists; otherwise on the CPU, and the first\r\n\t/// CPU call is traced into a GPU plan (shapes are fixed per size) which is then checked against that call's\r\n\t/// result before later calls use it. Any GPU failure falls back to the CPU for good.\r\n\t/// </summary>\r\n\tfloat[] RunStep( int joints, OnnxSession step, Dictionary<string, Tensor> feed, CancellationToken token )\r\n\t{\r\n\t\tIGpuProgram program;\r\n\t\tlock ( _lock ) _gpu.TryGetValue( joints, out program );\r\n\t\tif ( program is not null && GpuAcceleration.Enabled )\r\n\t\t{\r\n\t\t\ttry { return program.Run( feed )[\"v\"]; }\r\n\t\t\tcatch ( Exception e ) when ( e is not OperationCanceledException )\r\n\t\t\t{\r\n\t\t\t\tDisableGpu( joints, $\"The GPU run failed ({e.Message}); using the CPU.\" );\r\n\t\t\t}\r\n\t\t}\r\n\t\tvar tryGpu = GpuAcceleration.Enabled && GpuAcceleration.Compiler is not null;\r\n\t\tlock ( _lock ) tryGpu &= !_gpuRefused.Contains( joints ) && !_gpu.ContainsKey( joints );\r\n\t\tif ( !tryGpu ) return step.Run( feed, token )[\"v\"].F;\r\n\r\n\t\tvar recorder = new GpuPlan.Recorder();\r\n\t\tDictionary<string, Tensor> cpu;\r\n\t\tlock ( step ) // the trace hook belongs to the session\r\n\t\t{\r\n\t\t\tstep.Trace = recorder.Record;\r\n\t\t\ttry { cpu = step.Run( feed, token ); }\r\n\t\t\tfinally { step.Trace = null; }\r\n\t\t}\r\n\t\tvar v = cpu[\"v\"].F;\r\n\t\tvar plan = GpuPlan.Build( step, recorder );\r\n\t\tif ( plan is null ) { Refuse( joints, $\"No GPU plan: {GpuPlan.LastRefusal}.\" ); return v; }\r\n\t\ttry\r\n\t\t{\r\n\t\t\tprogram = GpuAcceleration.Compiler( plan, step );\r\n\t\t\tvar check = program.Run( feed )[\"v\"];\r\n\t\t\tvar worst = 0f;\r\n\t\t\tfor ( var i = 0; i < v.Length; i++ ) worst = MathF.Max( worst, MathF.Abs( check[i] - v[i] ) );\r\n\t\t\tif ( !(worst <= GpuAcceleration.AgreementTolerance) )\r\n\t\t\t{\r\n\t\t\t\t// find the first kernel that disagrees (one more traced CPU run keeps every value)\r\n\t\t\t\tvar values = new GpuPlan.Recorder { KeepValues = true };\r\n\t\t\t\tlock ( step )\r\n\t\t\t\t{\r\n\t\t\t\t\tstep.Trace = values.Record;\r\n\t\t\t\t\ttry { step.Run( feed, token ); }\r\n\t\t\t\t\tfinally { step.Trace = null; }\r\n\t\t\t\t}\r\n\t\t\t\tvar where = program.Diagnose( feed, values.Values );\r\n\t\t\t\tprogram.Dispose();\r\n\t\t\t\tRefuse( joints, $\"The GPU result differs from the CPU by {worst:G3}; using the CPU. First difference: {where ?? \"none found\"}\" );\r\n\t\t\t\treturn v;\r\n\t\t\t}\r\n\t\t\tlock ( _lock ) _gpu[joints] = program;\r\n\t\t\tGpuAcceleration.Status = null;\r\n\t\t}\r\n\t\tcatch ( Exception e ) when ( e is not OperationCanceledException )\r\n\t\t{\r\n\t\t\tprogram?.Dispose();\r\n\t\t\tRefuse( joints, $\"The GPU couldn't be used ({e.Message}); using the CPU.\" );\r\n\t\t}\r\n\t\treturn v;\r\n\t}\r\n\r\n\tvoid Refuse( int joints, string why )\r\n\t{\r\n\t\tlock ( _lock ) _gpuRefused.Add( joints );\r\n\t\tGpuAcceleration.Status = why;\r\n\t}\r\n\r\n\tvoid DisableGpu( int joints, string why )\r\n\t{\r\n\t\tlock ( _lock )\r\n\t\t{\r\n\t\t\tif ( _gpu.Remove( joints, out var p ) ) p.Dispose();\r\n\t\t\t_gpuRefused.Add( joints );\r\n\t\t}\r\n\t\tGpuAcceleration.Status = why;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// torchdiffeq's dopri5 (Dormand-Prince 5(4) with its step control, initial step and 4th-order interpolation), as\r\n\t/// upstream's Sampler.sample_ode calls it: from t = 0 to 1, RMS error norm, safety 0.9, growth at most 10x,\r\n\t/// shrink at most 5x (none after an accepted step). The last step may pass t = 1; the result is interpolated there.\r\n\t/// </summary>\r\n\tpublic static float[] Dopri5( float[] y0, Func<float[], float, float[]> f, double rtol, double atol, Action<float> progress, CancellationToken token )\r\n\t{\r\n\t\tvar n = y0.Length;\r\n\t\tdouble[] A = { 1 / 5.0, 3 / 10.0, 4 / 5.0, 8 / 9.0, 1.0, 1.0 };\r\n\t\tdouble[][] B =\r\n\t\t{\r\n\t\t\tnew[] { 1 / 5.0 },\r\n\t\t\tnew[] { 3 / 40.0, 9 / 40.0 },\r\n\t\t\tnew[] { 44 / 45.0, -56 / 15.0, 32 / 9.0 },\r\n\t\t\tnew[] { 19372 / 6561.0, -25360 / 2187.0, 64448 / 6561.0, -212 / 729.0 },\r\n\t\t\tnew[] { 9017 / 3168.0, -355 / 33.0, 46732 / 5247.0, 49 / 176.0, -5103 / 18656.0 },\r\n\t\t\tnew[] { 35 / 384.0, 0, 500 / 1113.0, 125 / 192.0, -2187 / 6784.0, 11 / 84.0 },\r\n\t\t};\r\n\t\tdouble[] E = { 35 / 384.0 - 1951 / 21600.0, 0, 500 / 1113.0 - 22642 / 50085.0, 125 / 192.0 - 451 / 720.0,\r\n\t\t\t-2187 / 6784.0 - -12231 / 42400.0, 11 / 84.0 - 649 / 6300.0, -1.0 / 60.0 };\r\n\t\tdouble[] Mid = { 6025192743 / 30085553152.0 / 2, 0, 51252292925 / 65400821598.0 / 2, -2691868925 / 45128329728.0 / 2,\r\n\t\t\t187940372067 / 1594534317056.0 / 2, -1776094331 / 19743644256.0 / 2, 11237099 / 235043384.0 / 2 };\r\n\t\tconst double Safety = 0.9, IFactor = 10, DFactor = 0.2;\r\n\t\tconst int Order = 5;\r\n\t\tdouble Rms( Func<int, double> v ) { var s = 0.0; for ( var i = 0; i < n; i++ ) { var x = v( i ); s += x * x; } return Math.Sqrt( s / n ); }\r\n\r\n\t\t// initial step (Hairer, Norsett & Wanner II.4), with the solver's order - 1 as torchdiffeq passes it\r\n\t\tvar fy0 = f( y0, 0f );\r\n\t\tvar d0 = Rms( i => y0[i] / (atol + Math.Abs( y0[i] ) * rtol ) );\r\n\t\tvar d1 = Rms( i => fy0[i] / (atol + Math.Abs( y0[i] ) * rtol ) );\r\n\t\tvar h0 = d0 < 1e-5 || d1 < 1e-5 ? 1e-6 : 0.01 * d0 / d1;\r\n\t\tvar probe = new float[n];\r\n\t\tfor ( var i = 0; i < n; i++ ) probe[i] = (float)(y0[i] + h0 * fy0[i]);\r\n\t\tvar fProbe = f( probe, (float)h0 );\r\n\t\tvar d2 = Rms( i => (fProbe[i] - fy0[i]) / (atol + Math.Abs( y0[i] ) * rtol ) ) / h0;\r\n\t\tvar h1 = d1 <= 1e-15 && d2 <= 1e-15 ? Math.Max( 1e-6, h0 * 1e-3 ) : Math.Pow( 0.01 / Math.Max( d1, d2 ), 1.0 / Order );\r\n\t\tvar dt = Math.Min( 100 * h0, h1 );\r\n\r\n\t\tvar y = (float[])y0.Clone();\r\n\t\tvar fy = fy0;\r\n\t\tdouble t = 0;\r\n\t\tvar k = new float[7][];\r\n\t\twhile ( true )\r\n\t\t{\r\n\t\t\ttoken.ThrowIfCancellationRequested();\r\n\t\t\tvar t1 = t + dt;\r\n\t\t\tk[0] = fy;\r\n\t\t\tvar yi = new float[n];\r\n\t\t\tfor ( var s = 0; s < 6; s++ )\r\n\t\t\t{\r\n\t\t\t\tvar b = B[s];\r\n\t\t\t\tfor ( var i = 0; i < n; i++ )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar acc = 0.0;\r\n\t\t\t\t\tfor ( var j = 0; j <= s; j++ ) acc += k[j][i] * (b[j] * dt);\r\n\t\t\t\t\tyi[i] = (float)(y[i] + acc);\r\n\t\t\t\t}\r\n\t\t\t\tvar ts = A[s] == 1.0 ? t1 : t + A[s] * dt;\r\n\t\t\t\tk[s + 1] = f( yi, (float)ts );\r\n\t\t\t\tif ( s < 5 ) yi = new float[n];\r\n\t\t\t}\r\n\t\t\t// the 6th stage is the 5th-order solution (FSAL: k[6] is its derivative)\r\n\t\t\tvar y1 = yi;\r\n\t\t\tvar ratio = Rms( i =>\r\n\t\t\t{\r\n\t\t\t\tvar err = 0.0;\r\n\t\t\t\tfor ( var j = 0; j < 7; j++ ) err += k[j][i] * (dt * E[j]);\r\n\t\t\t\treturn err / (atol + rtol * Math.Max( Math.Abs( y[i] ), Math.Abs( y1[i] ) ));\r\n\t\t\t} );\r\n\t\t\tvar accept = ratio <= 1;\r\n\t\t\tvar factor = ratio == 0 ? IFactor : Math.Min( IFactor, Math.Max( Safety / Math.Pow( ratio, 1.0 / Order ), accept ? 1.0 : DFactor ) );\r\n\t\t\tif ( accept )\r\n\t\t\t{\r\n\t\t\t\tif ( t1 >= 1.0 )\r\n\t\t\t\t{\r\n\t\t\t\t\t// 4th-order interpolation of this step at t = 1\r\n\t\t\t\t\tvar x = (1.0 - t) / dt;\r\n\t\t\t\t\tvar result = new float[n];\r\n\t\t\t\t\tfor ( var i = 0; i < n; i++ )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar mid = 0.0;\r\n\t\t\t\t\t\tfor ( var j = 0; j < 7; j++ ) mid += k[j][i] * (dt * Mid[j]);\r\n\t\t\t\t\t\tdouble ym = y[i] + mid, a0 = y[i], a1 = y1[i], f0 = k[0][i], f1 = k[6][i];\r\n\t\t\t\t\t\tvar ca = 2 * dt * (f1 - f0) - 8 * (a1 + a0) + 16 * ym;\r\n\t\t\t\t\t\tvar cb = dt * (5 * f0 - 3 * f1) + 18 * a0 + 14 * a1 - 32 * ym;\r\n\t\t\t\t\t\tvar cc = dt * (f1 - 4 * f0) - 11 * a0 - 5 * a1 + 16 * ym;\r\n\t\t\t\t\t\tvar cd = dt * f0;\r\n\t\t\t\t\t\tresult[i] = (float)(a0 + x * cd + x * x * cc + x * x * x * cb + x * x * x * x * ca);\r\n\t\t\t\t\t}\r\n\t\t\t\t\tprogress?.Invoke( 1f );\r\n\t\t\t\t\treturn result;\r\n\t\t\t\t}\r\n\t\t\t\ty = y1;\r\n\t\t\t\tfy = k[6];\r\n\t\t\t\tt = t1;\r\n\t\t\t\tprogress?.Invoke( (float)Math.Min( 0.99, t ) );\r\n\t\t\t}\r\n\t\t\tdt *= factor;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Standard normal noise (J,12,T) from a seed (Box-Muller).</summary>\r\n\tpublic static float[] Noise( int joints, int seed )\r\n\t{\r\n\t\tvar rng = new Random( seed );\r\n\t\tvar n = joints * 12 * Frames;\r\n\t\tvar r = new float[n];\r\n\t\tfor ( var i = 0; i < n; i += 2 )\r\n\t\t{\r\n\t\t\tvar u1 = 1.0 - rng.NextDouble(); var u2 = rng.NextDouble();\r\n\t\t\tvar mag = Math.Sqrt( -2.0 * Math.Log( u1 ) );\r\n\t\t\tr[i] = (float)(mag * Math.Cos( 2 * Math.PI * u2 ));\r\n\t\t\tif ( i + 1 < n ) r[i + 1] = (float)(mag * Math.Sin( 2 * Math.PI * u2 ));\r\n\t\t}\r\n\t\treturn r;\r\n\t}\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Editor/TextToAnimation/Inference/UniMate/UniMatePrompt.cs",
"FileName": "UniMatePrompt.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "namespace TextToAnimation.Editor.Inference.UniMate;\r\n\r\n/// <summary>Prompts as UniMate takes them, and clip names from them.</summary>\r\npublic static class UniMatePrompt\r\n{\r\n\t/// <summary>\r\n\t/// The caption UniMate is conditioned on: the prompt as written, trimmed - as upstream (inference/sample.py encodes\r\n\t/// <c>prompt.strip()</c> whole, with no rewriting or splitting; FLAN-T5 reads the wording).\r\n\t/// </summary>\r\n\tpublic static string Caption( string prompt )\r\n\t{\r\n\t\tvar s = prompt ?? \"\";\r\n\t\tint start = 0, end = s.Length;\r\n\t\twhile ( start < end && IsPythonSpace( s[start] ) ) start++;\r\n\t\twhile ( end > start && IsPythonSpace( s[end - 1] ) ) end--;\r\n\t\treturn s[start..end];\r\n\t}\r\n\r\n\t/// <summary>Python's <c>str.isspace</c>: .NET's whitespace plus the information separators U+001C..U+001F.</summary>\r\n\tstatic bool IsPythonSpace( char c ) => char.IsWhiteSpace( c ) || c is >= '\\u001c' and <= '\\u001f';\r\n\r\n\t/// <summary>\r\n\t/// A clip name from a prompt: the prompt itself on one line, capitalised, without a leading \"An object\"; only a\r\n\t/// very long one is shortened, at a word boundary.\r\n\t/// </summary>\r\n\tpublic static string ClipName( string prompt )\r\n\t{\r\n\t\tvar words = Caption( prompt ).Split( (char[])null, System.StringSplitOptions.RemoveEmptyEntries );\r\n\t\t// UniMate's captions all open with \"An object\" (its README's advice for prompts): the name is what it does\r\n\t\tif ( words.Length > 2 && words[0].Equals( \"an\", System.StringComparison.OrdinalIgnoreCase ) && words[1].Equals( \"object\", System.StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\twords = words[2..];\r\n\t\tvar text = string.Join( ' ', words ).TrimEnd( '.', '!', '?' ).Trim();\r\n\t\tconst int MaxLength = 48;\r\n\t\tif ( text.Length > MaxLength )\r\n\t\t{\r\n\t\t\tvar space = text.LastIndexOf( ' ', MaxLength );\r\n\t\t\tvar cut = space > 0 ? space : MaxLength;\r\n\t\t\tif ( char.IsHighSurrogate( text[cut - 1] ) ) cut--; // never half a character (emoji)\r\n\t\t\ttext = text[..cut].Trim();\r\n\t\t}\r\n\t\tif ( text.Length == 0 ) return \"Generated\";\r\n\t\treturn char.ToUpperInvariant( text[0] ) + text[1..];\r\n\t}\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Editor/TextToAnimation/Inference/UniMate/UniMateSkin.cs",
"FileName": "UniMateSkin.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Runtime.CompilerServices;\r\nusing TextToAnimation.Rig;\r\n\r\nnamespace TextToAnimation.Editor.Inference.UniMate;\r\n\r\n/// <summary>\r\n/// Skin weights per bone for a skeleton, which upstream's skeleton preparation prunes on (\"skinned bones are never\r\n/// prunable\"). The engine doesn't expose them; the editor reads them from the model's source FBX and attaches them\r\n/// here when it opens the model. Unknown (nothing attached) means every bone counts as skinned.\r\n/// </summary>\r\npublic static class UniMateSkin\r\n{\r\n\tsealed class Entry\r\n\t{\r\n\t\tpublic IReadOnlyDictionary<string, (double Max, double Sum)> Weights;\r\n\t\tpublic string ObjectType;\r\n\t\tpublic IReadOnlySet<string> Driven;\r\n\t}\r\n\r\n\tstatic readonly ConditionalWeakTable<Skeleton, Entry> _entries = new();\r\n\r\n\t/// <summary>Attaches per-bone (largest single, total) skin weights, and the model's name (upstream's object type).</summary>\r\n\t/// <param name=\"driven\">Bones the model's own constraints drive at runtime (the engine poses them, not clips).</param>\r\n\tpublic static void Attach( Skeleton skeleton, IReadOnlyDictionary<string, (double Max, double Sum)> weights, string objectType,\r\n\t\tIReadOnlySet<string> driven = null )\r\n\t{\r\n\t\tif ( skeleton is null ) return;\r\n\t\t_entries.Remove( skeleton );\r\n\t\t_entries.Add( skeleton, new Entry { Weights = weights, ObjectType = objectType ?? \"\", Driven = driven } );\r\n\t}\r\n\r\n\t/// <summary>The bones the model's constraints drive (empty when unknown).</summary>\r\n\tpublic static IReadOnlySet<string> DrivenOf( Skeleton skeleton )\r\n\t\t=> skeleton is not null && _entries.TryGetValue( skeleton, out var e ) && e.Driven is not null ? e.Driven : new HashSet<string>();\r\n\r\n\t/// <summary>The weights attached to <paramref name=\"skeleton\"/>, or null when unknown.</summary>\r\n\tpublic static IReadOnlyDictionary<string, (double Max, double Sum)> WeightsOf( Skeleton skeleton )\r\n\t\t=> skeleton is not null && _entries.TryGetValue( skeleton, out var e ) ? e.Weights : null;\r\n\r\n\t/// <summary>The model name attached to <paramref name=\"skeleton\"/> (\"\" when none).</summary>\r\n\tpublic static string ObjectTypeOf( Skeleton skeleton )\r\n\t\t=> skeleton is not null && _entries.TryGetValue( skeleton, out var e ) ? e.ObjectType : \"\";\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Editor/TextToAnimation/Mapping/MappingResult.cs",
"FileName": "MappingResult.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "// Vendored from humanoid-retargeter (Code/HumanoidRetargeter/Mapping/MappingResult.cs).\r\n#nullable enable annotations\r\n\r\nusing System.Collections.Generic;\r\n\r\nnamespace TextToAnimation.Editor.Mapping;\r\n\r\n/// <summary>How a mapping was produced; determines UI flow and preset-learning behavior.</summary>\r\npublic enum MappingSource\r\n{\r\n /// <summary>A shipped preset profile matched (mixamo, actorcore_cc, ...).</summary>\r\n Preset,\r\n\r\n /// <summary>A user-saved preset (keyed by <see cref=\"SkeletonSignature\"/>) matched.</summary>\r\n UserPreset,\r\n\r\n /// <summary>The auto-mapper resolved roles from bone-name tokens (stage A).</summary>\r\n AutoName,\r\n\r\n /// <summary>The auto-mapper fell back to pure hierarchy/geometry matching (stage B).</summary>\r\n AutoTopology,\r\n\r\n /// <summary>The user assigned roles by hand in the mapping editor.</summary>\r\n Manual,\r\n\r\n /// <summary>\r\n /// The source file itself declares the mapping \u2014 e.g. a VRM's\r\n /// <c>humanoid.humanBones</c> block (both the 0.x <c>extensions.VRM</c> and the 1.0\r\n /// <c>extensions.VRMC_vrm</c> layouts). Authoritative: consulted before user presets and\r\n /// shipped-profile detection, at confidence 1.0.\r\n /// </summary>\r\n Authored,\r\n}\r\n\r\n/// <summary>\r\n/// The outcome of mapping a source skeleton onto canonical <see cref=\"BoneRole\"/>s:\r\n/// role \u2192 source bone index, an overall confidence, and human-readable notes\r\n/// (unmapped roles, ignored bones, ambiguities) for the mapping report.\r\n/// </summary>\r\npublic sealed class MappingResult\r\n{\r\n /// <summary>Creates an empty result; callers fill <see cref=\"RoleToBone\"/> and\r\n /// <see cref=\"Confidence\"/>.</summary>\r\n public MappingResult(string profileName, MappingSource source)\r\n {\r\n ProfileName = profileName;\r\n Source = source;\r\n }\r\n\r\n /// <summary>Resolved roles: canonical role \u2192 bone index in the source skeleton.</summary>\r\n public Dictionary<BoneRole, int> RoleToBone { get; } = new();\r\n\r\n /// <summary>Mapping confidence in [0, 1]; 1 means every required and optional role\r\n /// resolved unambiguously.</summary>\r\n public float Confidence { get; set; }\r\n\r\n /// <summary>Profile that produced the mapping \u2014 a preset name, or <c>\"auto\"</c> /\r\n /// <c>\"topology\"</c> for the auto-mapper stages.</summary>\r\n public string ProfileName { get; }\r\n\r\n /// <summary>Where the mapping came from.</summary>\r\n public MappingSource Source { get; }\r\n\r\n /// <summary>Report notes: unmapped roles, ignored source bones, ambiguities.</summary>\r\n public List<string> Notes { get; } = new();\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Editor/TextToAnimation/UI/AnimationViewport.cs",
"FileName": "AnimationViewport.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Editor;\r\nusing Sandbox;\r\nusing TextToAnimation.Animation;\r\nusing TextToAnimation.Editor.Engine;\r\nusing TextToAnimation.Editor.Session;\r\nusing TextToAnimation.Maths;\r\nusing TextToAnimation.Processing;\r\n\r\nnamespace TextToAnimation.Editor.UI;\r\n\r\n/// <summary>\r\n/// The live model view: the workspace model posed with the active clip at the playhead, the bone overlay\r\n/// (blue = selected, amber = locked for regeneration), the root trajectory and the floor. Left click picks\r\n/// bones and drags the rotate handle of the selected bone (an undoable key on the current frame); right drag\r\n/// orbits, middle drag pans, the wheel zooms, double click frames the character.\r\n/// </summary>\r\npublic sealed class AnimationViewport : SceneRenderingWidget\r\n{\r\n\treadonly EditorSession _session;\r\n\tCameraComponent _camera;\r\n\tSceneModel _sceneModel;\r\n\tModel _model;\r\n\tint[] _boneMap = Array.Empty<int>();\r\n\tVector3[] _bindScale = Array.Empty<Vector3>();\r\n\tXForm[] _world = Array.Empty<XForm>();\r\n\tXForm[] _pose = Array.Empty<XForm>();\r\n\tbool _dragging;\r\n\r\n\tfloat _yaw = 35f, _pitch = 12f, _distance = 140f;\r\n\tVector3 _target = new( 0, 0, 36 );\r\n\tVector3 _pan;\r\n\tVector2 _lastMouse;\r\n\r\n\tpublic bool ShowSkeleton { get; set; } = true;\r\n\tpublic bool ShowTrajectory { get; set; } = true;\r\n\tpublic bool ShowGround { get; set; } = true;\r\n\tpublic bool ShowModel { get; set; } = true;\r\n\tpublic bool FollowCharacter { get; set; } = true;\r\n\r\n\t/// <summary>\r\n\t/// When set, the engine plays this compiled sequence of the model itself (no bone overrides) at the session\r\n\t/// playhead - the ground truth of what a saved animation looks like in game.\r\n\t/// </summary>\r\n\tpublic string EngineSequence { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// Bones the model's own constraints drive at runtime (from its vmdl and prefabs). For a clip whose compiled\r\n\t/// sequence is current, the view takes these from the engine itself, so the preview shows the game's result.\r\n\t/// </summary>\r\n\tpublic IReadOnlySet<string> ConstraintDriven { get; set; } = new HashSet<string>();\r\n\r\n\t/// <summary>The compiled sequence holding the active clip unchanged (set by the window), or null.</summary>\r\n\tpublic string SavedSequence { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// The preview's constraint-driven bones against the engine playing <paramref name=\"sequence\"/> at the playhead,\r\n\t/// both relative to <paramref name=\"anchor\"/> (the game may extract root motion): the largest position difference.\r\n\t/// </summary>\r\n\tpublic float PreviewVsGameOnDrivenBones( string sequence, string anchor )\r\n\t{\r\n\t\tvar rig = _session.Rig;\r\n\t\tif ( rig is null || !_sceneModel.IsValid() || _model is null ) return float.NaN;\r\n\t\tvar s = rig.Skeleton;\r\n\t\tvar a = s.IndexOf( anchor );\r\n\t\tif ( a < 0 || _boneMap[a] < 0 || _session.ActiveFrames is not { Count: > 0 } frames ) return float.NaN;\r\n\t\t// the pose at the current playhead (the paint tick may not have run since the last seek)\r\n\t\tSamplePose( frames, _session.Playhead, _pose );\r\n\t\tFkUtil.ToWorld( _pose, s, _world );\r\n\t\tApplyPose();\r\n\t\tvar preview = Enumerable.Range( 0, s.Count ).Select( b => _boneMap[b] >= 0 ? _sceneModel.GetBoneWorldTransform( _boneMap[b] ) : default ).ToArray();\r\n\t\tvar saved = EngineSequence;\r\n\t\tEngineSequence = sequence;\r\n\t\tApplyPose();\r\n\t\tvar game = Enumerable.Range( 0, s.Count ).Select( b => _boneMap[b] >= 0 ? _sceneModel.GetBoneWorldTransform( _boneMap[b] ) : default ).ToArray();\r\n\t\tEngineSequence = saved;\r\n\t\tApplyPose();\r\n\t\tvar worst = 0f; var worstOther = 0f; string worstBone = \"\", worstOtherBone = \"\";\r\n\t\tfor ( var b = 0; b < s.Count; b++ )\r\n\t\t{\r\n\t\t\tif ( _boneMap[b] < 0 ) continue;\r\n\t\t\tvar d = (preview[a].ToLocal( preview[b] ).Position - game[a].ToLocal( game[b] ).Position).Length;\r\n\t\t\tif ( ConstraintDriven.Contains( s[b].Name ) ) { if ( d > worst ) { worst = d; worstBone = s[b].Name; } }\r\n\t\t\telse if ( d > worstOther ) { worstOther = d; worstOtherBone = s[b].Name; }\r\n\t\t}\r\n\t\tLastDrivenReport = $\"worst {worstBone}; other bones max {worstOther:0.000} in ({worstOtherBone})\";\r\n\t\treturn worst;\r\n\t}\r\n\r\n\t/// <summary>Which bones the last <see cref=\"PreviewVsGameOnDrivenBones\"/> found furthest apart.</summary>\r\n\tpublic string LastDrivenReport { get; private set; } = \"\";\r\n\r\n\t/// <summary>The sequence the active clip was saved as, when it hasn't changed since (its compiled copy is current).</summary>\r\n\tstring CurrentSavedSequence()\r\n\t{\r\n\t\tvar clip = _session.ActiveClip;\r\n\t\treturn clip?.SavedUtc is not null && ClipListPanel.SavedRevisions.TryGetValue( clip.Id, out var revision ) && revision == clip.Revision\r\n\t\t\t? clip.EffectiveSequenceName : null;\r\n\t}\r\n\r\n\t/// <summary>True when the last pose took the constraint-driven bones from the engine.</summary>\r\n\tpublic bool UsedEngineConstraints { get; private set; }\r\n\r\n\tpublic AnimationViewport( Widget parent, EditorSession session ) : base( parent )\r\n\t{\r\n\t\t_session = session;\r\n\t\tMinimumSize = new Vector2( 320, 260 );\r\n\t\tMouseTracking = true;\r\n\t\tFocusMode = FocusMode.Click;\r\n\r\n\t\tScene = Scene.CreateEditorScene();\r\n\t\tusing ( Scene.Push() )\r\n\t\t{\r\n\t\t\t_camera = new GameObject( true, \"camera\" ).GetOrAddComponent<CameraComponent>( false );\r\n\t\t\t_camera.BackgroundColor = Theme.ControlBackground;\r\n\t\t\t_camera.ZNear = 1f;\r\n\t\t\t_camera.ZFar = 20000f;\r\n\t\t\t_camera.FieldOfView = 45f;\r\n\t\t\t_camera.Enabled = true;\r\n\t\t}\r\n\t\tCamera = _camera;\r\n\t\tvar world = Scene.SceneWorld;\r\n\t\tnew ScenePointLight( world, new Vector3( 120, 100, 160 ), 900, Color.White * 3.0f ).ShadowsEnabled = false;\r\n\t\tnew ScenePointLight( world, new Vector3( -140, -100, 110 ), 900, Color.White * 1.6f ).ShadowsEnabled = false;\r\n\t\tnew ScenePointLight( world, new Vector3( 0, 160, 60 ), 700, Color.White * 0.9f ).ShadowsEnabled = false;\r\n\t}\r\n\r\n\t/// <summary>Loads the workspace model into the view.</summary>\r\n\t/// <summary>\r\n\t/// Shows <paramref name=\"model\"/>. Always rebuilds the scene model and the bone map: a save recompiles the\r\n\t/// model in place (the same Model object) and the compiler may reorder its bones, so a cached map would pose\r\n\t/// the wrong bones. The camera is only reframed for a different model.\r\n\t/// </summary>\r\n\tpublic void SetModel( Model model )\r\n\t{\r\n\t\tvar reframe = model?.ResourcePath != _model?.ResourcePath;\r\n\t\t_model = model;\r\n\t\t_sceneModel?.Delete();\r\n\t\t_sceneModel = null;\r\n\t\tDeleteEngineModel();\r\n\t\tif ( model is null || model.IsError || _session.Rig is null ) return;\r\n\t\tDeleteEngineModel();\r\n\t\t_sceneModel = new SceneModel( Scene.SceneWorld, model, Transform.Zero ) { UseAnimGraph = false };\r\n\t\tvar skeleton = _session.Rig.Skeleton;\r\n\t\t_boneMap = skeleton.Bones.Select( b => model.Bones.GetBone( b.Name )?.Index ?? -1 ).ToArray();\r\n\t\t_bindScale = skeleton.Bones.Select( b => model.Bones.GetBone( b.Name )?.LocalTransform.Scale ?? Vector3.One ).ToArray();\r\n\t\t_world = new XForm[skeleton.Count];\r\n\t\t_pose = new XForm[skeleton.Count];\r\n\t\tif ( reframe ) FrameCharacter();\r\n\t}\r\n\r\n\t/// <summary>Frames the whole character (double click does the same).</summary>\r\n\tpublic void FrameCharacter()\r\n\t{\r\n\t\tvar rig = _session.Rig;\r\n\t\tif ( _model is not null && !_model.IsError && _model.Bounds.Size.Length > 1f )\r\n\t\t{\r\n\t\t\t// any creature: frame its whole body (a fox is long and low, a person tall and narrow)\r\n\t\t\tvar size = _model.Bounds.Size;\r\n\t\t\tvar extent = MathF.Max( size.z, MathF.Max( size.x, size.y ) * 0.75f );\r\n\t\t\t_distance = MathF.Max( extent * 1.9f, 30f );\r\n\t\t\t_target = new Vector3( 0, 0, size.z * 0.5f );\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tvar height = rig is null ? 72f : MathF.Max( rig.HipHeight * 2.1f, 20f );\r\n\t\t\t_distance = height * 1.9f;\r\n\t\t\t_target = new Vector3( 0, 0, height * 0.5f );\r\n\t\t}\r\n\t\t_pan = Vector3.Zero;\r\n\t\t_followCenter = null;\r\n\t}\r\n\r\n\tpublic void SetView( float yaw, float pitch ) { _yaw = yaw; _pitch = pitch; }\r\n\r\n\tprotected override void PreFrame()\r\n\t{\r\n\t\tvar started = FrameProbe.Now;\r\n\t\ttry { ViewFrame(); }\r\n\t\tfinally { FrameProbe.Add( \"viewport\", FrameProbe.Now - started ); }\r\n\t}\r\n\r\n\tvoid ViewFrame()\r\n\t{\r\n\t\tScene.EditorTick( RealTime.Now, RealTime.Delta );\r\n\t\tGizmoInstance.Input.IsHovered = IsActiveWindow && IsUnderMouse;\r\n\t\tUpdateGizmoInputs( GizmoInstance.Input.IsHovered );\r\n\r\n\t\tvar frames = _session.ActiveFrames;\r\n\t\tvar rig = _session.Rig;\r\n\t\tif ( rig is null || frames is null || frames.Count == 0 )\r\n\t\t{\r\n\t\t\t_gizmoHovered = false;\r\n\t\t\tUpdateCamera( _target );\r\n\t\t\tif ( ShowGround ) DrawGround( Vector3.Zero, 0f, 40f );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tSamplePose( frames, _session.Playhead, _pose );\r\n\t\tFkUtil.ToWorld( _pose, rig.Skeleton, _world );\r\n\t\tApplyPose();\r\n\r\n\t\tvar hips = rig.HipsIndex >= 0 ? rig.HipsIndex : rig.RootIndex;\r\n\t\tvar hipsPos = ToVec( _world[hips].Pos );\r\n\t\tvar groundZ = GroundHeight( rig );\r\n\t\tUpdateCamera( FollowCharacter ? new Vector3( hipsPos.x, hipsPos.y, _target.z ) : _target );\r\n\r\n\t\tif ( ShowGround ) DrawGround( hipsPos, groundZ, MathF.Max( rig.HipHeight * 2f, 30f ) );\r\n\t\tif ( ShowTrajectory ) DrawTrajectory( frames, rig, groundZ );\r\n\t\tif ( ShowSkeleton || !ShowModel ) DrawSkeleton( rig );\r\n\t\tDrawBonePicking( rig );\r\n\t\tDrawPoseGizmo( rig );\r\n\t\t_gizmoHovered = Gizmo.HasHovered;\r\n\t}\r\n\r\n\t// whether a bone dot or pose handle is under the mouse, as of the last drawn frame: Gizmo.HasHovered only works\r\n\t// while the gizmos are being drawn, not in mouse events\r\n\tbool _gizmoHovered;\r\n\r\n\tstatic Vector3 ToVec( System.Numerics.Vector3 v ) => new( v.X, v.Y, v.Z );\r\n\r\n\tstatic void SamplePose( List<XForm[]> frames, float playhead, XForm[] into )\r\n\t{\r\n\t\tvar a = Math.Clamp( (int)MathF.Floor( playhead ), 0, frames.Count - 1 );\r\n\t\tvar b = Math.Min( a + 1, frames.Count - 1 );\r\n\t\tvar t = playhead - a;\r\n\t\tvar fa = frames[a]; var fb = frames[b];\r\n\t\tvar n = Math.Min( into.Length, fa.Length );\r\n\t\tfor ( var i = 0; i < n; i++ )\r\n\t\t\tinto[i] = t <= 1e-4f ? fa[i] : new XForm( System.Numerics.Vector3.Lerp( fa[i].Pos, fb[i].Pos, t ),\r\n\t\t\t\tSystem.Numerics.Quaternion.Normalize( System.Numerics.Quaternion.Slerp( fa[i].Rot, fb[i].Rot, t ) ) );\r\n\t}\r\n\r\n\tSceneWorld _engineWorld;\r\n\tSceneModel _engineModel;\r\n\tstring _engineModelSequence;\r\n\r\n\t/// <summary>\r\n\t/// The engine's own pose of compiled <paramref name=\"sequence\"/> at <paramref name=\"seconds\"/> (its animation and\r\n\t/// the model's constraints), on a hidden model that only ever plays that sequence: switching sequences on a model\r\n\t/// crossfades from the previous one, which an editor (that never advances time) would never finish.\r\n\t/// </summary>\r\n\tSceneModel EngineModelAt( string sequence, float seconds )\r\n\t{\r\n\t\t// recreated until it really plays the sequence (one made while a save was recompiling the model has none)\r\n\t\tif ( !_engineModel.IsValid() || _engineModelSequence != sequence || _engineModel.CurrentSequence.Name != sequence )\r\n\t\t{\r\n\t\t\tDeleteEngineModel();\r\n\t\t\t_engineWorld = new SceneWorld();\r\n\t\t\t// the compiled model as it is now (a save recompiles it; the view's instance can lag behind)\r\n\t\t\tvar compiled = Model.Load( _model.ResourcePath );\r\n\t\t\t_engineModel = new SceneModel( _engineWorld, compiled is { IsError: false } ? compiled : _model, Transform.Zero ) { UseAnimGraph = false };\r\n\t\t\t_engineModel.CurrentSequence.Name = sequence;\r\n\t\t\t_engineModel.Update( 0f );\r\n\t\t\t_engineModelSequence = sequence;\r\n\t\t}\r\n\t\t_engineModel.CurrentSequence.Time = seconds;\r\n\t\t_engineModel.Update( 0f );\r\n\t\treturn _engineModel;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Every bone's world transform on an engine-played model, chained from the parent-space bones that\r\n\t/// <see cref=\"SceneModel.Update\"/> evaluates (the world transforms it reports refresh only when the scene renders).\r\n\t/// </summary>\r\n\tTransform[] EngineWorld( SceneModel engine )\r\n\t{\r\n\t\tvar count = _model.BoneCount;\r\n\t\tvar world = new Transform[count];\r\n\t\tvar state = new byte[count]; // 0 todo, 1 in progress (a parent loop cuts there), 2 done\r\n\t\tTransform Of( int b )\r\n\t\t{\r\n\t\t\tif ( state[b] == 2 ) return world[b];\r\n\t\t\tvar local = engine.GetParentSpaceBone( b );\r\n\t\t\tvar parent = _model.GetBoneParent( b );\r\n\t\t\tstate[b] = 1;\r\n\t\t\tworld[b] = parent < 0 || parent >= count || state[parent] == 1 ? local : Of( parent ).ToWorld( local );\r\n\t\t\tstate[b] = 2;\r\n\t\t\treturn world[b];\r\n\t\t}\r\n\t\tfor ( var b = 0; b < count; b++ ) Of( b );\r\n\t\treturn world;\r\n\t}\r\n\r\n\tvoid DeleteEngineModel()\r\n\t{\r\n\t\tif ( _engineModel.IsValid() ) _engineModel.Delete();\r\n\t\t_engineWorld?.Delete();\r\n\t\t_engineModel = null;\r\n\t\t_engineWorld = null;\r\n\t\t_engineModelSequence = null;\r\n\t}\r\n\r\n\tvoid ApplyPose()\r\n\t{\r\n\t\tif ( !_sceneModel.IsValid() ) return;\r\n\t\t_sceneModel.RenderingEnabled = ShowModel;\r\n\t\tif ( !string.IsNullOrEmpty( EngineSequence ) )\r\n\t\t{\r\n\t\t\tvar engine = EngineWorld( EngineModelAt( EngineSequence, _session.Playhead / (_session.ActiveClip?.Fps ?? 30f) ) );\r\n\t\t\tfor ( var b = 0; b < engine.Length; b++ ) _sceneModel.SetBoneOverride( b, engine[b] );\r\n\t\t\t_sceneModel.Update( 0f );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tvar final = new Transform[_boneMap.Length];\r\n\t\tfor ( var i = 0; i < _boneMap.Length; i++ ) final[i] = ModelBridge.ToTransform( _world[i], _bindScale[i] );\r\n\t\tUsedEngineConstraints = false;\r\n\t\tvar saved = SavedSequence ?? CurrentSavedSequence();\r\n\t\tif ( ConstraintDriven.Count > 0 && !string.IsNullOrEmpty( saved ) && _model.AnimationNames.Contains( saved ) )\r\n\t\t{\r\n\t\t\t// the engine evaluates the model's constraints only while playing a compiled sequence: play the saved one at\r\n\t\t\t// this time, take each driven bone relative to its parent, and place it on this pose (root motion kept)\r\n\t\t\tvar engine = EngineWorld( EngineModelAt( saved, _session.Playhead / (_session.ActiveClip?.Fps ?? 30f) ) );\r\n\t\t\tvar skeleton = _session.Rig.Skeleton;\r\n\t\t\tvar moved = new bool[skeleton.Count];\r\n\t\t\tfor ( var i = 0; i < skeleton.Count; i++ ) // parents come before children\r\n\t\t\t{\r\n\t\t\t\tvar p = skeleton[i].ParentIndex;\r\n\t\t\t\tif ( _boneMap[i] < 0 || p < 0 || _boneMap[p] < 0 ) continue;\r\n\t\t\t\tif ( ConstraintDriven.Contains( skeleton[i].Name ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar local = engine[_boneMap[p]].ToLocal( engine[_boneMap[i]] );\r\n\t\t\t\t\tfinal[i] = final[p].ToWorld( local );\r\n\t\t\t\t\tmoved[i] = UsedEngineConstraints = true;\r\n\t\t\t\t}\r\n\t\t\t\telse if ( moved[p] )\r\n\t\t\t\t{\r\n\t\t\t\t\t// below a driven bone: keep this pose's local transform under the engine-placed parent\r\n\t\t\t\t\tfinal[i] = final[p].ToWorld( ModelBridge.ToTransform( _world[p], _bindScale[p] ).ToLocal( ModelBridge.ToTransform( _world[i], _bindScale[i] ) ) );\r\n\t\t\t\t\tmoved[i] = true;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t\tfor ( var i = 0; i < _boneMap.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( _boneMap[i] < 0 ) continue;\r\n\t\t\t_sceneModel.SetBoneOverride( _boneMap[i], final[i] );\r\n\t\t}\r\n\t\t_sceneModel.Update( 0f ); // flush overrides now (otherwise the pose lags a frame)\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The engine's own pose while playing compiled <paramref name=\"sequence\"/> (animation + the model's constraints):\r\n\t/// world transforms of every bone and of the named attachments, at the given frames. Ground truth for the\r\n\t/// constraint evaluator.\r\n\t/// </summary>\r\n\tpublic object EnginePoses( string sequence, IEnumerable<int> frames, float fps, IReadOnlyList<string> attachments )\r\n\t{\r\n\t\tif ( _model is null || !_sceneModel.IsValid() ) return null;\r\n\t\tvar saved = EngineSequence;\r\n\t\tEngineSequence = null;\r\n\t\tvar result = new List<object>();\r\n\t\tstatic float[] P( Transform t ) => new[] { t.Position.x, t.Position.y, t.Position.z, t.Rotation.x, t.Rotation.y, t.Rotation.z, t.Rotation.w };\r\n\t\tforeach ( var f in frames )\r\n\t\t{\r\n\t\t\tvar engineModel = EngineModelAt( sequence, f / fps );\r\n\t\t\tvar engine = EngineWorld( engineModel );\r\n\t\t\t// the pose the sequence stores (before constraints), in the same model space as the engine's bones\r\n\t\t\tvar stored = new Dictionary<string, float[]>();\r\n\t\t\tif ( _session.ActiveFrames is { Count: > 0 } clipFrames && _session.Rig is { } rig )\r\n\t\t\t{\r\n\t\t\t\tvar pose = new XForm[rig.Skeleton.Count]; var world = new XForm[rig.Skeleton.Count];\r\n\t\t\t\tSamplePose( clipFrames, f, pose );\r\n\t\t\t\tFkUtil.ToWorld( pose, rig.Skeleton, world );\r\n\t\t\t\tfor ( var i = 0; i < rig.Skeleton.Count; i++ )\r\n\t\t\t\t\tif ( _boneMap[i] >= 0 ) stored[rig.Skeleton[i].Name] = P( ModelBridge.ToTransform( world[i], _bindScale[i] ) );\r\n\t\t\t}\r\n\t\t\tresult.Add( new\r\n\t\t\t{\r\n\t\t\t\tframe = f,\r\n\t\t\t\tstored,\r\n\t\t\t\tbones = Enumerable.Range( 0, _model.BoneCount ).ToDictionary( b => _model.GetBoneName( b ), b => P( engine[b] ) ),\r\n\t\t\t\tattachments = attachments.Select( a => (a, t: (object)engineModel.GetAttachment( a, true )) ).Where( x => x.t is Transform ).ToDictionary( x => x.a, x => P( (Transform)x.t ) ),\r\n\t\t\t} );\r\n\t\t}\r\n\t\tEngineSequence = saved;\r\n\t\treturn result;\r\n\t}\r\n\r\n\t/// <summary>The floor the model stands on: z = 0 for normal models, or its lowest rest point when that is below.</summary>\r\n\tstatic float GroundHeight( MotionRig rig )\r\n\t{\r\n\t\tvar lowest = float.MaxValue;\r\n\t\tfor ( var i = 0; i < rig.Skeleton.Count; i++ ) lowest = MathF.Min( lowest, rig.Skeleton.RestWorld[i].Pos.Z );\r\n\t\treturn lowest < 0f ? lowest : 0f;\r\n\t}\r\n\r\n\t// ---------------------------------------------------------------- camera\r\n\r\n\tVector3? _followCenter;\r\n\r\n\tvoid UpdateCamera( Vector3 goal )\r\n\t{\r\n\t\t_followCenter = _followCenter is { } c ? Vector3.Lerp( c, goal, 1f - MathF.Exp( -RealTime.Delta / 0.3f ) ) : goal;\r\n\t\tvar center = _followCenter.Value + _pan;\r\n\t\tvar yaw = MathX.DegreeToRadian( _yaw );\r\n\t\tvar pitch = MathX.DegreeToRadian( _pitch );\r\n\t\tvar dir = new Vector3( MathF.Cos( yaw ) * MathF.Cos( pitch ), MathF.Sin( yaw ) * MathF.Cos( pitch ), MathF.Sin( pitch ) ).Normal;\r\n\t\t_camera.WorldPosition = center + dir * _distance;\r\n\t\t_camera.WorldRotation = Rotation.LookAt( -dir, Vector3.Up );\r\n\t}\r\n\r\n\tprotected override void OnMousePress( MouseEvent e )\r\n\t{\r\n\t\tbase.OnMousePress( e );\r\n\t\t_lastMouse = e.LocalPosition;\r\n\t\t// left-drag on empty space looks around (on a bone or a gizmo handle it picks or poses instead)\r\n\t\t_orbiting = e.LeftMouseButton && !_gizmoHovered;\r\n\t}\r\n\r\n\tprotected override void OnMouseReleased( MouseEvent e )\r\n\t{\r\n\t\tbase.OnMouseReleased( e );\r\n\t\tif ( e.LeftMouseButton ) _orbiting = false;\r\n\t}\r\n\r\n\tbool _orbiting;\r\n\r\n\tprotected override void OnMouseMove( MouseEvent e )\r\n\t{\r\n\t\tbase.OnMouseMove( e );\r\n\t\tvar delta = e.LocalPosition - _lastMouse;\r\n\t\t_lastMouse = e.LocalPosition;\r\n\t\tif ( (e.ButtonState & MouseButtons.Right) != 0 || (_orbiting && (e.ButtonState & MouseButtons.Left) != 0) )\r\n\t\t{\r\n\t\t\t_yaw -= delta.x * 0.4f;\r\n\t\t\t_pitch = Math.Clamp( _pitch + delta.y * 0.3f, -15f, 85f );\r\n\t\t}\r\n\t\telse if ( (e.ButtonState & MouseButtons.Middle) != 0 )\r\n\t\t{\r\n\t\t\tvar scale = _distance * 0.0016f;\r\n\t\t\t_pan += (_camera.WorldRotation.Left * delta.x + _camera.WorldRotation.Up * delta.y) * scale;\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnWheel( WheelEvent e )\r\n\t{\r\n\t\tbase.OnWheel( e );\r\n\t\t_distance = Math.Clamp( _distance * (e.Delta > 0 ? 0.9f : 1.1f), 10f, 4000f );\r\n\t\te.Accept();\r\n\t}\r\n\r\n\tprotected override void OnDoubleClick( MouseEvent e )\r\n\t{\r\n\t\tbase.OnDoubleClick( e );\r\n\t\tif ( e.LeftMouseButton && !_gizmoHovered ) FrameCharacter();\r\n\t}\r\n\r\n\t// ---------------------------------------------------------------- overlays\r\n\r\n\tvoid DrawGround( Vector3 center, float z, float size )\r\n\t{\r\n\t\tusing var scope = Gizmo.Scope( \"t2a-ground\" );\r\n\t\tGizmo.Transform = Transform.Zero;\r\n\t\tGizmo.Draw.IgnoreDepth = false;\r\n\t\tGizmo.Draw.LineThickness = 1f;\r\n\t\tvar step = MathF.Max( size / 6f, 2f );\r\n\t\tconst int cells = 12;\r\n\t\tvar block = step * 4f;\r\n\t\tvar ox = MathF.Round( center.x / block ) * block;\r\n\t\tvar oy = MathF.Round( center.y / block ) * block;\r\n\t\tvar extent = cells * step;\r\n\t\tfor ( var i = -cells; i <= cells; i++ )\r\n\t\t{\r\n\t\t\tvar fade = 1f - MathF.Abs( i ) / (cells + 1f);\r\n\t\t\tGizmo.Draw.Color = Color.White.WithAlpha( (i == 0 ? .14f : .06f) * fade + .012f );\r\n\t\t\tvar o = i * step;\r\n\t\t\tGizmo.Draw.Line( new Vector3( ox + o, oy - extent, z ), new Vector3( ox + o, oy + extent, z ) );\r\n\t\t\tGizmo.Draw.Line( new Vector3( ox - extent, oy + o, z ), new Vector3( ox + extent, oy + o, z ) );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid DrawTrajectory( List<XForm[]> frames, MotionRig rig, float z )\r\n\t{\r\n\t\tvar path = RootTools.HipsTrajectory( frames, rig );\r\n\t\tusing var scope = Gizmo.Scope( \"t2a-trajectory\" );\r\n\t\tGizmo.Transform = Transform.Zero;\r\n\t\tGizmo.Draw.IgnoreDepth = true;\r\n\t\tGizmo.Draw.LineThickness = 2f;\r\n\t\tvar accent = TaStyle.Accent;\r\n\t\tvar current = _session.CurrentFrame;\r\n\t\tfor ( var f = 1; f < path.Length; f++ )\r\n\t\t{\r\n\t\t\tvar past = f <= current;\r\n\t\t\tGizmo.Draw.Color = accent.WithAlpha( past ? .9f : .35f );\r\n\t\t\tGizmo.Draw.Line( new Vector3( path[f - 1].X, path[f - 1].Y, z + .2f ), new Vector3( path[f].X, path[f].Y, z + .2f ) );\r\n\t\t}\r\n\t\t// heading ticks every half second\r\n\t\tvar every = Math.Max( 1, (int)MathF.Round( (_session.ActiveClip?.Fps ?? 30f) * 0.5f ) );\r\n\t\tGizmo.Draw.LineThickness = 1.5f;\r\n\t\tfor ( var f = 0; f < frames.Count; f += every )\r\n\t\t{\r\n\t\t\tvar heading = RootTools.RootHeading( frames, rig, f );\r\n\t\t\tvar dir = System.Numerics.Vector3.Transform( rig.Forward, RootTools.Yaw( rig, heading ) );\r\n\t\t\tvar p = new Vector3( path[f].X, path[f].Y, z + .2f );\r\n\t\t\tGizmo.Draw.Color = Color.White.WithAlpha( .35f );\r\n\t\t\tGizmo.Draw.Line( p, p + new Vector3( dir.X, dir.Y, 0 ) * rig.Cm( 12f ) );\r\n\t\t}\r\n\t\t// pinned frames (in-between constraints)\r\n\t\tif ( _session.ActiveClip is { } clip )\r\n\t\t{\r\n\t\t\tGizmo.Draw.Color = Theme.Yellow.WithAlpha( .9f );\r\n\t\t\tforeach ( var pin in clip.PinnedFrames.Where( p => p < path.Length ) )\r\n\t\t\t\tGizmo.Draw.LineCircle( new Vector3( path[pin].X, path[pin].Y, z + .2f ), Vector3.Up, rig.Cm( 6f ), 0, 360, 4 );\r\n\t\t}\r\n\t\tvar at = path[Math.Clamp( current, 0, path.Length - 1 )];\r\n\t\tGizmo.Draw.Color = accent;\r\n\t\tGizmo.Draw.LineCircle( new Vector3( at.X, at.Y, z + .2f ), Vector3.Up, rig.Cm( 10f ), 0, 360, 24 );\r\n\t}\r\n\r\n\tvoid DrawSkeleton( MotionRig rig )\r\n\t{\r\n\t\tvar clip = _session.ActiveClip;\r\n\t\tusing var scope = Gizmo.Scope( \"t2a-skeleton\" );\r\n\t\tGizmo.Transform = Transform.Zero;\r\n\t\tGizmo.Draw.IgnoreDepth = true;\r\n\t\tGizmo.Draw.LineThickness = 2f;\r\n\t\tfor ( var i = 0; i < rig.Skeleton.Count; i++ )\r\n\t\t{\r\n\t\t\tvar parent = rig.Skeleton[i].ParentIndex;\r\n\t\t\tif ( parent < 0 || !rig.IsMotionBone( i ) ) continue;\r\n\t\t\tvar locked = clip?.LockedBones.Contains( rig.Skeleton[i].Name ) == true;\r\n\t\t\tvar selected = _session.SelectedBones.Contains( i );\r\n\t\t\tGizmo.Draw.Color = selected ? TaStyle.AccentLight : locked ? Theme.Yellow.WithAlpha( .95f ) : TaStyle.Accent.WithAlpha( .55f );\r\n\t\t\tGizmo.Draw.Line( ToVec( _world[parent].Pos ), ToVec( _world[i].Pos ) );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid DrawBonePicking( MotionRig rig )\r\n\t{\r\n\t\tvar clip = _session.ActiveClip;\r\n\t\tvar radius = MathF.Max( rig.Cm( 1.6f ), 0.45f );\r\n\t\tvar clicked = false;\r\n\t\tfor ( var i = 0; i < rig.Skeleton.Count; i++ )\r\n\t\t{\r\n\t\t\tif ( !rig.IsMotionBone( i ) ) continue;\r\n\t\t\tusing ( Gizmo.Scope( $\"t2a-bone/{i}\", new Transform( ToVec( _world[i].Pos ) ) ) )\r\n\t\t\t{\r\n\t\t\t\tGizmo.Hitbox.DepthBias = 0.01f;\r\n\t\t\t\tGizmo.Hitbox.Sphere( new Sphere( 0, radius ) );\r\n\t\t\t\tGizmo.Hitbox.TrySetHovered( 0f );\r\n\t\t\t\tvar selected = _session.SelectedBones.Contains( i );\r\n\t\t\t\tvar locked = clip?.LockedBones.Contains( rig.Skeleton[i].Name ) == true;\r\n\t\t\t\tif ( ShowSkeleton || selected || Gizmo.IsHovered )\r\n\t\t\t\t{\r\n\t\t\t\t\tGizmo.Draw.IgnoreDepth = true;\r\n\t\t\t\t\tGizmo.Draw.Color = selected ? TaStyle.AccentLight : Gizmo.IsHovered ? Color.White : locked ? Theme.Yellow : TaStyle.Accent.WithAlpha( .7f );\r\n\t\t\t\t\tGizmo.Draw.LineSphere( new Sphere( 0, radius ), 6 );\r\n\t\t\t\t}\r\n\t\t\t\tif ( Gizmo.IsHovered && Gizmo.WasLeftMousePressed && !clicked )\r\n\t\t\t\t{\r\n\t\t\t\t\tclicked = true;\r\n\t\t\t\t\tvar additive = global::Editor.Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Ctrl ) || global::Editor.Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Shift );\r\n\t\t\t\t\t_session.SelectBone( i, additive );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t\t// click on empty space clears the selection (but not while a handle is used)\r\n\t\tif ( !clicked && Gizmo.WasLeftMousePressed && !Gizmo.HasHovered && !Gizmo.Pressed.Any && IsUnderMouse )\r\n\t\t\t_session.SelectBone( null );\r\n\t}\r\n\r\n\tvoid DrawPoseGizmo( MotionRig rig )\r\n\t{\r\n\t\tif ( _session.PrimaryBone is not int bone || _session.ActiveClip is not { } clip || _session.Busy )\r\n\t\t{\r\n\t\t\tEndDrag();\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tvar name = rig.Skeleton[bone].Name;\r\n\t\tvar parent = rig.Skeleton[bone].ParentIndex;\r\n\t\tvar world = _world[bone];\r\n\t\tusing ( Gizmo.Scope( $\"t2a-pose/{bone}\", new Transform( ToVec( world.Pos ), new Rotation( world.Rot.X, world.Rot.Y, world.Rot.Z, world.Rot.W ) ) ) )\r\n\t\t{\r\n\t\t\tGizmo.Hitbox.DepthBias = 0.01f;\r\n\t\t\tvar moved = false;\r\n\t\t\tXForm newWorld = world;\r\n\t\t\tif ( Gizmo.Control.Rotate( \"rot\", Rotation.Identity, out var delta ) )\r\n\t\t\t{\r\n\t\t\t\tvar d = new System.Numerics.Quaternion( delta.x, delta.y, delta.z, delta.w );\r\n\t\t\t\tnewWorld = new XForm( world.Pos, MathQ.Normalize( world.Rot * d ) );\r\n\t\t\t\tmoved = true;\r\n\t\t\t}\r\n\t\t\t// the motion root and the hips can also be moved\r\n\t\t\tif ( (bone == rig.RootIndex || bone == rig.HipsIndex) && Gizmo.Control.Position( \"pos\", Vector3.Zero, out var move ) )\r\n\t\t\t{\r\n\t\t\t\tvar worldMove = new Rotation( world.Rot.X, world.Rot.Y, world.Rot.Z, world.Rot.W ) * move;\r\n\t\t\t\tnewWorld = new XForm( newWorld.Pos + new System.Numerics.Vector3( worldMove.x, worldMove.y, worldMove.z ), newWorld.Rot );\r\n\t\t\t\tmoved = true;\r\n\t\t\t}\r\n\t\t\tif ( moved )\r\n\t\t\t{\r\n\t\t\t\tif ( !_dragging )\r\n\t\t\t\t{\r\n\t\t\t\t\t_dragging = true;\r\n\t\t\t\t\t_session.BeginInteractiveEdit( $\"Pose {name}\" );\r\n\t\t\t\t}\r\n\t\t\t\tvar parentWorld = parent < 0 ? XForm.Identity : _world[parent];\r\n\t\t\t\tvar targetLocal = XForm.ToLocal( parentWorld, newWorld );\r\n\t\t\t\t_session.SetPoseKey( bone, _session.CurrentFrame, targetLocal );\r\n\t\t\t}\r\n\t\t\tif ( _dragging && !Gizmo.Pressed.Any ) EndDrag();\r\n\t\t}\r\n\t}\r\n\r\n\tvoid EndDrag()\r\n\t{\r\n\t\tif ( !_dragging ) return;\r\n\t\t_dragging = false;\r\n\t\t_session.EndInteractiveEdit();\r\n\t}\r\n\r\n\tpublic override void OnDestroyed()\r\n\t{\r\n\t\tbase.OnDestroyed();\r\n\t\t_sceneModel?.Delete();\r\n\t\tDeleteEngineModel();\r\n\t\tScene?.Destroy();\r\n\t\tScene = null;\r\n\t}\r\n\r\n\t/// <summary>Renders the view to PNG bytes (used by the test gate for visual checks).</summary>\r\n\tpublic byte[] RenderToPng( int width = 640, int height = 480 )\r\n\t{\r\n\t\tvar bitmap = new Bitmap( width, height );\r\n\t\t_camera.RenderToBitmap( bitmap );\r\n\t\treturn bitmap.ToPng();\r\n\t}\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Editor/TextToAnimation/UI/PromptComposer.cs",
"FileName": "PromptComposer.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Editor;\r\nusing Sandbox;\r\n\r\nnamespace TextToAnimation.Editor.UI;\r\n\r\n/// <summary>Which part of an existing animation a text change applies to.</summary>\r\npublic enum ChangeScope { WholeBody, UpperBody, LowerBody, Arms, SelectedBones, Unlocked }\r\n\r\n/// <summary>The settings behind the prompt. Simple users never need to touch them.</summary>\r\npublic sealed class PromptOptions\r\n{\r\n\tpublic float Seconds { get; set; } = 4f;\r\n\tpublic int Takes { get; set; } = 1;\r\n\tpublic int Steps { get; set; } = 24;\r\n\tpublic float Guidance { get; set; } = 3f;\r\n\t/// <summary>Null = a new random seed per generation.</summary>\r\n\tpublic int? Seed { get; set; }\r\n\tpublic float VariationStrength { get; set; } = 0.5f;\r\n\tpublic ChangeScope Scope { get; set; } = ChangeScope.WholeBody;\r\n\t/// <summary>Smooth the generated motion and lock planted feet (off = the model's raw output).</summary>\r\n\tpublic bool CleanUp { get; set; } = true;\r\n}\r\n\r\n/// <summary>\r\n/// The prompt row at the top of the timeline dock: what the prompt does (a chip), the text field\r\n/// (Enter sends, Shift+Enter adds a line and the field grows up to three lines), which body part a change\r\n/// applies to, one settings chip (length, takes, quality, advanced) and a send button that becomes stop while\r\n/// generating. Styled like the editor's other inputs so it reads as part of the dock.\r\n/// </summary>\r\npublic sealed class PromptComposer : Widget\r\n{\r\n\tconst float LineHeight = 17f;\r\n\tconst float SingleLine = 28f;\r\n\r\n\treadonly Layout _leading;\r\n\treadonly Field _field;\r\n\treadonly PromptBox _box;\r\n\treadonly ChipButton _scopeChip;\r\n\treadonly ChipButton _settingsChip;\r\n\treadonly SendButton _send;\r\n\tstring _target;\r\n\tstring _disabledReason;\r\n\tstring _placeholder;\r\n\tbool _busy;\r\n\tbool _showScope = true;\r\n\tbool _showLength = true;\r\n\r\n\tpublic PromptOptions Options { get; } = new();\r\n\r\n\t/// <summary>Called with the trimmed prompt when the user sends it.</summary>\r\n\tpublic Action<string> Submitted { get; set; }\r\n\t/// <summary>Called when the user presses stop while busy.</summary>\r\n\tpublic Action StopRequested { get; set; }\r\n\t/// <summary>The body parts offered by the scope chip.</summary>\r\n\tpublic List<ChangeScope> ScopeChoices { get; set; } = new() { ChangeScope.WholeBody };\r\n\t/// <summary>Allows sending an empty prompt (variations, in-betweens).</summary>\r\n\tpublic bool AllowEmpty { get; set; }\r\n\t/// <summary>Called when the user clicks the history icon (shows past prompts).</summary>\r\n\tpublic Action<Widget> HistoryRequested { get; set; }\r\n\r\n\tpublic PromptComposer( Widget parent, string placeholder ) : base( parent )\r\n\t{\r\n\t\t_placeholder = placeholder;\r\n\t\tLayout = Layout.Row();\r\n\t\tLayout.Spacing = 6;\r\n\t\t_leading = Layout.AddRow();\r\n\t\t_leading.Spacing = 6;\r\n\t\t_field = Layout.Add( new Field( this ), 1 );\r\n\t\t_box = _field.Box;\r\n\t\t_box.Send = Send;\r\n\t\t_field.History.OnClick = () => HistoryRequested?.Invoke( _field.History );\r\n\t\t_box.TextChanged += _ => Grow();\r\n\t\t_scopeChip = Layout.Add( new ChipButton( this, \"\", \"accessibility_new\", ScopeMenu, \"Which part of the body the change applies to\" ) );\r\n\t\t_settingsChip = Layout.Add( new ChipButton( this, \"\", \"tune\", SettingsMenu, \"Length, takes, quality and more\" ) );\r\n\t\t_send = Layout.Add( new SendButton( this, () => { if ( _busy ) StopRequested?.Invoke(); else Send(); } ) );\r\n\t\tRefreshChips();\r\n\t\tRefreshPlaceholder();\r\n\t\tGrow();\r\n\t}\r\n\r\n\tpublic string Text\r\n\t{\r\n\t\tget => _box.PlainText ?? \"\";\r\n\t\tset { _box.PlainText = value ?? \"\"; Grow(); }\r\n\t}\r\n\r\n\tpublic void FocusPrompt() => _box.Focus();\r\n\r\n\t/// <summary>The text input (tests post key events to it).</summary>\r\n\tpublic Widget Input => _box;\r\n\r\n\t/// <summary>Sends the prompt as if the user pressed Enter.</summary>\r\n\tpublic void Submit() => Send();\r\n\r\n\t/// <summary>The animation a change applies to (for the placeholder), or null for a new animation.</summary>\r\n\tpublic void SetTarget( string clipName )\r\n\t{\r\n\t\t_target = clipName;\r\n\t\tRefreshChips();\r\n\t\tRefreshPlaceholder();\r\n\t}\r\n\r\n\tpublic string Target => _target;\r\n\r\n\t/// <summary>Adds a chip before the text field (e.g. what the prompt does).</summary>\r\n\tpublic ChipButton AddChip( string text, string icon, Action clicked, string tooltip )\r\n\t\t=> _leading.Add( new ChipButton( this, text, icon, clicked, tooltip ) );\r\n\r\n\t/// <summary>Whether the body-part chip is shown.</summary>\r\n\tpublic bool ShowScope\r\n\t{\r\n\t\tget => _showScope;\r\n\t\tset { _showScope = value; if ( !value ) Options.Scope = ChangeScope.WholeBody; RefreshChips(); }\r\n\t}\r\n\r\n\t/// <summary>Whether the settings offer a length (new animations).</summary>\r\n\tpublic bool ShowLength { get => _showLength; set { _showLength = value; RefreshChips(); } }\r\n\r\n\t/// <summary>Re-reads the options into the chips (after changing <see cref=\"Options\"/> in code).</summary>\r\n\tpublic void RefreshOptions() => RefreshChips();\r\n\r\n\t/// <summary>Disables sending, with the reason shown in the field (null enables).</summary>\r\n\tpublic void SetDisabledReason( string reason )\r\n\t{\r\n\t\t_disabledReason = reason;\r\n\t\t_box.Editable = reason is null;\r\n\t\t_send.Enabled = reason is null || _busy;\r\n\t\tRefreshPlaceholder();\r\n\t}\r\n\r\n\tpublic bool Busy\r\n\t{\r\n\t\tget => _busy;\r\n\t\tset { _busy = value; _send.Stop = value; _send.Enabled = value || _disabledReason is null; _send.Update(); }\r\n\t}\r\n\r\n\tpublic string Placeholder { get => _placeholder; set { _placeholder = value; RefreshPlaceholder(); } }\r\n\r\n\tvoid RefreshPlaceholder() => _box.PlaceholderText = _disabledReason ?? _placeholder ?? \"Describe a motion\u2026\";\r\n\r\n\tvoid Send()\r\n\t{\r\n\t\tif ( _busy || _disabledReason is not null ) return;\r\n\t\tvar text = Text.Trim();\r\n\t\tif ( text.Length == 0 && !AllowEmpty ) { _box.Focus(); return; }\r\n\t\tSubmitted?.Invoke( text );\r\n\t}\r\n\r\n\t/// <summary>One line by default; grows with Shift+Enter lines up to three.</summary>\r\n\tvoid Grow()\r\n\t{\r\n\t\tvar lines = Math.Clamp( (Text.Count( c => c == '\\n' ) + 1), 1, 3 );\r\n\t\tvar height = SingleLine + (lines - 1) * LineHeight;\r\n\t\t_field.FixedHeight = height;\r\n\t\tFixedHeight = height;\r\n\t}\r\n\r\n\tvoid RefreshChips()\r\n\t{\r\n\t\t_scopeChip.Visible = _showScope && _target is not null;\r\n\t\t_scopeChip.Text = ScopeName( Options.Scope );\r\n\t\tvar parts = new List<string>();\r\n\t\tif ( _showLength ) parts.Add( $\"{Options.Seconds:0.#} s\" );\r\n\t\tparts.Add( Options.Takes == 1 ? \"1 take\" : $\"{Options.Takes} takes\" );\r\n\t\tparts.Add( Options.Steps <= 12 ? \"Fast\" : Options.Steps >= 40 ? \"Best\" : \"Standard\" );\r\n\t\t_settingsChip.Text = string.Join( \" \u00b7 \", parts );\r\n\t}\r\n\r\n\tpublic static string ScopeName( ChangeScope scope ) => scope switch\r\n\t{\r\n\t\tChangeScope.UpperBody => \"Upper body\",\r\n\t\tChangeScope.LowerBody => \"Legs\",\r\n\t\tChangeScope.Arms => \"Arms\",\r\n\t\tChangeScope.SelectedBones => \"Selected bones\",\r\n\t\tChangeScope.Unlocked => \"Unlocked bones\",\r\n\t\t_ => \"Whole body\",\r\n\t};\r\n\r\n\tvoid Choose( Menu menu, string text, bool selected, Action apply )\r\n\t\t=> menu.AddOption( text, selected ? \"check\" : null, () => { apply(); RefreshChips(); } );\r\n\r\n\tvoid ScopeMenu()\r\n\t{\r\n\t\tvar menu = new Menu( this );\r\n\t\tmenu.AddHeading( \"The change applies to\" );\r\n\t\tforeach ( var s in ScopeChoices )\r\n\t\t{\r\n\t\t\tvar scope = s;\r\n\t\t\tChoose( menu, ScopeName( scope ), Options.Scope == scope, () => Options.Scope = scope );\r\n\t\t}\r\n\t\tmenu.OpenAtCursor();\r\n\t}\r\n\r\n\tvoid SettingsMenu()\r\n\t{\r\n\t\tvar menu = new Menu( this );\r\n\t\tif ( _showLength )\r\n\t\t{\r\n\t\t\tmenu.AddHeading( \"Length\" );\r\n\t\t\tforeach ( var s in new[] { 2f, 3f, 4f, 6f, 8f, 10f } )\r\n\t\t\t\tChoose( menu, $\"{s:0} seconds\", MathF.Abs( Options.Seconds - s ) < 0.01f, () => Options.Seconds = s );\r\n\t\t\tmenu.AddSeparator();\r\n\t\t}\r\n\t\tmenu.AddHeading( \"Takes\" );\r\n\t\tfor ( var n = 1; n <= 4; n++ )\r\n\t\t{\r\n\t\t\tvar count = n;\r\n\t\t\tChoose( menu, count == 1 ? \"1 take\" : $\"{count} takes\", Options.Takes == count, () => Options.Takes = count );\r\n\t\t}\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddHeading( \"Quality\" );\r\n\t\tChoose( menu, \"Fast\", Options.Steps == 12, () => Options.Steps = 12 );\r\n\t\tChoose( menu, \"Standard\", Options.Steps == 24, () => Options.Steps = 24 );\r\n\t\tChoose( menu, \"Best (slowest)\", Options.Steps == 40, () => Options.Steps = 40 );\r\n\t\tmenu.AddSeparator();\r\n\t\tvar advanced = menu.AddMenu( \"Advanced\", \"more_horiz\" );\r\n\t\tadvanced.AddHeading( \"Result\" );\r\n\t\tChoose( advanced, \"Clean up (smooth, lock feet)\", Options.CleanUp, () => Options.CleanUp = true );\r\n\t\tChoose( advanced, \"Raw model output\", !Options.CleanUp, () => Options.CleanUp = false );\r\n\t\tadvanced.AddSeparator();\r\n\t\tadvanced.AddHeading( \"Prompt strength\" );\r\n\t\tforeach ( var g in new[] { 2f, 3f, 4.5f } )\r\n\t\t\tChoose( advanced, g switch { 2f => \"Loose\", 3f => \"Normal\", _ => \"Strict\" }, MathF.Abs( Options.Guidance - g ) < 0.01f, () => Options.Guidance = g );\r\n\t\tadvanced.AddSeparator();\r\n\t\tadvanced.AddHeading( \"Variations are\" );\r\n\t\tforeach ( var v in new[] { 0.3f, 0.5f, 0.75f } )\r\n\t\t\tChoose( advanced, v switch { 0.3f => \"Close to the original\", 0.5f => \"Somewhat different\", _ => \"Very different\" }, MathF.Abs( Options.VariationStrength - v ) < 0.01f, () => Options.VariationStrength = v );\r\n\t\tadvanced.AddSeparator();\r\n\t\tadvanced.AddHeading( \"Seed\" );\r\n\t\tChoose( advanced, \"Random each time\", Options.Seed is null, () => Options.Seed = null );\r\n\t\tChoose( advanced, Options.Seed is int fixedSeed ? $\"Fixed: {fixedSeed}\" : \"Fixed (reproducible)\", Options.Seed is not null,\r\n\t\t\t() => Options.Seed ??= Random.Shared.Next( 1, 99999 ) );\r\n\t\tmenu.OpenAtCursor();\r\n\t}\r\n\r\n\t/// <summary>The input's frame: the editor's field look, a sparkle on the left, blue edge when focused.</summary>\r\n\tsealed class Field : Widget\r\n\t{\r\n\t\tpublic PromptBox Box { get; }\r\n\t\tpublic IconButton History { get; }\r\n\r\n\t\tpublic Field( Widget parent ) : base( parent )\r\n\t\t{\r\n\t\t\tLayout = Layout.Row();\r\n\t\t\tLayout.Margin = new Sandbox.UI.Margin( 26, 0, 2, 0 );\r\n\t\t\tBox = Layout.Add( new PromptBox( this ), 1 );\r\n\t\t\tHistory = Layout.Add( new IconButton( \"history\", null, this )\r\n\t\t\t{\r\n\t\t\t\tFixedSize = 24, IconSize = 16, Background = Color.Transparent, Foreground = Theme.TextLight,\r\n\t\t\t\tToolTip = \"Recent prompts\",\r\n\t\t\t} );\r\n\t\t}\r\n\r\n\t\tprotected override void OnPaint()\r\n\t\t{\r\n\t\t\tPaint.Antialiasing = true;\r\n\t\t\tvar focused = Box.IsFocused;\r\n\t\t\tPaint.SetPen( focused ? TaStyle.Accent : TaStyle.InputEdge, 1 );\r\n\t\t\tPaint.SetBrush( Theme.WindowBackground );\r\n\t\t\tPaint.DrawRect( LocalRect.Shrink( .5f ), TaStyle.Radius );\r\n\t\t\tPaint.SetPen( focused ? TaStyle.AccentLight : TaStyle.Accent );\r\n\t\t\tPaint.DrawIcon( new Rect( 6, 0, 16, MathF.Min( Height, SingleLine ) ), \"auto_awesome\", 14, TextFlag.Center );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The text area: Enter sends, Shift+Enter is a new line.</summary>\r\n\tsealed class PromptBox : TextEdit\r\n\t{\r\n\t\tpublic Action Send { get; set; }\r\n\r\n\t\tpublic PromptBox( Widget parent ) : base( parent )\r\n\t\t{\r\n\t\t\tSetStyles( \"background-color: transparent; border: none; font-size: 13px; padding-top: 3px;\" );\r\n\t\t\tVerticalScrollbarMode = ScrollbarMode.Off;\r\n\t\t\tHorizontalScrollbarMode = ScrollbarMode.Off;\r\n\t\t}\r\n\r\n\t\tprotected override void OnKeyPress( KeyEvent e )\r\n\t\t{\r\n\t\t\tif ( (e.Key == KeyCode.Enter || e.Key == KeyCode.Return) && !e.HasShift )\r\n\t\t\t{\r\n\t\t\t\te.Accepted = true;\r\n\t\t\t\tSend?.Invoke();\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\t\t\tbase.OnKeyPress( e );\r\n\t\t}\r\n\r\n\t\tprotected override void OnFocus( FocusChangeReason reason ) { base.OnFocus( reason ); Parent?.Update(); }\r\n\t\tprotected override void OnBlur( FocusChangeReason reason ) { base.OnBlur( reason ); Parent?.Update(); }\r\n\t}\r\n\r\n\t/// <summary>Round blue send button; a stop square while busy.</summary>\r\n\tsealed class SendButton : Widget\r\n\t{\r\n\t\treadonly Action _clicked;\r\n\t\tpublic bool Stop { get; set; }\r\n\r\n\t\tpublic SendButton( Widget parent, Action clicked ) : base( parent )\r\n\t\t{\r\n\t\t\t_clicked = clicked;\r\n\t\t\tFixedSize = 28;\r\n\t\t\tCursor = CursorShape.Finger;\r\n\t\t\tMouseTracking = true;\r\n\t\t\tToolTip = \"Generate (Enter)\";\r\n\t\t}\r\n\r\n\t\tprotected override void OnMouseEnter() => Update();\r\n\t\tprotected override void OnMouseLeave() => Update();\r\n\r\n\t\tprotected override void OnMousePress( MouseEvent e )\r\n\t\t{\r\n\t\t\tif ( e.LeftMouseButton ) e.Accepted = true;\r\n\t\t}\r\n\r\n\t\tprotected override void OnMouseReleased( MouseEvent e )\r\n\t\t{\r\n\t\t\tbase.OnMouseReleased( e );\r\n\t\t\tif ( Enabled && e.LeftMouseButton && LocalRect.IsInside( e.LocalPosition ) ) _clicked();\r\n\t\t}\r\n\r\n\t\tprotected override void OnPaint()\r\n\t\t{\r\n\t\t\tPaint.Antialiasing = true;\r\n\t\t\tPaint.ClearPen();\r\n\t\t\tPaint.SetBrush( !Enabled ? Color.White.WithAlpha( .1f ) : Paint.HasMouseOver ? TaStyle.AccentLight : TaStyle.Accent );\r\n\t\t\tPaint.DrawRect( LocalRect.Shrink( 1 ), 14 );\r\n\t\t\tPaint.SetPen( Enabled ? Color.White : Theme.TextDisabled );\r\n\t\t\tPaint.DrawIcon( LocalRect, Stop ? \"stop\" : \"arrow_upward\", 17, TextFlag.Center );\r\n\t\t\tToolTip = Stop ? \"Stop\" : \"Generate (Enter)\";\r\n\t\t}\r\n\t}\r\n}\r\n\r\n/// <summary>A compact chip (icon, text, \u25be) in the editor's button style, for options and modes.</summary>\r\npublic sealed class ChipButton : Widget\r\n{\r\n\tstring _text;\r\n\treadonly string _icon;\r\n\tpublic Action Clicked { get; set; }\r\n\r\n\t/// <summary>Draws the dropdown arrow (chips that open a menu); off for plain one-click chips.</summary>\r\n\tpublic bool Arrow { get; set; } = true;\r\n\r\n\tfloat ArrowRoom => Arrow ? 20 : 10;\r\n\r\n\tpublic ChipButton( Widget parent, string text, string icon, Action clicked, string tooltip ) : base( parent )\r\n\t{\r\n\t\t_icon = icon;\r\n\t\tClicked = clicked;\r\n\t\tToolTip = tooltip;\r\n\t\tFixedHeight = 28;\r\n\t\tCursor = CursorShape.Finger;\r\n\t\tMouseTracking = true;\r\n\t\tText = text;\r\n\t}\r\n\r\n\tpublic string Text\r\n\t{\r\n\t\tget => _text;\r\n\t\tset\r\n\t\t{\r\n\t\t\t_text = value ?? \"\";\r\n\t\t\tFixedWidth = MathF.Ceiling( 8 + 16 + 5 + _text.Length * 6.2f + ArrowRoom );\r\n\t\t\tUpdate();\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnMouseEnter() => Update();\r\n\tprotected override void OnMouseLeave() => Update();\r\n\r\n\tprotected override void OnMousePress( MouseEvent e )\r\n\t{\r\n\t\tif ( e.LeftMouseButton ) e.Accepted = true;\r\n\t}\r\n\r\n\tprotected override void OnMouseReleased( MouseEvent e )\r\n\t{\r\n\t\tbase.OnMouseReleased( e );\r\n\t\tif ( Enabled && e.LeftMouseButton && LocalRect.IsInside( e.LocalPosition ) ) Clicked?.Invoke();\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tPaint.Antialiasing = true;\r\n\t\tvar hover = Paint.HasMouseOver && Enabled;\r\n\t\tPaint.SetPen( Color.Lerp( Theme.ControlBackground.WithAlpha( 1f ), Color.White, hover ? .25f : .15f ), 1 );\r\n\t\tPaint.SetBrush( hover ? Color.Lerp( TaStyle.ButtonFill, Color.White, .06f ) : TaStyle.ButtonFill );\r\n\t\tPaint.DrawRect( LocalRect.Shrink( .5f ), TaStyle.Radius );\r\n\t\tPaint.SetPen( Enabled ? TaStyle.AccentLight : Theme.TextDisabled );\r\n\t\tPaint.DrawIcon( new Rect( 8, 0, 16, Height ), _icon, 14, TextFlag.Center );\r\n\t\tPaint.SetDefaultFont( 8 );\r\n\t\tPaint.SetPen( Enabled ? Theme.Text : Theme.TextDisabled );\r\n\t\tvar textWidth = Paint.MeasureText( _text ).x;\r\n\t\tvar wanted = MathF.Ceiling( 8 + 16 + 5 + textWidth + ArrowRoom );\r\n\t\tif ( MathF.Abs( wanted - FixedWidth ) > .5f ) FixedWidth = wanted;\r\n\t\tPaint.DrawText( new Rect( 29, 0, textWidth + 2, Height ), _text, TextFlag.LeftCenter );\r\n\t\tif ( !Arrow ) return;\r\n\t\tPaint.SetPen( Theme.TextLight );\r\n\t\tPaint.DrawIcon( new Rect( Width - 18, 0, 14, Height ), \"expand_more\", 13, TextFlag.Center );\r\n\t}\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Code/TextToAnimation/Assembly.cs",
"FileName": "Assembly.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "#nullable enable annotations\r\n\r\n// Global usings for the s&box in-engine compiler.\r\n//\r\n// The plain net8.0 dev harness gets these automatically via <ImplicitUsings>,\r\n// but s&box's compiler injects no BCL usings at all - without this file the\r\n// library fails to compile inside the editor (CS0246 on List<>, IEnumerable<>,\r\n// FormatException, ...). Duplicating the SDK's implicit set is harmless there\r\n// (verified: no warnings).\r\n\r\nglobal using System;\r\nglobal using System.Collections.Generic;\r\nglobal using System.IO;\r\nglobal using System.Linq;\r\nglobal using System.Threading;\r\nglobal using System.Threading.Tasks;\r\n\r\n// NOTE on Vector3: s&box declares its own Vector3 in the *global namespace*,\r\n// which wins over `using System.Numerics;` imports during name lookup and\r\n// breaks this System.Numerics-based core (.X/.Y/.Z, static helpers, delegate\r\n// signatures). A global using-alias does NOT fix this (CS0576: alias conflicts\r\n// with the global-namespace type at every use site). The working fix is a\r\n// *namespace-scoped* alias, declared after the file-scoped namespace line:\r\n//\r\n// namespace TextToAnimation.Xyz;\r\n// using Vector3 = System.Numerics.Vector3;\r\n//\r\n// Every file in this tree that uses the simple name Vector3 carries that line.\r\n// (Quaternion and Matrix4x4 are not global-namespace types in s&box and need\r\n// no alias.)\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Code/TextToAnimation/Mapping/MappingResult.cs",
"FileName": "MappingResult.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "#nullable enable annotations\r\n\r\nusing System.Collections.Generic;\r\n\r\nnamespace TextToAnimation.Mapping;\r\n\r\n/// <summary>How a mapping was produced; determines UI flow and preset-learning behavior.</summary>\r\npublic enum MappingSource\r\n{\r\n /// <summary>A shipped preset profile matched (mixamo, actorcore_cc, ...).</summary>\r\n Preset,\r\n\r\n /// <summary>A user-saved preset (keyed by <see cref=\"SkeletonSignature\"/>) matched.</summary>\r\n UserPreset,\r\n\r\n /// <summary>The auto-mapper resolved roles from bone-name tokens (stage A).</summary>\r\n AutoName,\r\n\r\n /// <summary>The auto-mapper fell back to pure hierarchy/geometry matching (stage B).</summary>\r\n AutoTopology,\r\n\r\n /// <summary>The user assigned roles by hand in the mapping editor.</summary>\r\n Manual,\r\n\r\n /// <summary>\r\n /// The source file itself declares the mapping \u2014 e.g. a VRM's\r\n /// <c>humanoid.humanBones</c> block (both the 0.x <c>extensions.VRM</c> and the 1.0\r\n /// <c>extensions.VRMC_vrm</c> layouts). Authoritative: consulted before user presets and\r\n /// shipped-profile detection, at confidence 1.0.\r\n /// </summary>\r\n Authored,\r\n}\r\n\r\n/// <summary>\r\n/// The outcome of mapping a source skeleton onto canonical <see cref=\"BoneRole\"/>s:\r\n/// role \u2192 source bone index, an overall confidence, and human-readable notes\r\n/// (unmapped roles, ignored bones, ambiguities) for the mapping report.\r\n/// </summary>\r\npublic sealed class MappingResult\r\n{\r\n /// <summary>Creates an empty result; callers fill <see cref=\"RoleToBone\"/> and\r\n /// <see cref=\"Confidence\"/>.</summary>\r\n public MappingResult(string profileName, MappingSource source)\r\n {\r\n ProfileName = profileName;\r\n Source = source;\r\n }\r\n\r\n /// <summary>Resolved roles: canonical role \u2192 bone index in the source skeleton.</summary>\r\n public Dictionary<BoneRole, int> RoleToBone { get; } = new();\r\n\r\n /// <summary>Mapping confidence in [0, 1]; 1 means every required and optional role\r\n /// resolved unambiguously.</summary>\r\n public float Confidence { get; set; }\r\n\r\n /// <summary>Profile that produced the mapping \u2014 a preset name, or <c>\"auto\"</c> /\r\n /// <c>\"topology\"</c> for the auto-mapper stages.</summary>\r\n public string ProfileName { get; }\r\n\r\n /// <summary>Where the mapping came from.</summary>\r\n public MappingSource Source { get; }\r\n\r\n /// <summary>Report notes: unmapped roles, ignored source bones, ambiguities.</summary>\r\n public List<string> Notes { get; } = new();\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Code/TextToAnimation/Maths/MathQ.cs",
"FileName": "MathQ.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "#nullable enable annotations\r\n\r\nusing System;\r\nusing System.Numerics;\r\n\r\nnamespace TextToAnimation.Maths;\r\n\r\nusing Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/TextToAnimation/Assembly.cs)\r\n\r\n/// <summary>\r\n/// Quaternion algebra helpers used throughout the retargeting pipeline.\r\n/// All angles are radians; quaternions are XYZW (<see cref=\"System.Numerics.Quaternion\"/> native);\r\n/// <c>a * b</c> applies <c>b</c> first (column-vector convention).\r\n/// </summary>\r\npublic static class MathQ\r\n{\r\n private const float NearZeroLengthSq = 1e-12f;\r\n\r\n /// <summary>\r\n /// Defensively normalizes a quaternion. Returns <see cref=\"Quaternion.Identity\"/> when the\r\n /// input has near-zero length or non-finite components instead of producing NaNs.\r\n /// </summary>\r\n public static Quaternion Normalize(Quaternion q)\r\n {\r\n var lengthSq = q.LengthSquared();\r\n if (!float.IsFinite(lengthSq) || lengthSq < NearZeroLengthSq)\r\n return Quaternion.Identity;\r\n return Quaternion.Multiply(q, 1f / MathF.Sqrt(lengthSq));\r\n }\r\n\r\n /// <summary>\r\n /// Shortest-arc rotation taking direction <paramref name=\"a\"/> onto direction <paramref name=\"b\"/>\r\n /// (magnitudes are ignored). For antiparallel inputs a 180\u00b0 rotation about a stable axis\r\n /// perpendicular to <paramref name=\"a\"/> is returned. Near-zero inputs yield identity.\r\n /// </summary>\r\n public static Quaternion FromTo(Vector3 a, Vector3 b)\r\n {\r\n if (a.LengthSquared() < NearZeroLengthSq || b.LengthSquared() < NearZeroLengthSq)\r\n return Quaternion.Identity;\r\n\r\n var an = Vector3.Normalize(a);\r\n var bn = Vector3.Normalize(b);\r\n var dot = Vector3.Dot(an, bn);\r\n\r\n if (dot >= 1f - 1e-8f)\r\n return Quaternion.Identity;\r\n\r\n if (dot <= -1f + 1e-7f)\r\n {\r\n // Antiparallel: any axis perpendicular to a works; pick a stable one.\r\n return Quaternion.CreateFromAxisAngle(Perpendicular(an), MathF.PI);\r\n }\r\n\r\n // q = (a x b, 1 + a\u00b7b), normalized \u2014 the half-angle construction of the shortest arc.\r\n var cross = Vector3.Cross(an, bn);\r\n return Normalize(new Quaternion(cross.X, cross.Y, cross.Z, 1f + dot));\r\n }\r\n\r\n /// <summary>\r\n /// Decomposes a rotation into <paramref name=\"swing\"/> and <paramref name=\"twist\"/> so that\r\n /// <c>q == swing * twist</c> (twist applied first). The twist is the projection of the\r\n /// rotation onto <paramref name=\"twistAxis\"/>: its rotation axis is parallel to\r\n /// <paramref name=\"twistAxis\"/>, while the swing's rotation axis is perpendicular to it.\r\n /// When the rotation is a pure 180\u00b0 swing (no component about the axis), twist is identity.\r\n /// </summary>\r\n /// <exception cref=\"ArgumentException\">Thrown when <paramref name=\"twistAxis\"/> has near-zero length.</exception>\r\n public static void SwingTwist(Quaternion q, Vector3 twistAxis, out Quaternion swing, out Quaternion twist)\r\n {\r\n if (twistAxis.LengthSquared() < NearZeroLengthSq)\r\n throw new ArgumentException(\"Twist axis must be non-zero.\", nameof(twistAxis));\r\n\r\n var axis = Vector3.Normalize(twistAxis);\r\n q = Normalize(q);\r\n\r\n // Project the rotation's vector part onto the twist axis.\r\n var projection = q.X * axis.X + q.Y * axis.Y + q.Z * axis.Z;\r\n var raw = new Quaternion(axis.X * projection, axis.Y * projection, axis.Z * projection, q.W);\r\n\r\n // Degenerate case (q.W ~ 0 and projection ~ 0): pure 180\u00b0 swing, no twist.\r\n twist = Normalize(raw);\r\n swing = Normalize(q * Quaternion.Conjugate(twist));\r\n }\r\n\r\n /// <summary>\r\n /// Geodesic angle between two rotations in radians, in [0, \u03c0], invariant under the\r\n /// q / -q double cover (so <c>AngleBetween(q, -q) == 0</c>).\r\n /// </summary>\r\n public static float AngleBetween(Quaternion a, Quaternion b)\r\n {\r\n // Use atan2 on the delta rotation rather than acos on the dot product: acos is\r\n // ill-conditioned near 0\u00b0, atan2 is accurate over the whole range.\r\n var delta = Quaternion.Conjugate(Normalize(a)) * Normalize(b);\r\n var sinHalf = MathF.Sqrt(delta.X * delta.X + delta.Y * delta.Y + delta.Z * delta.Z);\r\n var cosHalf = MathF.Abs(delta.W); // |W| folds the q / -q double cover.\r\n return 2f * MathF.Atan2(sinHalf, cosHalf);\r\n }\r\n\r\n /// <summary>\r\n /// Angle between two directions in radians, in [0, \u03c0] (magnitudes are ignored).\r\n /// Near-zero inputs yield 0. Uses atan2, which stays accurate near 0 and \u03c0 where\r\n /// acos of the dot product loses precision.\r\n /// </summary>\r\n public static float AngleBetween(Vector3 a, Vector3 b)\r\n {\r\n if (a.LengthSquared() < NearZeroLengthSq || b.LengthSquared() < NearZeroLengthSq)\r\n return 0f;\r\n return MathF.Atan2(Vector3.Cross(a, b).Length(), Vector3.Dot(a, b));\r\n }\r\n\r\n /// <summary>\r\n /// Builds an orthonormal right-handed basis rotation from a forward and an up hint.\r\n /// </summary>\r\n /// <remarks>\r\n /// Convention: returns the rotation <c>R</c> such that\r\n /// <list type=\"bullet\">\r\n /// <item><c>rotate(R, +Y) == normalize(forward)</c> (primary axis),</item>\r\n /// <item><c>rotate(R, +Z) == </c> the Gram-Schmidt orthonormalization of <paramref name=\"up\"/>\r\n /// against forward (secondary axis),</item>\r\n /// <item><c>rotate(R, +X) == forwardN x upOrtho</c> (right-handed completion).</item>\r\n /// </list>\r\n /// Degenerate inputs are handled defensively: a near-zero forward yields identity; an up hint\r\n /// (near-)parallel to forward falls back to world +Z, or world +X when forward itself is\r\n /// (near-)parallel to +Z.\r\n /// </remarks>\r\n public static Quaternion BasisFromForwardUp(Vector3 forward, Vector3 up)\r\n {\r\n if (forward.LengthSquared() < NearZeroLengthSq)\r\n return Quaternion.Identity;\r\n\r\n var y = Vector3.Normalize(forward);\r\n\r\n var z = up - y * Vector3.Dot(up, y);\r\n if (z.LengthSquared() < 1e-8f)\r\n {\r\n // Up hint unusable: pick a stable fallback and orthonormalize it.\r\n var fallback = MathF.Abs(Vector3.Dot(y, Vector3.UnitZ)) > 0.99f ? Vector3.UnitX : Vector3.UnitZ;\r\n z = fallback - y * Vector3.Dot(fallback, y);\r\n }\r\n z = Vector3.Normalize(z);\r\n\r\n var x = Vector3.Cross(y, z);\r\n\r\n // System.Numerics matrices act on row vectors, so the rows are the images of the\r\n // unit axes under the rotation: row1 = R*X, row2 = R*Y, row3 = R*Z.\r\n var m = new Matrix4x4(\r\n x.X, x.Y, x.Z, 0f,\r\n y.X, y.Y, y.Z, 0f,\r\n z.X, z.Y, z.Z, 0f,\r\n 0f, 0f, 0f, 1f);\r\n\r\n return Normalize(Quaternion.CreateFromRotationMatrix(m));\r\n }\r\n\r\n /// <summary>Stable unit vector perpendicular to <paramref name=\"unit\"/> (assumed normalized).\r\n /// Internal so geometry passes (e.g. <c>TwoBoneIk</c>) reuse one implementation.</summary>\r\n internal static Vector3 Perpendicular(Vector3 unit)\r\n {\r\n var p = Vector3.Cross(unit, Vector3.UnitX);\r\n if (p.LengthSquared() < 1e-6f)\r\n p = Vector3.Cross(unit, Vector3.UnitY);\r\n return Vector3.Normalize(p);\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Code/TextToAnimation/Rig/JointNames.cs",
"FileName": "JointNames.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "#nullable enable annotations\r\n\r\nusing System.Text;\r\nusing System.Text.RegularExpressions;\r\n\r\nnamespace TextToAnimation.Rig;\r\n\r\n/// <summary>\r\n/// Raw bone name to the \"clean\" joint vocabulary UniMate was trained on (\"mixamorig:LeftUpLeg\" -> \"Left Thigh\",\r\n/// \"thigh.L\" -> \"Left Thigh\", \"Tail_05\" -> \"Tail\"). A port of the generic and Mixamo paths of UniMate's\r\n/// data_process/joint_annotation/names_clean_rule.py (clean_joint_name + post_process); the dataset-specific\r\n/// maps (Japanese, spider, NPC, elk rigs) are left out. Names are only a hint: <see cref=\"RigAnalysis\"/>\r\n/// labels bones from geometry and uses these for bones it cannot classify.\r\n/// </summary>\r\npublic static class JointNames\r\n{\r\n static readonly Regex TrailingToken = new(@\"[._](\\d+|[LRlr]|x)$\", RegexOptions.CultureInvariant);\r\n static readonly Regex SideToken = new(@\"[._]([LR])(?=[._]|\\d|$)\", RegexOptions.CultureInvariant);\r\n static readonly Regex SingleSide = new(@\"^[LR](?:[A-Z][a-z]|[A-Z]{0,2}_[A-Za-z])\", RegexOptions.CultureInvariant);\r\n static readonly Regex FingerCode = new(@\"^[Ff]inger([0-4])\\d*(Nub)?$\", RegexOptions.CultureInvariant);\r\n static readonly Regex FingerSeg = new(@\"^[Ff]inger([1-5])(Metacarpal|Proximal|Medial|Distal|Tip)\\d*$\", RegexOptions.CultureInvariant);\r\n static readonly Regex ParenDecor = new(@\"\\s*\\([^)]*\\)\", RegexOptions.CultureInvariant);\r\n static readonly Regex Namespace = new(@\"^[A-Za-z][\\w .-]*:\", RegexOptions.CultureInvariant);\r\n static readonly Regex BipPrefix = new(@\"^(?:BN_)?Bip\\d+(?:[-_ ]+|(?=[LR][A-Z]))\", RegexOptions.CultureInvariant);\r\n static readonly Regex Mixamorig = new(@\"^mixamorig\\d*[:_]\", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);\r\n static readonly string[] FingerCodeNames = { \"Thumb\", \"Index\", \"Middle\", \"Ring\", \"Pinky\" };\r\n\r\n /// <summary>The cleaned, post-processed joint name (never empty; \"Bone\" when nothing anatomical survives).</summary>\r\n public static string Clean(string raw) => PostProcess(CleanJointName(raw ?? \"\"));\r\n\r\n /// <summary>True when <paramref name=\"cleaned\"/> (side word removed) is a word of the training vocabulary.</summary>\r\n public static bool IsKnown(string cleaned)\r\n {\r\n var (_, baseName) = SplitSide(cleaned);\r\n if (baseName.Length == 0 || baseName == \"Bone\") return false;\r\n if (baseName.EndsWith(\" End\", StringComparison.Ordinal)) baseName = baseName[..^4];\r\n return KnownWords.Contains(baseName);\r\n }\r\n\r\n /// <summary>(\"Left\", \"Thigh\") from \"Left Thigh\"; side is \"\" for center names.</summary>\r\n public static (string Side, string Base) SplitSide(string cleaned)\r\n {\r\n if (cleaned.StartsWith(\"Left \", StringComparison.Ordinal)) return (\"Left\", cleaned[5..]);\r\n if (cleaned.StartsWith(\"Right \", StringComparison.Ordinal)) return (\"Right\", cleaned[6..]);\r\n return (\"\", cleaned);\r\n }\r\n\r\n static HashSet<string>? _known;\r\n static HashSet<string> KnownWords\r\n {\r\n get\r\n {\r\n if (_known is not null) return _known;\r\n var set = new HashSet<string>(StringComparer.Ordinal);\r\n foreach (var v in Canonical.Values) if (v.Length > 0) set.Add(SplitSide(v).Base);\r\n foreach (var v in MixamoMap.Values) set.Add(SplitSide(v).Base);\r\n foreach (var v in SymmetricPairPriority) set.Add(v);\r\n set.Remove(\"Bone\");\r\n set.Remove(\"End\");\r\n return _known = set;\r\n }\r\n }\r\n\r\n static string CleanJointName(string raw)\r\n {\r\n if (raw.Trim().Length == 0) return raw;\r\n var stripped = ParenDecor.Replace(raw, \"\").Trim();\r\n if (stripped.Length > 0) raw = stripped;\r\n if (Regex.IsMatch(raw, @\"^Bone\\d+$\")) return \"Bone\";\r\n if (Regex.IsMatch(raw.Trim('_'), @\"^_?\\d+$\")) return raw;\r\n var m = Mixamorig.Match(raw);\r\n if (m.Success) return CleanPrefixed(raw, m.Value, MixamoMap);\r\n return CleanStandard(raw);\r\n }\r\n\r\n static string CleanPrefixed(string raw, string prefix, Dictionary<string, string> map)\r\n {\r\n var name = raw[prefix.Length..];\r\n var (side, rest) = ExtractSidePrefix(name);\r\n if (!map.TryGetValue(rest, out var result) && !map.TryGetValue(Regex.Replace(rest, @\"\\d+$\", \"\"), out result))\r\n {\r\n result = SplitAndMapTokens(rest);\r\n if (result.Length == 0) result = rest;\r\n }\r\n return WithSide(side, result).Trim();\r\n }\r\n\r\n static string? Canon(string token)\r\n {\r\n if (Canonical.TryGetValue(token, out var value)) return value;\r\n if (token.Length > 0)\r\n {\r\n var cap = Capitalize(token);\r\n if (cap != token && Canonical.TryGetValue(cap, out value)) return value;\r\n }\r\n return null;\r\n }\r\n\r\n /// <summary>Python's str.capitalize(): first character upper, the rest lower.</summary>\r\n static string Capitalize(string s) => s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..].ToLowerInvariant();\r\n\r\n static (string Side, string Name) StripTrailingDecorations(string name)\r\n {\r\n var side = \"\";\r\n while (true)\r\n {\r\n var m = TrailingToken.Match(name);\r\n if (!m.Success) return (side, name);\r\n var tok = m.Groups[1].Value;\r\n if (tok is \"L\" or \"l\") side = \"Left\";\r\n else if (tok is \"R\" or \"r\") side = \"Right\";\r\n name = name[..m.Index];\r\n }\r\n }\r\n\r\n static (string Side, string Name) ExtractSidePrefix(string name)\r\n {\r\n var (side, baseName) = StripTrailingDecorations(name);\r\n name = baseName;\r\n if (side.Length > 0)\r\n {\r\n var cut = Regex.Replace(name, @\"^(?:Left|Right)(?=[A-Z])|^(?:Left|Right)[_ ]|^[LR][-_]|^[lr]_\", \"\");\r\n return (side, cut.Length > 0 ? cut : name);\r\n }\r\n if (Regex.IsMatch(name, @\"^[RL][-_.]\")) return (name[0] == 'L' ? \"Left\" : \"Right\", name[2..]);\r\n var m = Regex.Match(name, @\"^([RL]) +\");\r\n if (m.Success) return (m.Groups[1].Value == \"L\" ? \"Left\" : \"Right\", name[m.Length..]);\r\n m = Regex.Match(name, @\"^(Left|Right)_\", RegexOptions.IgnoreCase);\r\n if (m.Success) return (Capitalize(m.Groups[1].Value), name[m.Length..]);\r\n m = Regex.Match(name, @\"^(Left|Right)(?=[A-Z])\");\r\n if (m.Success) return (m.Groups[1].Value, name[m.Groups[1].Length..]);\r\n if (name.Length > 1 && (name[0] == 'L' || name[0] == 'R') && char.IsUpper(name[1]))\r\n {\r\n var t = SideToken.Match(name, 1);\r\n if (t.Success)\r\n {\r\n var rest = (name[..t.Index] + name[(t.Index + t.Length)..]).Trim('.', '_');\r\n return (t.Groups[1].Value == \"L\" ? \"Left\" : \"Right\", rest.Length > 0 ? rest : name);\r\n }\r\n if (SingleSide.IsMatch(name)) return (name[0] == 'L' ? \"Left\" : \"Right\", name[1..]);\r\n }\r\n m = Regex.Match(name, @\"_([LR])_(\\d+)$\");\r\n if (m.Success) return (m.Groups[1].Value == \"L\" ? \"Left\" : \"Right\", name[..m.Index] + \"_\" + m.Groups[2].Value);\r\n m = Regex.Match(name, @\"_([LR])$\");\r\n if (m.Success) return (m.Groups[1].Value == \"L\" ? \"Left\" : \"Right\", name[..m.Index]);\r\n return (\"\", name);\r\n }\r\n\r\n static string SplitAndMapTokens(string name)\r\n {\r\n var mapped = new List<string>();\r\n foreach (var t in Regex.Split(name, @\"(?=[A-Z])|_\"))\r\n {\r\n if (t.Length == 0) continue;\r\n var baseName = Regex.Replace(t, @\"\\d+$\", \"\");\r\n var value = Canon(baseName);\r\n if (value is not null) { if (value.Length > 0) mapped.Add(value); }\r\n else if (baseName.Length > 1) mapped.Add(baseName);\r\n }\r\n return string.Join(\" \", mapped);\r\n }\r\n\r\n static string WithSide(string side, string result)\r\n {\r\n if (side.Length == 0) return result;\r\n var m = Regex.Match(result, @\"^(Left|Right)\\b\\s*\");\r\n if (m.Success) result = result[m.Length..];\r\n return (side + \" \" + result).Trim();\r\n }\r\n\r\n static string CleanStandard(string raw)\r\n {\r\n var name = Namespace.Replace(raw, \"\", 1);\r\n if (name.Length == 0) name = raw;\r\n name = BipPrefix.Replace(name, \"\", 1);\r\n foreach (var prefix in RemovePrefixes)\r\n if (name.StartsWith(prefix, StringComparison.Ordinal)) { name = name[prefix.Length..]; break; }\r\n name = name.TrimStart('_').Trim();\r\n if (name.Length == 0) return raw.Trim('_');\r\n\r\n var (side, rest) = ExtractSidePrefix(name);\r\n name = rest;\r\n\r\n var m = FingerSeg.Match(name);\r\n if (m.Success)\r\n {\r\n var label = FingerCodeNames[int.Parse(m.Groups[1].Value) - 1] + \" Finger\";\r\n if (m.Groups[2].Value == \"Tip\") label += \" End\";\r\n return WithSide(side, label);\r\n }\r\n m = FingerCode.Match(name);\r\n if (m.Success)\r\n {\r\n var label = FingerCodeNames[int.Parse(m.Groups[1].Value)] + \" Finger\";\r\n if (m.Groups[2].Success && m.Groups[2].Value.Length > 0) label += \" End\";\r\n return WithSide(side, label);\r\n }\r\n\r\n m = Regex.Match(name, @\"^(.+?)_?(\\d+)$\");\r\n var baseName = m.Success ? m.Groups[1].Value.TrimEnd('_') : name;\r\n var mappedBase = Canon(baseName);\r\n if (mappedBase is not null)\r\n {\r\n if (mappedBase.Length == 0 || mappedBase == \"Bone\") return \"Bone\";\r\n return WithSide(side, mappedBase);\r\n }\r\n\r\n var mapped = new List<string>();\r\n foreach (var p in baseName.Split('_'))\r\n {\r\n if (p.Length == 0) continue;\r\n if (p is \"L\" or \"l\") { if (side.Length == 0) side = \"Left\"; continue; }\r\n if (p is \"R\" or \"r\") { if (side.Length == 0) side = \"Right\"; continue; }\r\n var subBase = Regex.Replace(p, @\"\\d+$\", \"\");\r\n var subMapped = Canon(subBase);\r\n if (subMapped is not null) { if (subMapped.Length > 0) mapped.Add(subMapped); }\r\n else if (subBase.Length > 0)\r\n {\r\n foreach (var st in Regex.Split(subBase, @\"(?=[A-Z])\"))\r\n {\r\n if (st.Length == 0) continue;\r\n var stMapped = Canon(st);\r\n if (stMapped is not null) { if (stMapped.Length > 0) mapped.Add(stMapped); }\r\n else if (st.Length > 1) mapped.Add(st);\r\n }\r\n }\r\n }\r\n var result = string.Join(\" \", mapped);\r\n if (result.Length == 0 || result == \"Bone\") return \"Bone\";\r\n var sided = WithSide(side, result).Trim();\r\n return sided.Length > 0 ? sided : raw;\r\n }\r\n\r\n static string PostProcess(string name)\r\n {\r\n name = Regex.Replace(name, @\"\\s+\", \" \").Trim().TrimEnd('.', '_', ' ');\r\n name = Regex.Replace(name, @\"\\s+\\d+$\", \"\");\r\n name = Regex.Replace(name, @\"(\\w)\\d+\\s+End\", \"$1 End\");\r\n name = Regex.Replace(name, @\"\\bHand (Thumb|Index|Middle|Ring|Pinky) Finger\\b\", \"$1 Finger\");\r\n name = Regex.Replace(name, @\"\\b(Thumb|Index|Middle|Ring|Pinky) Finger Finger\\b\", \"$1 Finger\");\r\n name = Regex.Replace(name, @\"\\bFinger (Thumb|Index|Middle|Ring|Pinky) Finger\\b\", \"$1 Finger\");\r\n name = Regex.Replace(name, @\"\\b(?:Up|Upper) Leg$\", \"Thigh\");\r\n name = Regex.Replace(name, @\"\\bLower Leg$\", \"Shin\");\r\n name = Regex.Replace(name, @\"\\b(?:Lower|Fore) Arm$\", \"Forearm\");\r\n name = Regex.Replace(name, @\"\\bToe Base$\", \"Toe\");\r\n name = Regex.Replace(name, @\"^(?!Left |Right )(.+) (Left|Right)$\", \"$2 $1\");\r\n name = Regex.Replace(name, @\"^Head Top End$\", \"Head End\");\r\n name = name.Replace(\". \", \" \");\r\n name = Regex.Replace(name, @\"^Spine ?\\d* ?Tail$\", \"Tail\");\r\n name = Regex.Replace(name, @\"^Head ?\\d* ?Jaw$\", \"Jaw\");\r\n name = Regex.Replace(name, @\"^Head ?\\d* ?Eyelid$\", \"Eyelid\");\r\n name = Regex.Replace(name, @\"^Head Muzzle$\", \"Muzzle\");\r\n name = Regex.Replace(name, @\"^Head Jaw End$\", \"Jaw End\");\r\n name = Regex.Replace(name, @\"^Head Brain$\", \"Head\");\r\n name = Regex.Replace(name, @\"Bip \\d+ \", \"\");\r\n name = Regex.Replace(name, @\"^(\\w+) \\1$\", \"$1\");\r\n if (Regex.IsMatch(name, @\"^Xtra\")) name = \"Bone\";\r\n name = Regex.Replace(name, @\"Ponytail\\d*.*\", \"Appendage\");\r\n var words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);\r\n var sb = new StringBuilder();\r\n foreach (var w in words)\r\n {\r\n if (sb.Length > 0) sb.Append(' ');\r\n sb.Append(char.IsLower(w[0]) && w.Length > 1 ? Capitalize(w) : w);\r\n }\r\n name = sb.ToString();\r\n name = Regex.Replace(name, @\"^Spine (Left|Right) Wing$\", \"$1 Wing\");\r\n name = Regex.Replace(name, @\"\\s+\\d+$\", \"\");\r\n return name.Length == 0 ? \"Bone\" : name;\r\n }\r\n\r\n // generated from UniMate data_process/joint_annotation/vocab.py and face_select_rule.py\r\n static readonly string[] RemovePrefixes = { \"BN_Bip01_\", \"BN_Bip01 \", \"Base Human \", \"Bip001_\", \"Bip001 \", \"Bip01_\", \"Bip01 \", \"Preset01_\", \"Preset01 \", \"Mutant:\", \"Sif:\", \"BN_\", \"NPC_\", \"jt_\", \"Bn_\", \"bn_\", \"b_\" };\r\n static readonly Dictionary<string, string> Canonical = new(StringComparer.Ordinal)\r\n {\r\n [\"Pelvis\"] = \"Pelvis\",\r\n [\"pelv\"] = \"Pelvis\",\r\n [\"Spine\"] = \"Spine\",\r\n [\"Spn\"] = \"Spine\",\r\n [\"Spline\"] = \"Spine\",\r\n [\"Ribcage\"] = \"Ribcage\",\r\n [\"Neck\"] = \"Neck\",\r\n [\"Nek\"] = \"Neck\",\r\n [\"Head\"] = \"Head\",\r\n [\"Scull\"] = \"Skull\",\r\n [\"Skull\"] = \"Skull\",\r\n [\"ScullBase\"] = \"Skull Base\",\r\n [\"SkullBase\"] = \"Skull Base\",\r\n [\"HeadNub\"] = \"Head End\",\r\n [\"Nub\"] = \"End\",\r\n [\"Tip\"] = \"End\",\r\n [\"Hips\"] = \"Hips\",\r\n [\"Cog\"] = \"Root\",\r\n [\"Clavicle\"] = \"Shoulder\",\r\n [\"Collarbone\"] = \"Shoulder\",\r\n [\"Scapula\"] = \"Scapula\",\r\n [\"UpperArm\"] = \"Upper Arm\",\r\n [\"Upperarm\"] = \"Upper Arm\",\r\n [\"Humerus\"] = \"Upper Arm\",\r\n [\"Forearm\"] = \"Forearm\",\r\n [\"ForeArm\"] = \"Forearm\",\r\n [\"LowerArm\"] = \"Forearm\",\r\n [\"Lowerarm\"] = \"Forearm\",\r\n [\"Radius\"] = \"Forearm\",\r\n [\"Hand\"] = \"Hand\",\r\n [\"Hnd\"] = \"Hand\",\r\n [\"Palm\"] = \"Palm\",\r\n [\"Wrist\"] = \"Wrist\",\r\n [\"Elbow\"] = \"Elbow\",\r\n [\"Shoulder\"] = \"Shoulder\",\r\n [\"Thigh\"] = \"Thigh\",\r\n [\"Femur\"] = \"Thigh\",\r\n [\"UpLeg\"] = \"Thigh\",\r\n [\"UpperLeg\"] = \"Thigh\",\r\n [\"Upperleg\"] = \"Thigh\",\r\n [\"Calf\"] = \"Shin\",\r\n [\"Tibia\"] = \"Shin\",\r\n [\"Leg\"] = \"Leg\",\r\n [\"LowerLeg\"] = \"Shin\",\r\n [\"Lowerleg\"] = \"Shin\",\r\n [\"HorseLink\"] = \"Fetlock\",\r\n [\"LargeCannon\"] = \"Cannon\",\r\n [\"Metacarpus\"] = \"Metacarpus\",\r\n [\"PhalangesManus\"] = \"Phalanges\",\r\n [\"PhalanxPrima\"] = \"Pastern\",\r\n [\"Foot\"] = \"Foot\",\r\n [\"Ankle\"] = \"Ankle\",\r\n [\"Heel\"] = \"Heel\",\r\n [\"Toe\"] = \"Toe\",\r\n [\"Toes\"] = \"Toe\",\r\n [\"ToeBase\"] = \"Toe\",\r\n [\"Ball\"] = \"Toe\",\r\n [\"Hoof\"] = \"Hoof\",\r\n [\"Knee\"] = \"Knee\",\r\n [\"Foreleg\"] = \"Front Leg\",\r\n [\"Hindleg\"] = \"Hind Leg\",\r\n [\"Finger\"] = \"Finger\",\r\n [\"Thumb\"] = \"Thumb Finger\",\r\n [\"Pinky\"] = \"Pinky Finger\",\r\n [\"Little\"] = \"Pinky Finger\",\r\n [\"Ring\"] = \"Ring Finger\",\r\n [\"Middle\"] = \"Middle Finger\",\r\n [\"Index\"] = \"Index Finger\",\r\n [\"Jaw\"] = \"Jaw\",\r\n [\"Tongue\"] = \"Tongue\",\r\n [\"Thouge\"] = \"Tongue\",\r\n [\"Tone\"] = \"Tongue\",\r\n [\"tunge\"] = \"Tongue\",\r\n [\"Tunge\"] = \"Tongue\",\r\n [\"Ear\"] = \"Ear\",\r\n [\"Eye\"] = \"Eye\",\r\n [\"Eyeball\"] = \"Eyeball\",\r\n [\"EyeBall\"] = \"Eyeball\",\r\n [\"Eyebrow\"] = \"Eyebrow\",\r\n [\"Eyelid\"] = \"Eyelid\",\r\n [\"Eyeleds\"] = \"Eyelid\",\r\n [\"Mouth\"] = \"Mouth\",\r\n [\"Lip\"] = \"Lip\",\r\n [\"Nose\"] = \"Nose\",\r\n [\"Muzzle\"] = \"Muzzle\",\r\n [\"Chin\"] = \"Chin\",\r\n [\"Cheek\"] = \"Cheek\",\r\n [\"Torso\"] = \"Body\",\r\n [\"LegAnkle\"] = \"Ankle\",\r\n [\"Fingers\"] = \"Finger\",\r\n [\"Tail\"] = \"Tail\",\r\n [\"Tai\"] = \"Tail\",\r\n [\"Wing\"] = \"Wing\",\r\n [\"RWing\"] = \"Right Wing\",\r\n [\"LWing\"] = \"Left Wing\",\r\n [\"Feather\"] = \"Feather\",\r\n [\"Feeler\"] = \"Antenna\",\r\n [\"Antenna\"] = \"Antenna\",\r\n [\"Feelers\"] = \"Barbel\",\r\n [\"Tentacle\"] = \"Tentacle\",\r\n [\"Tentacles\"] = \"Tentacle\",\r\n [\"Claw\"] = \"Claw\",\r\n [\"HandClaw\"] = \"Hand Claw\",\r\n [\"Fang\"] = \"Fang\",\r\n [\"Fangs\"] = \"Fang\",\r\n [\"Mandible\"] = \"Mandible\",\r\n [\"BigMandible\"] = \"Large Mandible\",\r\n [\"LowerMandible\"] = \"Lower Mandible\",\r\n [\"Pincer\"] = \"Pincer\",\r\n [\"pincers\"] = \"Pincer\",\r\n [\"Pliers\"] = \"Pincer\",\r\n [\"Piers\"] = \"Pincer\",\r\n [\"Mane\"] = \"Mane\",\r\n [\"Fur\"] = \"Fur\",\r\n [\"Hair\"] = \"Mane\",\r\n [\"Beard\"] = \"Whisker\",\r\n [\"Mascara\"] = \"Whisker\",\r\n [\"Shell\"] = \"Shell\",\r\n [\"Stinger\"] = \"Stinger\",\r\n [\"Horn\"] = \"Horn\",\r\n [\"dorsal\"] = \"Dorsal Plate\",\r\n [\"Fin\"] = \"Fin\",\r\n [\"ponitail\"] = \"Crest\",\r\n [\"Ponytail\"] = \"Appendage\",\r\n [\"Downbody\"] = \"Lower Body\",\r\n [\"Down\"] = \"Lower Body\",\r\n [\"Body\"] = \"Body\",\r\n [\"Reins\"] = \"Reins\",\r\n [\"Halter\"] = \"Halter\",\r\n [\"Jiggle\"] = \"Jiggle\",\r\n [\"TwistBone\"] = \"Twist\",\r\n [\"UpperArmTwist\"] = \"Upper Arm Twist\",\r\n [\"ForearmTwist\"] = \"Forearm Twist\",\r\n [\"ThighMuscle\"] = \"Thigh Muscle\",\r\n [\"NeckMuscle\"] = \"Neck Muscle\",\r\n [\"Clip\"] = \"Mandible\",\r\n [\"Wings\"] = \"Wing\",\r\n [\"Shall\"] = \"Mandible\",\r\n [\"Trajectory\"] = \"Root\",\r\n [\"locator\"] = \"Root\",\r\n [\"locator2\"] = \"Root\",\r\n [\"center\"] = \"Center\",\r\n [\"MagicEffectsNode\"] = \"Bone\",\r\n [\"Handle\"] = \"Handle\",\r\n [\"IK_Chain\"] = \"IK Chain\",\r\n [\"Joint\"] = \"\",\r\n [\"Jnt\"] = \"\",\r\n [\"Internal\"] = \"\",\r\n [\"Def\"] = \"\",\r\n [\"Part\"] = \"\",\r\n [\"Armature\"] = \"\",\r\n [\"Mixamorig\"] = \"\",\r\n [\"Character\"] = \"\",\r\n [\"Rig\"] = \"\",\r\n [\"Bip\"] = \"\",\r\n [\"Base\"] = \"\",\r\n [\"Human\"] = \"\",\r\n [\"Mid\"] = \"\",\r\n [\"Controller\"] = \"\",\r\n [\"Controler\"] = \"\",\r\n [\"Ctrl\"] = \"\",\r\n [\"Quick\"] = \"\",\r\n [\"Untitled\"] = \"\",\r\n [\"Pasted\"] = \"\",\r\n [\"Bind\"] = \"\",\r\n [\"Skeleton\"] = \"\",\r\n [\"Reference\"] = \"\",\r\n [\"Res\"] = \"\",\r\n [\"Main\"] = \"\",\r\n [\"Metacarpal\"] = \"\",\r\n [\"Proximal\"] = \"\",\r\n [\"Intermediate\"] = \"\",\r\n [\"Medial\"] = \"\",\r\n [\"Distal\"] = \"\",\r\n [\"Digit\"] = \"Finger\",\r\n [\"FrontLeg\"] = \"Front Leg\",\r\n [\"MiddleLeg\"] = \"Middle Leg\",\r\n [\"HindLeg\"] = \"Hind Leg\",\r\n [\"Crab_pincers\"] = \"Pincer\",\r\n [\"SpineR\"] = \"Right Abdomen\",\r\n [\"SpineL\"] = \"Left Abdomen\",\r\n };\r\n static readonly Dictionary<string, string> MixamoMap = new(StringComparer.Ordinal)\r\n {\r\n [\"Hips\"] = \"Hips\",\r\n [\"Spine\"] = \"Spine\",\r\n [\"Spine1\"] = \"Spine\",\r\n [\"Spine2\"] = \"Spine\",\r\n [\"Neck\"] = \"Neck\",\r\n [\"Head\"] = \"Head\",\r\n [\"HeadTop_End\"] = \"Head End\",\r\n [\"Eye\"] = \"Eye\",\r\n [\"Shoulder\"] = \"Shoulder\",\r\n [\"Arm\"] = \"Upper Arm\",\r\n [\"ForeArm\"] = \"Forearm\",\r\n [\"Hand\"] = \"Hand\",\r\n [\"HandThumb\"] = \"Thumb Finger\",\r\n [\"HandIndex\"] = \"Index Finger\",\r\n [\"HandMiddle\"] = \"Middle Finger\",\r\n [\"HandRing\"] = \"Ring Finger\",\r\n [\"HandPinky\"] = \"Pinky Finger\",\r\n [\"UpLeg\"] = \"Thigh\",\r\n [\"Leg\"] = \"Shin\",\r\n [\"Foot\"] = \"Foot\",\r\n [\"ToeBase\"] = \"Toe\",\r\n [\"Toe_End\"] = \"Toe End\",\r\n };\r\n public static readonly string[] SymmetricPairPriority = { \"Thigh\", \"Shoulder\", \"Front Shoulder\", \"Back Hip\", \"Hip\", \"Scapula\", \"Upper Arm\", \"Arm\", \"Front Leg\", \"Hind Leg\", \"Middle Leg\", \"Back Leg\", \"Wing\", \"Leg\", \"Pectoral Fin\", \"Pelvic Fin\", \"Fin\", \"Gill\", \"Pincer\", \"Mandible\", \"Large Mandible\", \"Lower Mandible\", \"Stinger\", \"Claw\", \"Hand Claw\", \"Antenna\", \"Forearm\", \"Shin\", \"Knee\", \"Elbow\", \"Ankle\", \"Wrist\", \"Hand\", \"Palm\", \"Foot\", \"Heel\", \"Front Paw\", \"Back Paw\", \"Paw\", \"Front Hoof\", \"Rear Hoof\", \"Hoof\", \"Fetlock\", \"Cannon\", \"Metacarpus\", \"Pastern\", \"Toe\", \"Thumb Finger\", \"Index Finger\", \"Middle Finger\", \"Ring Finger\", \"Pinky Finger\", \"Finger\", \"Neck\", \"Eye\", \"Eyeball\", \"Eyelid\", \"Eyebrow\", \"Ear\", \"Horn\", \"Cheek\", \"Whisker\", \"Fang\", \"Barbel\", \"Tentacle\", \"Feather\", \"Tail\" };\r\n public static readonly string[] TailKeywords = { \"Tail\", \"Tail Twist\" };\r\n public static readonly string[] HeadKeywords = { \"Chin\", \"Head\", \"Head End\", \"Jaw\", \"Lower Jaw\", \"Muzzle\", \"Nose\", \"Skull\", \"Skull Base\", \"Tongue\", \"Upper Jaw\" };\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Code/TextToAnimation/Rig/TargetRig.cs",
"FileName": "TargetRig.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "#nullable enable annotations\r\n\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Numerics;\r\nusing System.Text.Json;\r\nusing TextToAnimation.Mapping;\r\nusing TextToAnimation.Maths;\r\nusing TextToAnimation.Rig;\r\nusing SkeletonModel = TextToAnimation.Rig.Skeleton;\r\n\r\nnamespace TextToAnimation.Rig;\r\n\r\nusing Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/TextToAnimation/Assembly.cs)\r\n\r\n/// <summary>\r\n/// A humanoid target rig: skeleton plus per-bone <see cref=\"BoneClass\"/> and (for animated\r\n/// bones) canonical <see cref=\"BoneRole\"/> annotations. The shipped s&box default is\r\n/// loaded from the committed <c>Assets/humanoid_retargeter/target_rig_sbox.json</c> produced\r\n/// by <see cref=\"TargetRigGenerator\"/> (see <see cref=\"SboxDefault\"/>); arbitrary user-picked\r\n/// targets are built from any <see cref=\"SkeletonModel\"/> via <see cref=\"FromSkeleton\"/>.\r\n/// This type does no file IO \u2014 callers pass JSON text.\r\n/// </summary>\r\npublic sealed class TargetRig\r\n{\r\n private readonly BoneClass[] _classes;\r\n private readonly BoneRole?[] _roles;\r\n private readonly Dictionary<BoneRole, int> _boneByRole;\r\n private readonly Vector3?[]? _tailWorld;\r\n\r\n private TargetRig(string name, SkeletonModel skeleton, BoneClass[] classes, BoneRole?[] roles,\r\n Dictionary<BoneRole, int> boneByRole, Vector3?[]? tailWorld = null,\r\n bool helpersAreConstraintDriven = true)\r\n {\r\n Name = name;\r\n Skeleton = skeleton;\r\n _classes = classes;\r\n _roles = roles;\r\n _boneByRole = boneByRole;\r\n _tailWorld = tailWorld;\r\n HelpersAreConstraintDriven = helpersAreConstraintDriven;\r\n }\r\n\r\n /// <summary>Rig name (e.g. <c>sbox_human_male</c>).</summary>\r\n public string Name { get; }\r\n\r\n /// <summary>\r\n /// True when the target model's own vmdl re-drives the\r\n /// <see cref=\"BoneClass.ConstraintDriven\"/> helpers at runtime (the shipped s&box\r\n /// rigs: their AnimConstraintList drives twist/helper bones, so clips exclude those\r\n /// channels). False for rigs built from plain skeletons\r\n /// (<see cref=\"FromSkeleton\"/> \u2014 custom FBX targets): the generated vmdl carries NO\r\n /// constraints, so twist-named bones must keep baked channels or they freeze at rest\r\n /// (candy-wrapped wrists on an Auto-Rig Pro export).\r\n /// </summary>\r\n public bool HelpersAreConstraintDriven { get; }\r\n\r\n /// <summary>The target skeleton (rest pose in centimeters, parents before children).</summary>\r\n public SkeletonModel Skeleton { get; }\r\n\r\n /// <summary>The class of the bone at <paramref name=\"boneIndex\"/>.</summary>\r\n public BoneClass ClassOf(int boneIndex) => _classes[boneIndex];\r\n\r\n /// <summary>The canonical role of the bone at <paramref name=\"boneIndex\"/>, or null\r\n /// for role-less (non-animated) bones.</summary>\r\n public BoneRole? RoleOf(int boneIndex) => _roles[boneIndex];\r\n\r\n /// <summary>The bone index carrying <paramref name=\"role\"/>, or null when the rig has no\r\n /// bone for it (e.g. <see cref=\"BoneRole.Spine3\"/>, <see cref=\"BoneRole.ThumbMetaL\"/>).</summary>\r\n public int? BoneForRole(BoneRole role) => _boneByRole.TryGetValue(role, out var index) ? index : null;\r\n\r\n /// <summary>\r\n /// Rest tail position of the bone in rig world space (cm), when the rig definition\r\n /// carries one (<c>tail_world</c> in the generated JSON \u2014 the s&box default rig\r\n /// does). Null for rigs built from plain skeletons (<see cref=\"FromSkeleton\"/>), which\r\n /// have no tail data. Used by the DL solver's end-joint synthesis.\r\n /// </summary>\r\n public Vector3? TailWorldOf(int boneIndex) => _tailWorld?[boneIndex];\r\n\r\n /// <summary>Uses an authoritative bind skeleton while retaining this rig's curated\r\n /// roles, helper classes and constraint ownership. Additional bones keep rest channels.</summary>\r\n public TargetRig WithBindPose(SkeletonModel skeleton)\r\n {\r\n ArgumentNullException.ThrowIfNull(skeleton);\r\n var classes = new BoneClass[skeleton.Count];\r\n var roles = new BoneRole?[skeleton.Count];\r\n var byRole = new Dictionary<BoneRole, int>();\r\n var tails = new Vector3?[skeleton.Count];\r\n for (var i = 0; i < skeleton.Count; i++)\r\n {\r\n var old = Skeleton.IndexOf(skeleton[i].Name);\r\n if (old < 0) continue;\r\n classes[i] = _classes[old];\r\n roles[i] = _roles[old];\r\n if (roles[i] is { } role) byRole.Add(role, i);\r\n if (TailWorldOf(old) is { } tail)\r\n {\r\n var relative = Vector3.Transform(tail - Skeleton.RestWorld[old].Pos, Quaternion.Inverse(Skeleton.RestWorld[old].Rot));\r\n tails[i] = skeleton.RestWorld[i].Pos + Vector3.Transform(relative, skeleton.RestWorld[i].Rot);\r\n }\r\n }\r\n foreach (var role in _boneByRole.Keys)\r\n if (!byRole.ContainsKey(role)) throw new ArgumentException($\"New bind skeleton is missing role {role}.\", nameof(skeleton));\r\n return new TargetRig(Name, skeleton, classes, roles, byRole, tails, HelpersAreConstraintDriven);\r\n }\r\n\r\n /// <summary>Indices of all bones of the given class, in skeleton order.</summary>\r\n public IEnumerable<int> BonesOfClass(BoneClass boneClass)\r\n {\r\n for (var i = 0; i < _classes.Length; i++)\r\n {\r\n if (_classes[i] == boneClass)\r\n yield return i;\r\n }\r\n }\r\n\r\n /// <summary>\r\n /// Parses a target-rig definition produced by <see cref=\"TargetRigGenerator\"/>.\r\n /// </summary>\r\n /// <exception cref=\"ArgumentException\">Thrown when the JSON does not match the expected\r\n /// schema or violates invariants (e.g. a role on a non-animated bone, duplicate roles).</exception>\r\n public static TargetRig Load(string json)\r\n {\r\n ArgumentNullException.ThrowIfNull(json);\r\n using var doc = JsonDocument.Parse(json);\r\n var root = doc.RootElement;\r\n\r\n var name = root.GetProperty(\"name\").GetString()\r\n ?? throw new ArgumentException(\"Target rig JSON has no 'name'.\");\r\n\r\n var bonesJson = root.GetProperty(\"bones\");\r\n var count = bonesJson.GetArrayLength();\r\n var definitions = new List<BoneDefinition>(count);\r\n var classByName = new Dictionary<string, BoneClass>(count, StringComparer.Ordinal);\r\n var roleByName = new Dictionary<string, BoneRole>(count, StringComparer.Ordinal);\r\n var tailByName = new Dictionary<string, Vector3>(count, StringComparer.Ordinal);\r\n\r\n foreach (var bone in bonesJson.EnumerateArray())\r\n {\r\n // Shared bone-geometry shape (name/parent/local_pos/local_rot_xyzw) \u2014 one reader\r\n // for rig ground-truth JSON and target-rig JSON (RigJson owns it).\r\n var definition = RigJson.ReadBoneDefinition(bone);\r\n var boneName = definition.Name;\r\n definitions.Add(definition);\r\n\r\n var boneClass = Enum.Parse<BoneClass>(bone.GetProperty(\"class\").GetString()!);\r\n classByName[boneName] = boneClass;\r\n\r\n if (bone.TryGetProperty(\"role\", out var roleProp))\r\n {\r\n if (boneClass != BoneClass.Animated)\r\n throw new ArgumentException(\r\n $\"Bone '{boneName}' is {boneClass} but carries a role \u2014 only Animated bones may have roles.\");\r\n roleByName[boneName] = Enum.Parse<BoneRole>(roleProp.GetString()!);\r\n }\r\n\r\n if (bone.TryGetProperty(\"tail_world\", out var tailProp)\r\n && tailProp.ValueKind == JsonValueKind.Array && tailProp.GetArrayLength() == 3)\r\n {\r\n tailByName[boneName] = new Vector3(\r\n (float)tailProp[0].GetDouble(),\r\n (float)tailProp[1].GetDouble(),\r\n (float)tailProp[2].GetDouble());\r\n }\r\n }\r\n\r\n var skeleton = SkeletonModel.Create(definitions);\r\n\r\n var classes = new BoneClass[skeleton.Count];\r\n var roles = new BoneRole?[skeleton.Count];\r\n var boneByRole = new Dictionary<BoneRole, int>();\r\n var tails = tailByName.Count > 0 ? new Vector3?[skeleton.Count] : null;\r\n for (var i = 0; i < skeleton.Count; i++)\r\n {\r\n var boneName = skeleton[i].Name;\r\n classes[i] = classByName[boneName];\r\n if (roleByName.TryGetValue(boneName, out var role))\r\n {\r\n roles[i] = role;\r\n if (!boneByRole.TryAdd(role, i))\r\n throw new ArgumentException($\"Role {role} is assigned to more than one bone.\");\r\n }\r\n if (tails is not null && tailByName.TryGetValue(boneName, out var tail))\r\n tails[i] = tail;\r\n }\r\n\r\n return new TargetRig(name, skeleton, classes, roles, boneByRole, tails);\r\n }\r\n\r\n /// <summary>\r\n /// Parses the shipped s&box default target rig from the committed\r\n /// <c>Assets/humanoid_retargeter/target_rig_sbox.json</c> text (callers do the file IO).\r\n /// Alias of <see cref=\"Load\"/>, named per design \u00a71 to distinguish the curated default\r\n /// target from <see cref=\"FromSkeleton\"/> custom targets.\r\n /// </summary>\r\n public static TargetRig SboxDefault(string targetRigJson) => Load(targetRigJson);\r\n\r\n /// <summary>\r\n /// Builds a target rig from an arbitrary humanoid skeleton (design \u00a71 \"Custom targets\"):\r\n /// roles come from <paramref name=\"map\"/> (the same detection/mapping used for sources),\r\n /// bone classes from <paramref name=\"rules\"/> name patterns (default\r\n /// <see cref=\"BoneClassRules\"/>). Any bone that carries a mapped role is forced\r\n /// <see cref=\"BoneClass.Animated\"/> \u2014 a role always wins over a twist-like name. That is\r\n /// what makes ActorCore rigs work: <c>CC_Base_NeckTwist01</c> matches the twist pattern\r\n /// but IS the neck bone (the actorcore_cc profile maps it to <see cref=\"BoneRole.Neck\"/>),\r\n /// so it ends up Animated.\r\n /// </summary>\r\n /// <exception cref=\"ArgumentException\">Thrown when the mapping references a bone index\r\n /// outside the skeleton or assigns two roles to the same bone.</exception>\r\n public static TargetRig FromSkeleton(SkeletonModel skeleton, MappingResult map, BoneClassRules? rules = null)\r\n {\r\n ArgumentNullException.ThrowIfNull(skeleton);\r\n ArgumentNullException.ThrowIfNull(map);\r\n rules ??= new BoneClassRules();\r\n\r\n var roles = new BoneRole?[skeleton.Count];\r\n var boneByRole = new Dictionary<BoneRole, int>(map.RoleToBone.Count);\r\n foreach (var (role, boneIndex) in map.RoleToBone)\r\n {\r\n if (boneIndex < 0 || boneIndex >= skeleton.Count)\r\n throw new ArgumentException(\r\n $\"Mapping assigns role {role} to bone index {boneIndex}, outside the skeleton (count {skeleton.Count}).\",\r\n nameof(map));\r\n if (roles[boneIndex] is { } existing)\r\n throw new ArgumentException(\r\n $\"Bone '{skeleton[boneIndex].Name}' carries both roles {existing} and {role}.\", nameof(map));\r\n roles[boneIndex] = role;\r\n boneByRole.Add(role, boneIndex);\r\n }\r\n\r\n var classes = new BoneClass[skeleton.Count];\r\n for (var i = 0; i < skeleton.Count; i++)\r\n classes[i] = roles[i] is not null ? BoneClass.Animated : rules.Classify(skeleton[i].Name);\r\n\r\n // No curated vmdl backs this rig: whatever we generate for it carries no\r\n // AnimConstraintList, so its helper bones need real baked channels.\r\n return new TargetRig(map.ProfileName, skeleton, classes, roles, boneByRole,\r\n tailWorld: null, helpersAreConstraintDriven: false);\r\n }\r\n\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "Code/TextToAnimation/Vmdl/VmdlWriter.cs",
"FileName": "VmdlWriter.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "#nullable enable annotations\r\n\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Globalization;\r\nusing System.IO;\r\nusing System.Linq;\r\n\r\nnamespace TextToAnimation.Vmdl;\r\n\r\n/// <summary>\r\n/// One AnimEvent to attach as a child of a vmdl AnimFile node. The node shape replicates the\r\n/// shipped citizen animation list prefab exactly (all 28 shipped events are\r\n/// <c>AE_FOOTSTEP</c>): <c>_class = \"AnimEvent\"</c>, <c>event_class</c>, <c>event_frame</c>\r\n/// (integer frame on the clip's grid), and an <c>event_keys</c> object carrying\r\n/// <c>Attachment</c> (<c>\"foot_L\"</c>/<c>\"foot_R\"</c>), <c>Foot</c> (STRING <c>\"0\"</c> =\r\n/// left, <c>\"1\"</c> = right) and <c>Volume</c> (<c>0.7</c> throughout the shipped data).\r\n/// </summary>\r\npublic sealed class AnimEventEntry\r\n{\r\n /// <summary>Event class name (e.g. <c>AE_FOOTSTEP</c>).</summary>\r\n public string EventClass { get; set; } = \"\";\r\n\r\n /// <summary>Frame the event fires on (the clip's own frame grid).</summary>\r\n public int Frame { get; set; }\r\n\r\n /// <summary>event_keys <c>Attachment</c> value (<c>\"foot_L\"</c>/<c>\"foot_R\"</c> in the\r\n /// shipped footstep data); null omits the key.</summary>\r\n public string? Attachment { get; set; }\r\n\r\n /// <summary>event_keys <c>Foot</c> value \u2014 the shipped data encodes the side as a STRING:\r\n /// <c>\"0\"</c> = left, <c>\"1\"</c> = right; null omits the key.</summary>\r\n public string? Foot { get; set; }\r\n\r\n /// <summary>event_keys <c>Volume</c> value (<c>0.7</c> in all shipped footstep events);\r\n /// null omits the key.</summary>\r\n public double? Volume { get; set; }\r\n\r\n /// <summary>\r\n /// Additional string event_keys emitted after the fixed keys, in insertion order.\r\n /// (W3b addition, southpaw project: punch clips carry semantic contact tags such as\r\n /// <c>Zone</c>/<c>Family</c>/<c>Hand</c> on their contact-frame AnimEvent; the shape\r\n /// stays the shipped citizen AnimEvent node shape, only extra keys appear.) Null or\r\n /// empty adds nothing, so existing callers are unaffected.\r\n /// </summary>\r\n public IReadOnlyDictionary<string, string>? ExtraKeys { get; set; }\r\n}\r\n\r\n/// <summary>One animation to register in a vmdl AnimationList.</summary>\r\npublic sealed class AnimEntry\r\n{\r\n /// <summary>Sequence name (must be unique within the AnimationList).</summary>\r\n public string Name { get; set; } = \"\";\r\n\r\n /// <summary>Animation source path relative to the assets root\r\n /// (e.g. <c>models/x/animations/walk.dmx</c>).</summary>\r\n public string SourceFilename { get; set; } = \"\";\r\n\r\n /// <summary>Whether the sequence loops.</summary>\r\n public bool Looping { get; set; }\r\n\r\n /// <summary>Whether to add an ExtractMotion child node (ground-plane translation\r\n /// extraction, linear, matching the shipped citizen prefab usage).</summary>\r\n public bool ExtractMotion { get; set; }\r\n\r\n /// <summary>AnimEvent children to emit on the AnimFile node (e.g. generated\r\n /// <c>AE_FOOTSTEP</c> events); empty = no event nodes.</summary>\r\n public IReadOnlyList<AnimEventEntry> Events { get; set; } = Array.Empty<AnimEventEntry>();\r\n\r\n /// <summary>\r\n /// When set, the AnimFile node gets an <c>AnimSubtract</c> child (first child, like the\r\n /// shipped citizen data orders it) making the sequence an additive delta at compile time:\r\n /// <c>anim_name</c> = this value (the sequence whose reference frame is subtracted \u2014\r\n /// the shipped <c>IdleLayer_01_delta</c> names its base sequence; other shipped entries\r\n /// self-reference), <c>frame</c> = <see cref=\"SubtractFrame\"/>. The animation source is\r\n /// reused verbatim (<see cref=\"SourceFilename\"/> points at the SAME file as the base\r\n /// entry) \u2014 resourcecompiler performs the per-frame subtraction; no frame math happens\r\n /// here. The AnimFile's own <c>delta</c> attribute stays <c>false</c>, exactly like every\r\n /// shipped <c>_delta</c> sequence. Null = plain (non-additive) sequence.\r\n /// </summary>\r\n public string? SubtractAnimName { get; set; }\r\n\r\n /// <summary>Reference frame index the AnimSubtract child subtracts (the shipped data uses\r\n /// 0 for loops and mid-clip indices for aim matrices); ignored while\r\n /// <see cref=\"SubtractAnimName\"/> is null.</summary>\r\n public int SubtractFrame { get; set; }\r\n}\r\n\r\n/// <summary>\r\n/// One detected locomotion family to emit as a directional 2D blend: a <c>Folder</c> named by\r\n/// the family stem grouping the member AnimFile entries plus one <c>2DBlend</c> node wired to\r\n/// the citizen pose parameters (<c>move_x</c>/<c>move_y</c>). Produced by\r\n/// <see cref=\"LocomotionSetDetector\"/>; consumed by <see cref=\"VmdlWriter\"/> and\r\n/// <see cref=\"VmdlAugmenter\"/>.\r\n/// </summary>\r\npublic sealed class LocomotionSetSpec\r\n{\r\n /// <summary>Name of the Folder node grouping the family (the stem, collision-suffixed).</summary>\r\n public required string FolderName { get; init; }\r\n\r\n /// <summary>Name of the 2DBlend node (<c><stem>_2D</c>, collision-suffixed).</summary>\r\n public required string BlendName { get; init; }\r\n\r\n /// <summary>Looping flag of the 2DBlend node (true when every member loops; the shipped\r\n /// locomotion blends are all looping).</summary>\r\n public required bool Looping { get; init; }\r\n\r\n /// <summary>\r\n /// The 3\u00d73 <c>blend_anim_list</c> grid, <c>[row][col]</c> with rows indexed by\r\n /// <c>move_x</c> (\u22121, 0, +1) and columns by <c>move_y</c> (\u22121, 0, +1) \u2014 the exact shipped\r\n /// citizen layout: row 0 = [SW, S, SE], row 1 = [W, center, E], row 2 = [NW, N, NE].\r\n /// </summary>\r\n public required string[][] BlendGrid { get; init; }\r\n\r\n /// <summary>Names of the batch AnimFile entries grouped under the Folder, in canonical\r\n /// direction order (N, NE, E, SE, S, SW, W, NW; absent diagonals skipped).</summary>\r\n public required IReadOnlyList<string> MemberNames { get; init; }\r\n}\r\n\r\n/// <summary>\r\n/// Generates standalone Base-Model vmdl files (KV3 text) that reference an existing model and\r\n/// register retargeted animation DMX files, following the shipped citizen vmdl conventions\r\n/// (field set proven to compile in M0 via <c>m0_test.vmdl</c>).\r\n/// </summary>\r\npublic static class VmdlWriter\r\n{\r\n /// <summary>The KV3 header line used by shipped modeldoc vmdl files.</summary>\r\n public const string Kv3Header =\r\n \"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->\";\r\n\r\n /// <summary>\r\n /// Builds a standalone vmdl: RootNode with <c>base_model_name</c> =\r\n /// <paramref name=\"baseModelPath\"/>, an optional ModelModifierList/ScaleAndMirror node\r\n /// (omitted entirely when <paramref name=\"scale\"/> is 1.0 \u2014 engine-unit sources need no\r\n /// rescale; 0.3937 converts cm sources like the citizen rig), and an AnimationList with\r\n /// one AnimFile per entry. When <paramref name=\"locomotionSets\"/> is non-empty, each\r\n /// set's member entries are grouped under a Folder node (appended after the loose\r\n /// entries) together with the set's 2DBlend node, replicating the shipped citizen\r\n /// locomotion layout. When <paramref name=\"meshFilePath\"/> is non-empty a\r\n /// RenderMeshList/RenderMeshFile node embeds that mesh source (assets-relative,\r\n /// <paramref name=\"meshImportScale\"/> raw-units\u2192skeleton-units) \u2014 this is what gives\r\n /// custom-FBX-target vmdls a skeleton and skin to play on (an empty\r\n /// <c>base_model_name</c> with no mesh compiles into a model with 0 bones and 0\r\n /// sequences).\r\n /// </summary>\r\n public static string GenerateStandalone(string baseModelPath, IEnumerable<AnimEntry> anims,\r\n float scale, string defaultRootBone,\r\n IReadOnlyList<LocomotionSetSpec>? locomotionSets = null,\r\n string meshFilePath = \"\", float meshImportScale = 1.0f,\r\n IReadOnlyDictionary<string, string>? materialRemaps = null,\r\n IReadOnlyList<string>? meshImportNames = null)\r\n {\r\n ArgumentNullException.ThrowIfNull(baseModelPath);\r\n ArgumentNullException.ThrowIfNull(anims);\r\n ArgumentNullException.ThrowIfNull(defaultRootBone);\r\n\r\n var children = new KvArray();\r\n\r\n if (!string.IsNullOrEmpty(meshFilePath))\r\n {\r\n children.Items.Add(BuildRenderMeshListNode(meshFilePath, meshImportScale, meshImportNames));\r\n // Retarget channels may drive unweighted root/helper joints. ModelDoc otherwise\r\n // culls them from imported meshes, breaking the hierarchy the animation targets.\r\n children.Items.Add(new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"BoneMarkupList\"),\r\n [\"children\"] = new KvArray(),\r\n [\"bone_cull_type\"] = new KvString(\"None\"),\r\n });\r\n }\r\n if (materialRemaps is { Count: > 0 })\r\n children.Items.Add(BuildMaterialGroupListNode(materialRemaps));\r\n\r\n if (scale != 1.0f)\r\n {\r\n var modifier = new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"ModelModifier_ScaleAndMirror\"),\r\n // float -> shortest-round-trip string -> double keeps \"0.3937\" exact in text.\r\n [\"scale\"] = new KvDouble(\r\n double.Parse(scale.ToString(\"R\", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture)),\r\n [\"mirror_x\"] = new KvBool(false),\r\n [\"mirror_y\"] = new KvBool(false),\r\n [\"mirror_z\"] = new KvBool(false),\r\n [\"flip_bone_forward\"] = new KvBool(false),\r\n [\"swap_left_and_right_bones\"] = new KvBool(false),\r\n };\r\n var modifierChildren = new KvArray();\r\n modifierChildren.Items.Add(modifier);\r\n children.Items.Add(new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"ModelModifierList\"),\r\n [\"children\"] = modifierChildren,\r\n });\r\n }\r\n\r\n var sets = locomotionSets ?? Array.Empty<LocomotionSetSpec>();\r\n var grouped = new HashSet<string>(StringComparer.Ordinal);\r\n foreach (var set in sets)\r\n {\r\n foreach (var member in set.MemberNames)\r\n grouped.Add(member);\r\n }\r\n\r\n var animList = anims as IReadOnlyList<AnimEntry> ?? new List<AnimEntry>(anims);\r\n var animChildren = new KvArray();\r\n foreach (var anim in animList)\r\n {\r\n if (!grouped.Contains(anim.Name))\r\n animChildren.Items.Add(BuildAnimFileNode(anim, defaultRootBone));\r\n }\r\n foreach (var set in sets)\r\n animChildren.Items.Add(BuildLocomotionFolderNode(set, animList, defaultRootBone));\r\n children.Items.Add(new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"AnimationList\"),\r\n [\"children\"] = animChildren,\r\n [\"default_root_bone_name\"] = new KvString(defaultRootBone),\r\n });\r\n\r\n var root = new KvObject\r\n {\r\n [\"rootNode\"] = new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"RootNode\"),\r\n [\"children\"] = children,\r\n [\"model_archetype\"] = new KvString(\"\"),\r\n [\"primary_associated_entity\"] = new KvString(\"\"),\r\n [\"anim_graph_name\"] = new KvString(\"\"),\r\n [\"base_model_name\"] = new KvString(baseModelPath),\r\n },\r\n };\r\n\r\n return Kv3.Serialize(new Kv3Document(Kv3Header, root));\r\n }\r\n\r\n /// <summary>\r\n /// Builds one AnimFile KV3 node (full attribute set as compiled in M0). When the entry\r\n /// requests motion extraction, an ExtractMotion child extracting ground-plane translation\r\n /// on <paramref name=\"motionRootBone\"/> is included; <see cref=\"AnimEntry.Events\"/>\r\n /// become AnimEvent children (shipped-prefab node shape, see\r\n /// <see cref=\"AnimEventEntry\"/>); <see cref=\"AnimEntry.SubtractAnimName\"/> adds the\r\n /// AnimSubtract FIRST child the shipped <c>_delta</c> sequences carry\r\n /// (<c>_class</c>/<c>anim_name</c>/<c>frame</c>, nothing else).\r\n /// </summary>\r\n internal static KvObject BuildAnimFileNode(AnimEntry entry, string motionRootBone)\r\n {\r\n ArgumentNullException.ThrowIfNull(entry);\r\n\r\n var node = new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"AnimFile\"),\r\n [\"name\"] = new KvString(entry.Name),\r\n };\r\n\r\n var nodeChildren = new KvArray();\r\n if (entry.SubtractAnimName is not null)\r\n {\r\n nodeChildren.Items.Add(new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"AnimSubtract\"),\r\n [\"anim_name\"] = new KvString(entry.SubtractAnimName),\r\n [\"frame\"] = new KvLong(entry.SubtractFrame),\r\n });\r\n }\r\n if (entry.ExtractMotion)\r\n {\r\n var extract = new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"ExtractMotion\"),\r\n [\"extract_tx\"] = new KvBool(true),\r\n [\"extract_ty\"] = new KvBool(true),\r\n [\"extract_tz\"] = new KvBool(false),\r\n [\"extract_rz\"] = new KvBool(false),\r\n [\"linear\"] = new KvBool(true),\r\n [\"quadratic\"] = new KvBool(false),\r\n [\"root_bone_name\"] = new KvString(motionRootBone),\r\n [\"motion_type\"] = new KvString(\"Single\"),\r\n };\r\n nodeChildren.Items.Add(extract);\r\n }\r\n foreach (var animEvent in entry.Events)\r\n nodeChildren.Items.Add(BuildAnimEventNode(animEvent));\r\n if (nodeChildren.Items.Count > 0)\r\n node[\"children\"] = nodeChildren;\r\n\r\n node[\"activity_name\"] = new KvString(\"\");\r\n node[\"activity_weight\"] = new KvLong(1);\r\n node[\"weight_list_name\"] = new KvString(\"\");\r\n node[\"fade_in_time\"] = new KvDouble(0.2);\r\n node[\"fade_out_time\"] = new KvDouble(0.2);\r\n node[\"looping\"] = new KvBool(entry.Looping);\r\n node[\"delta\"] = new KvBool(false);\r\n node[\"worldSpace\"] = new KvBool(false);\r\n node[\"hidden\"] = new KvBool(false);\r\n node[\"anim_markup_ordered\"] = new KvBool(false);\r\n node[\"disable_compression\"] = new KvBool(false);\r\n node[\"disable_interpolation\"] = new KvBool(false);\r\n node[\"enable_scale\"] = new KvBool(false);\r\n node[\"source_filename\"] = new KvString(entry.SourceFilename);\r\n node[\"start_frame\"] = new KvLong(-1);\r\n node[\"end_frame\"] = new KvLong(-1);\r\n node[\"framerate\"] = new KvDouble(-1.0);\r\n node[\"take\"] = new KvLong(0);\r\n node[\"reverse\"] = new KvBool(false);\r\n return node;\r\n }\r\n\r\n /// <summary>\r\n /// Builds one AnimEvent KV3 node in the shipped citizen prefab shape:\r\n /// <c>_class</c>/<c>event_class</c>/<c>event_frame</c> plus an <c>event_keys</c> object\r\n /// (key order <c>Attachment</c>, <c>Foot</c>, <c>Volume</c>, exactly like the shipped\r\n /// data; null-valued keys are omitted, and an all-null key set omits the object).\r\n /// </summary>\r\n /// <summary>A <c>RenderMeshList</c> node with one <c>RenderMeshFile</c> child (field\r\n /// set mirrors the shipped citizen_human_male_staging.vmdl). Shared with\r\n /// <see cref=\"VmdlAugmenter.EnsureMeshFile\"/> so accumulated pipeline-owned vmdls that\r\n /// predate mesh embedding can be healed in place.</summary>\r\n internal static KvObject BuildRenderMeshListNode(string meshFilePath, float meshImportScale,\r\n IReadOnlyList<string>? meshImportNames = null)\r\n {\r\n var meshChildren = new KvArray();\r\n // Keep source mesh instances separate: merging a large multi-part FBX into one\r\n // skin buffer can corrupt rendered vertices despite correct bones and weights.\r\n IEnumerable<string?> parts = meshImportNames is { Count: > 1 }\r\n ? (IEnumerable<string?>)meshImportNames.Distinct(StringComparer.OrdinalIgnoreCase) : new string?[] { null };\r\n foreach (var part in parts)\r\n {\r\n var names = new KvArray();\r\n if (part is not null)\r\n names.Items.Add(new KvString(part));\r\n // Do not reuse an input mesh name as a ModelDoc node name (self-reference).\r\n var name = Path.GetFileNameWithoutExtension(meshFilePath);\r\n if (part is not null)\r\n name += $\"_part_{meshChildren.Items.Count}\";\r\n var node = new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"RenderMeshFile\"),\r\n [\"name\"] = new KvString(name),\r\n [\"filename\"] = new KvString(meshFilePath),\r\n [\"import_translation\"] = MakeVector3(0, 0, 0),\r\n [\"import_rotation\"] = MakeVector3(0, 0, 0),\r\n [\"import_scale\"] = new KvDouble(\r\n double.Parse(meshImportScale.ToString(\"R\", CultureInfo.InvariantCulture),\r\n CultureInfo.InvariantCulture)),\r\n [\"align_origin_x_type\"] = new KvString(\"None\"),\r\n [\"align_origin_y_type\"] = new KvString(\"None\"),\r\n [\"align_origin_z_type\"] = new KvString(\"None\"),\r\n [\"parent_bone\"] = new KvString(\"\"),\r\n [\"import_filter\"] = new KvObject\r\n {\r\n [\"exclude_by_default\"] = new KvBool(part is not null),\r\n [\"exception_list\"] = names,\r\n },\r\n };\r\n meshChildren.Items.Add(node);\r\n }\r\n return new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"RenderMeshList\"),\r\n [\"children\"] = meshChildren,\r\n };\r\n }\r\n\r\n /// <summary>\r\n /// A <c>MaterialGroupList</c> with one <c>DefaultMaterialGroup</c> carrying material\r\n /// remaps (bare mesh material reference \u2192 assets-relative vmat path) \u2014 the shipped\r\n /// citizen vmdl's own mechanism. FBX materials are bare names the compiler cannot\r\n /// resolve as resource paths; without the remap every mesh logs\r\n /// 'Missing vmat \"<name>.vmat\"' and renders placeholder materials.\r\n /// </summary>\r\n internal static KvObject BuildMaterialGroupListNode(IReadOnlyDictionary<string, string> remaps)\r\n {\r\n var remapArray = new KvArray();\r\n foreach (var (from, to) in remaps.OrderBy(kv => kv.Key, StringComparer.Ordinal))\r\n {\r\n remapArray.Items.Add(new KvObject\r\n {\r\n [\"from\"] = new KvString(from),\r\n [\"to\"] = new KvString(to),\r\n });\r\n }\r\n\r\n var groupChildren = new KvArray();\r\n groupChildren.Items.Add(new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"DefaultMaterialGroup\"),\r\n [\"remaps\"] = remapArray,\r\n [\"use_global_default\"] = new KvBool(false),\r\n [\"global_default_material\"] = new KvString(\"\"),\r\n });\r\n return new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"MaterialGroupList\"),\r\n [\"children\"] = groupChildren,\r\n };\r\n }\r\n\r\n /// <summary>KV3 <c>[ x, y, z ]</c> double array (RenderMeshFile import vectors).</summary>\r\n private static KvArray MakeVector3(double x, double y, double z)\r\n {\r\n var array = new KvArray();\r\n array.Items.Add(new KvDouble(x));\r\n array.Items.Add(new KvDouble(y));\r\n array.Items.Add(new KvDouble(z));\r\n return array;\r\n }\r\n\r\n private static KvObject BuildAnimEventNode(AnimEventEntry animEvent)\r\n {\r\n var node = new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"AnimEvent\"),\r\n [\"event_class\"] = new KvString(animEvent.EventClass),\r\n [\"event_frame\"] = new KvLong(animEvent.Frame),\r\n };\r\n\r\n var keys = new KvObject();\r\n if (animEvent.Attachment is not null)\r\n keys[\"Attachment\"] = new KvString(animEvent.Attachment);\r\n if (animEvent.Foot is not null)\r\n keys[\"Foot\"] = new KvString(animEvent.Foot);\r\n if (animEvent.Volume is { } volume)\r\n keys[\"Volume\"] = new KvDouble(volume);\r\n if (animEvent.ExtraKeys is not null)\r\n {\r\n foreach (var kv in animEvent.ExtraKeys)\r\n keys[kv.Key] = new KvString(kv.Value);\r\n }\r\n if (keys.Count > 0)\r\n node[\"event_keys\"] = keys;\r\n\r\n return node;\r\n }\r\n\r\n /// <summary>\r\n /// Builds a locomotion Folder node in the shipped citizen shape (<c>_class</c>,\r\n /// <c>name</c>, <c>children</c>): the set's 2DBlend node first, then the member AnimFile\r\n /// nodes (in <see cref=\"LocomotionSetSpec.MemberNames\"/> order) \u2014 mirroring how the\r\n /// shipped <c>CrouchWalk</c>/<c>Walk</c>/<c>Run</c> folders lead with their blend nodes.\r\n /// </summary>\r\n internal static KvObject BuildLocomotionFolderNode(\r\n LocomotionSetSpec set, IReadOnlyList<AnimEntry> allEntries, string motionRootBone)\r\n {\r\n ArgumentNullException.ThrowIfNull(set);\r\n ArgumentNullException.ThrowIfNull(allEntries);\r\n\r\n var folderChildren = new KvArray();\r\n folderChildren.Items.Add(Build2DBlendNode(set));\r\n foreach (var member in set.MemberNames)\r\n {\r\n AnimEntry? entry = null;\r\n foreach (var candidate in allEntries)\r\n {\r\n if (string.Equals(candidate.Name, member, StringComparison.Ordinal))\r\n {\r\n entry = candidate;\r\n break;\r\n }\r\n }\r\n if (entry is null)\r\n throw new ArgumentException(\r\n $\"Locomotion set '{set.FolderName}' references unknown entry '{member}'.\");\r\n folderChildren.Items.Add(BuildAnimFileNode(entry, motionRootBone));\r\n }\r\n\r\n return new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"Folder\"),\r\n [\"name\"] = new KvString(set.FolderName),\r\n [\"children\"] = folderChildren,\r\n };\r\n }\r\n\r\n /// <summary>\r\n /// Builds one 2DBlend KV3 node replicating the shipped citizen locomotion blends\r\n /// (<c>CrouchWalk_Default_2D</c>, <c>Run_Default_2D</c>, \u2026) EXACTLY: the common sequence\r\n /// attribute set (with <c>activity_name</c> left empty \u2014 the shipped activity wiring is\r\n /// model-specific), then <c>row_pose_param_name = \"move_x\"</c>,\r\n /// <c>col_pose_param_name = \"move_y\"</c>, <c>row_weight_list</c>/<c>col_weight_list</c>\r\n /// of <c>[-1.0, 0.0, 1.0]</c> and the 3\u00d73 <c>blend_anim_list</c> grid. The pose\r\n /// parameters are NOT declared here: the citizen base models pull them in via their\r\n /// PoseParamList prefab (<c>citizen_poseparamlist.vmdl_prefab</c>), which Base-Model\r\n /// children inherit \u2014 custom (non-citizen) targets must declare <c>move_x</c>/<c>move_y</c>\r\n /// on their base model for the blend to be drivable.\r\n /// </summary>\r\n internal static KvObject Build2DBlendNode(LocomotionSetSpec set)\r\n {\r\n ArgumentNullException.ThrowIfNull(set);\r\n if (set.BlendGrid.Length != 3 || Array.Exists(set.BlendGrid, row => row.Length != 3))\r\n throw new ArgumentException(\"Locomotion blend grid must be 3x3.\", nameof(set));\r\n\r\n var node = new KvObject\r\n {\r\n [\"_class\"] = new KvString(\"2DBlend\"),\r\n [\"name\"] = new KvString(set.BlendName),\r\n [\"activity_name\"] = new KvString(\"\"),\r\n [\"activity_weight\"] = new KvLong(1),\r\n [\"weight_list_name\"] = new KvString(\"\"),\r\n [\"fade_in_time\"] = new KvDouble(0.2),\r\n [\"fade_out_time\"] = new KvDouble(0.2),\r\n [\"looping\"] = new KvBool(set.Looping),\r\n [\"delta\"] = new KvBool(false),\r\n [\"worldSpace\"] = new KvBool(false),\r\n [\"hidden\"] = new KvBool(false),\r\n [\"anim_markup_ordered\"] = new KvBool(false),\r\n [\"disable_compression\"] = new KvBool(false),\r\n [\"disable_interpolation\"] = new KvBool(false),\r\n [\"enable_scale\"] = new KvBool(false),\r\n [\"row_pose_param_name\"] = new KvString(\"move_x\"),\r\n [\"col_pose_param_name\"] = new KvString(\"move_y\"),\r\n [\"row_weight_list\"] = AxisWeights(),\r\n [\"col_weight_list\"] = AxisWeights(),\r\n };\r\n\r\n var grid = new KvArray();\r\n foreach (var row in set.BlendGrid)\r\n {\r\n var rowArray = new KvArray();\r\n foreach (var cell in row)\r\n rowArray.Items.Add(new KvString(cell));\r\n grid.Items.Add(rowArray);\r\n }\r\n node[\"blend_anim_list\"] = grid;\r\n return node;\r\n\r\n static KvArray AxisWeights()\r\n {\r\n var weights = new KvArray();\r\n weights.Items.Add(new KvDouble(-1.0));\r\n weights.Items.Add(new KvDouble(0.0));\r\n weights.Items.Add(new KvDouble(1.0));\r\n return weights;\r\n }\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "TextToAnimation/Processing/FootstepEvents.cs",
"FileName": "FootstepEvents.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "#nullable enable annotations\r\n\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Numerics;\r\nusing TextToAnimation.Vmdl;\r\nusing TextToAnimation.Maths;\r\nusing SkeletonModel = TextToAnimation.Rig.Skeleton;\r\n\r\nnamespace TextToAnimation.Processing;\r\n\r\nusing Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/TextToAnimation/Assembly.cs)\r\n\r\n/// <summary>\r\n/// Generates <c>AE_FOOTSTEP</c> <see cref=\"AnimEventEntry\"/> lists from foot-plant detection\r\n/// on a solved TARGET clip: each settled contact after a lift is the touchdown\r\n/// moment, so it becomes one footstep event for that foot.\r\n/// </summary>\r\n/// <remarks>\r\n/// <para><b>Shipped-data findings</b> (dev fixture\r\n/// <c>citizen_animationlist.vmdl_prefab</c>): every one of the 28 shipped AnimEvent nodes is\r\n/// <c>event_class = \"AE_FOOTSTEP\"</c> with an <c>event_keys</c> object of exactly\r\n/// <c>Attachment = \"foot_L\"</c>/<c>\"foot_R\"</c>, <c>Foot = \"0\"</c> (left) / <c>\"1\"</c>\r\n/// (right, a STRING) and <c>Volume = 0.7</c>. The generated events replicate that shape\r\n/// verbatim, including the side encoding.</para>\r\n/// <para><b>Frame-0 plants are skipped</b>: a plant interval that begins on the clip's first\r\n/// frame means the foot was ALREADY planted when the clip starts \u2014 no touchdown happened\r\n/// inside the clip, and on looping clips the same physical step would otherwise fire twice\r\n/// (once at the wrap-around touchdown near the clip end, once at frame 0).</para>\r\n/// </remarks>\r\npublic static class FootstepEvents\r\n{\r\n /// <summary>The Source 2 footstep anim-event class (the only event class present in the\r\n /// shipped citizen animation data).</summary>\r\n public const string FootstepEventClass = \"AE_FOOTSTEP\";\r\n\r\n /// <summary>The constant volume used by every shipped footstep event.</summary>\r\n public const double FootstepVolume = 0.7;\r\n\r\n /// <summary>\r\n /// Detects vertical settling near the ground after a foot lift and returns one\r\n /// <c>AE_FOOTSTEP</c> per contact (frame-0 contacts skipped), merged in frame order.\r\n /// Horizontal sliding is allowed for in-place locomotion; cleanup still uses its\r\n /// stricter world-speed detector. Without explicit options, minimum contact duration\r\n /// scales with sample rate (0.1 seconds).\r\n /// </summary>\r\n /// <param name=\"frames\">Solved per-frame local transforms (target skeleton bone order).</param>\r\n /// <param name=\"skeleton\">Target skeleton the frames are expressed against.</param>\r\n /// <param name=\"left\">Left leg chain (target bone indices).</param>\r\n /// <param name=\"right\">Right leg chain (target bone indices).</param>\r\n /// <param name=\"up\">Target character up direction.</param>\r\n /// <param name=\"fps\">Clip sample rate.</param>\r\n /// <param name=\"options\">Plant-detection tunables; null = defaults (callers rescale the\r\n /// cm-tuned thresholds for engine-space rigs, like the foot-plant cleanup does).</param>\r\n public static List<AnimEventEntry> Generate(\r\n List<XForm[]> frames,\r\n SkeletonModel skeleton,\r\n FootChain left,\r\n FootChain right,\r\n Vector3 up,\r\n float fps,\r\n FootPlantOptions? options = null)\r\n {\r\n ArgumentNullException.ThrowIfNull(frames);\r\n ArgumentNullException.ThrowIfNull(skeleton);\r\n ArgumentNullException.ThrowIfNull(left);\r\n ArgumentNullException.ThrowIfNull(right);\r\n\r\n var events = new List<AnimEventEntry>();\r\n if (frames.Count < 2 || !float.IsFinite(fps) || fps <= 0 || !float.IsFinite(up.LengthSquared()) || up.LengthSquared() < 1e-12f)\r\n return events;\r\n up = Vector3.Normalize(up);\r\n options ??= new FootPlantOptions\r\n {\r\n // Same minimum contact time at every sample rate (three frames at 30 Hz).\r\n MinPlantFrames = Math.Max(2, (int)MathF.Ceiling(fps * 0.1f)),\r\n };\r\n var ankleL = FootPlant.AnkleWorldPositions(frames, skeleton, left.Ankle);\r\n var ankleR = FootPlant.AnkleWorldPositions(frames, skeleton, right.Ankle);\r\n var ground = FootPlant.EstimateGround(ankleL, ankleR, up);\r\n AddFoot(events, Contacts(ankleL, up, ground, fps, options), attachment: \"foot_L\", foot: \"0\");\r\n AddFoot(events, Contacts(ankleR, up, ground, fps, options), attachment: \"foot_R\", foot: \"1\");\r\n // Stable frame ordering across both feet (List.Sort is unstable; the comparer breaks\r\n // frame ties on the side key so the result is deterministic).\r\n events.Sort((a, b) => a.Frame != b.Frame\r\n ? a.Frame.CompareTo(b.Frame)\r\n : string.CompareOrdinal(a.Foot, b.Foot));\r\n return events;\r\n }\r\n\r\n // A planted foot slides horizontally in an in-place clip. Detect vertical settling\r\n // near the floor instead, but require a real lift before another touchdown. This\r\n // also prevents horizontal speed jitter from generating repeated step sounds.\r\n private static List<FrameRange> Contacts(Vector3[] ankle, Vector3 up, float ground, float fps, FootPlantOptions options)\r\n {\r\n var heights = new Vector3[ankle.Length];\r\n for (var i = 0; i < ankle.Length; i++) heights[i] = up * Vector3.Dot(ankle[i], up);\r\n var candidates = FootPlant.DetectPlants(heights, up, ground, fps, options);\r\n var contacts = new List<FrameRange>();\r\n var previousEnd = 0;\r\n foreach (var candidate in candidates)\r\n {\r\n var lifted = false;\r\n for (var i = previousEnd; i < candidate.Start; i++)\r\n lifted |= Vector3.Dot(ankle[i], up) - ground >= options.HeightThresholdCm * 1.5f;\r\n if (lifted) contacts.Add(candidate);\r\n previousEnd = candidate.End;\r\n }\r\n return contacts;\r\n }\r\n\r\n private static void AddFoot(\r\n List<AnimEventEntry> events, List<FrameRange> plants, string attachment, string foot)\r\n {\r\n foreach (var plant in plants)\r\n {\r\n if (plant.Start == 0)\r\n continue; // already planted at clip start: no touchdown inside the clip\r\n events.Add(new AnimEventEntry\r\n {\r\n EventClass = FootstepEventClass,\r\n Frame = plant.Start,\r\n Attachment = attachment,\r\n Foot = foot,\r\n Volume = FootstepVolume,\r\n });\r\n }\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "TextToAnimation/Processing/IkBoneBaker.cs",
"FileName": "IkBoneBaker.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "#nullable enable annotations\r\n\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Numerics;\r\nusing TextToAnimation.Mapping;\r\nusing TextToAnimation.Maths;\r\nusing TextToAnimation.Rig;\r\n\r\nnamespace TextToAnimation.Processing;\r\n\r\nusing Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/TextToAnimation/Assembly.cs)\r\n\r\n/// <summary>\r\n/// Bakes real animation channels onto the <see cref=\"BoneClass.IkBaked\"/> helper bones from\r\n/// the already-solved <see cref=\"BoneClass.Animated\"/> body bones. The s&box default\r\n/// animgraph reads these bones, so every retargeted clip must carry data for them.\r\n/// </summary>\r\n/// <remarks>\r\n/// <para>\r\n/// Every rule below was DERIVED from shipped Facepunch clips\r\n/// (<c>Citizen@Run_N.fbx</c>, <c>[email protected]</c>, plus the <c>*_AimMatrix</c> clips for\r\n/// the aim bones) by measuring per-frame relationships between the IK bones and the solved\r\n/// body bones, and is verified by reproducing those clips' IK channels in\r\n/// <c>IkBoneBakerTests</c>. Measured residuals on both probe clips (positions cm, FBX scene\r\n/// space of the citizen sources, Y-up):\r\n/// </para>\r\n/// <list type=\"bullet\">\r\n/// <item><c>foot_L/R_IK_target</c>: world transform equals the world transform of\r\n/// <c>ankle_L/R</c> exactly (max residual 1e-4 cm / 0.000\u00b0).</item>\r\n/// <item><c>hand_L/R_IK_target</c>: world transform equals <c>hand_L/R</c> exactly\r\n/// (1e-4 cm / 0.000\u00b0).</item>\r\n/// <item><c>hand_L_to_R_ikrule</c> (parented under <c>hand_R</c>): world transform equals\r\n/// <c>hand_L</c> exactly \u2014 the bone named <c>hand_X_to_Y_ikrule</c> carries hand X's world\r\n/// transform expressed in hand Y's space (1e-4 cm / 0.000\u00b0). Symmetrically for\r\n/// <c>hand_R_to_L_ikrule</c>.</item>\r\n/// <item><c>root_IK</c>: world position equals the pelvis world position projected onto the\r\n/// ground plane \u2014 the lateral components match the pelvis exactly (0.0000 cm) and the up\r\n/// component stays at root_IK's rest height (0). World rotation never animates: it stays at\r\n/// root_IK's rest world rotation (0.000\u00b0 deviation even while the pelvis yaws), which is the\r\n/// up-axis frame change quaternion, i.e. identity in engine space. The up axis is derived\r\n/// from rest geometry (the dominant component of pelvisRest \u2212 rootIkRest), not hardcoded.</item>\r\n/// <item><c>hold_L/R</c>: local transform stays at rest (0.0000 cm / 0.000\u00b0).</item>\r\n/// <item><c>hand_L/R_IK_attach</c>: absent from every probed shipped clip skeleton (the clip\r\n/// FBXs parent the hand IK targets directly under <c>root_IK</c>); in the human_male rig its\r\n/// rest local under <c>root_IK</c> is identity. Keeping rest reproduces the shipped layout,\r\n/// and the hand targets still land on the hands because their locals are solved through the\r\n/// attach's world.</item>\r\n/// <item><c>aim_matrix_*</c>: absent from all regular shipped clips. Even in the dedicated\r\n/// <c>*_AimMatrix</c> clips, <c>aim_matrix_02/03</c> sit exactly at rest local\r\n/// (0.0000 cm / 0.000\u00b0) and <c>aim_matrix_01</c> holds an animator-authored constant pose\r\n/// (not derivable from the body) \u2014 these bones are animgraph aim-space references, so\r\n/// retargeted clips keep them at rest.</item>\r\n/// </list>\r\n/// <para>\r\n/// Any <see cref=\"BoneClass.IkBaked\"/> bone without a recognized name also keeps its rest\r\n/// local (the conservative choice \u2014 identical to omitting the channel).\r\n/// </para>\r\n/// </remarks>\r\npublic static class IkBoneBaker\r\n{\r\n /// <summary>\r\n /// Overwrites the local transforms of every <see cref=\"BoneClass.IkBaked\"/> bone in every\r\n /// frame, deriving them from the already-solved body bones of the same frame. All other\r\n /// bones' locals are never touched. Deterministic; mutates <paramref name=\"frames\"/> in\r\n /// place.\r\n /// </summary>\r\n /// <param name=\"frames\">Per-frame local transforms, one <see cref=\"XForm\"/> per bone in\r\n /// <paramref name=\"target\"/> skeleton order (e.g. <c>Clip.Frames</c>).</param>\r\n /// <param name=\"target\">The target rig supplying skeleton, bone classes and roles.</param>\r\n /// <exception cref=\"ArgumentException\">Thrown when a frame's length does not match the\r\n /// target skeleton.</exception>\r\n public static void Bake(List<XForm[]> frames, TargetRig target)\r\n {\r\n ArgumentNullException.ThrowIfNull(frames);\r\n ArgumentNullException.ThrowIfNull(target);\r\n\r\n var skeleton = target.Skeleton;\r\n var count = skeleton.Count;\r\n for (var f = 0; f < frames.Count; f++)\r\n {\r\n if (frames[f].Length != count)\r\n throw new ArgumentException(\r\n $\"Frame {f} has {frames[f].Length} bones but the target skeleton has {count}.\",\r\n nameof(frames));\r\n }\r\n\r\n var rules = BuildRules(target);\r\n\r\n var world = new XForm[count];\r\n var computed = new bool[count];\r\n foreach (var locals in frames)\r\n {\r\n Array.Clear(computed);\r\n\r\n // Pass 1: world transforms of every bone with no IkBaked ancestor (the body).\r\n // A bone is skipped exactly when its parent was skipped or is itself IkBaked,\r\n // so the skip set is precisely the IK bones and their descendants.\r\n for (var i = 0; i < count; i++)\r\n {\r\n if (target.ClassOf(i) == BoneClass.IkBaked)\r\n continue;\r\n var parent = skeleton[i].ParentIndex;\r\n if (parent >= 0 && !computed[parent])\r\n continue;\r\n world[i] = parent < 0 ? locals[i] : XForm.Compose(world[parent], locals[i]);\r\n computed[i] = true;\r\n }\r\n\r\n // Pass 2: bake IK bones (and propagate worlds through their subtrees). Bones are\r\n // topologically sorted, so a parent's world is always available by the time its\r\n // child is processed.\r\n for (var i = 0; i < count; i++)\r\n {\r\n if (computed[i])\r\n continue;\r\n var parent = skeleton[i].ParentIndex;\r\n\r\n if (target.ClassOf(i) == BoneClass.IkBaked)\r\n {\r\n if (rules[i].Kind == RuleKind.KeepRestLocal)\r\n {\r\n locals[i] = skeleton[i].RestLocal;\r\n world[i] = parent < 0 ? locals[i] : XForm.Compose(world[parent], locals[i]);\r\n }\r\n else\r\n {\r\n var desired = Desired(in rules[i], world, computed, skeleton[i].Name);\r\n locals[i] = parent < 0 ? desired : XForm.ToLocal(world[parent], desired);\r\n world[i] = desired;\r\n }\r\n }\r\n else\r\n {\r\n // Non-IK descendant of an IK bone: local untouched, world propagated.\r\n world[i] = parent < 0 ? locals[i] : XForm.Compose(world[parent], locals[i]);\r\n }\r\n computed[i] = true;\r\n }\r\n }\r\n }\r\n\r\n /// <summary>\r\n /// Pins the KeepRestLocal-classified IK helper bones (aim_matrix_*, hold_*,\r\n /// *_IK_attach and unrecognized IkBaked names) to their REST locals in every frame.\r\n /// Used on MIRRORED clips (southpaw G8 mirror fix): these bones are aim-space\r\n /// reference CONSTANTS the model's aim/eye constraint setup was authored against\r\n /// (measured at rest in every shipped citizen clip); conjugating them like body\r\n /// geometry feeds the aim chain an alien reference frame and the head/eye region\r\n /// collapses. The body-derived helpers (root_IK, IK targets, ikrule) are NOT\r\n /// touched: their mirrored channels stay the exact conjugates of the primary's.\r\n /// </summary>\r\n public static void PinRestLocalReferenceBones(List<XForm[]> frames, TargetRig target)\r\n {\r\n ArgumentNullException.ThrowIfNull(frames);\r\n ArgumentNullException.ThrowIfNull(target);\r\n\r\n var skeleton = target.Skeleton;\r\n var rules = BuildRules(target);\r\n foreach (var locals in frames)\r\n {\r\n for (var i = 0; i < locals.Length && i < skeleton.Count; i++)\r\n {\r\n if (target.ClassOf(i) == BoneClass.IkBaked && rules[i].Kind == RuleKind.KeepRestLocal)\r\n locals[i] = skeleton[i].RestLocal;\r\n }\r\n }\r\n }\r\n\r\n // ------------------------------------------------------------------ rules\r\n\r\n private enum RuleKind\r\n {\r\n /// <summary>Keep the rest local transform (hold_*, aim_matrix_*, *_IK_attach, unknown).</summary>\r\n KeepRestLocal,\r\n\r\n /// <summary>World transform copies a body bone's world transform (IK targets, ikrule).</summary>\r\n FollowBody,\r\n\r\n /// <summary>root_IK: pelvis world position projected to the ground plane, fixed rest rotation.</summary>\r\n GroundProjectedHips,\r\n }\r\n\r\n private readonly struct Rule\r\n {\r\n public RuleKind Kind { get; init; }\r\n public int BodyBone { get; init; }\r\n public int UpAxis { get; init; }\r\n public float GroundLevel { get; init; }\r\n public Quaternion FixedWorldRot { get; init; }\r\n }\r\n\r\n private static Rule[] BuildRules(TargetRig target)\r\n {\r\n var skeleton = target.Skeleton;\r\n var rules = new Rule[skeleton.Count];\r\n\r\n foreach (var i in target.BonesOfClass(BoneClass.IkBaked))\r\n {\r\n rules[i] = skeleton[i].Name switch\r\n {\r\n \"root_IK\" => RootIkRule(target, i),\r\n \"foot_L_IK_target\" => FollowRule(target, BoneRole.FootL),\r\n \"foot_R_IK_target\" => FollowRule(target, BoneRole.FootR),\r\n \"hand_L_IK_target\" => FollowRule(target, BoneRole.HandL),\r\n \"hand_R_IK_target\" => FollowRule(target, BoneRole.HandR),\r\n \"hand_L_to_R_ikrule\" => FollowRule(target, BoneRole.HandL),\r\n \"hand_R_to_L_ikrule\" => FollowRule(target, BoneRole.HandR),\r\n _ => default, // KeepRestLocal\r\n };\r\n }\r\n\r\n return rules;\r\n }\r\n\r\n private static Rule FollowRule(TargetRig target, BoneRole role)\r\n => target.BoneForRole(role) is { } body\r\n ? new Rule { Kind = RuleKind.FollowBody, BodyBone = body }\r\n : default; // body bone missing from the rig: keep rest\r\n\r\n private static Rule RootIkRule(TargetRig target, int rootIk)\r\n {\r\n if (target.BoneForRole(BoneRole.Hips) is not { } pelvis)\r\n return default;\r\n\r\n var skeleton = target.Skeleton;\r\n // Up axis = dominant component of the rest offset pelvis \u2212 root_IK. In every probed\r\n // rig they share the lateral components exactly and differ only by the hip height.\r\n var d = Vector3.Abs(skeleton.RestWorld[pelvis].Pos - skeleton.RestWorld[rootIk].Pos);\r\n var up = d.X >= d.Y ? (d.X >= d.Z ? 0 : 2) : (d.Y >= d.Z ? 1 : 2);\r\n\r\n return new Rule\r\n {\r\n Kind = RuleKind.GroundProjectedHips,\r\n BodyBone = pelvis,\r\n UpAxis = up,\r\n GroundLevel = Component(skeleton.RestWorld[rootIk].Pos, up),\r\n FixedWorldRot = skeleton.RestWorld[rootIk].Rot,\r\n };\r\n }\r\n\r\n private static XForm Desired(in Rule rule, XForm[] world, bool[] computed, string name)\r\n {\r\n if (!computed[rule.BodyBone])\r\n throw new InvalidOperationException(\r\n $\"IK bone '{name}' follows a body bone that is itself under an IK bone \u2014 unsupported hierarchy.\");\r\n\r\n return rule.Kind == RuleKind.FollowBody\r\n ? world[rule.BodyBone]\r\n : new XForm(\r\n WithComponent(world[rule.BodyBone].Pos, rule.UpAxis, rule.GroundLevel),\r\n rule.FixedWorldRot);\r\n }\r\n\r\n private static float Component(Vector3 v, int axis)\r\n => axis switch { 0 => v.X, 1 => v.Y, _ => v.Z };\r\n\r\n private static Vector3 WithComponent(Vector3 v, int axis, float value)\r\n {\r\n switch (axis)\r\n {\r\n case 0: v.X = value; break;\r\n case 1: v.Y = value; break;\r\n default: v.Z = value; break;\r\n }\r\n return v;\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_text_to_animation",
"Path": "TextToAnimation/Rig/Pose.cs",
"FileName": "Pose.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 409724,
"IsPrivate": false,
"Code": "#nullable enable annotations\r\n\r\nusing System;\r\nusing TextToAnimation.Maths;\r\n\r\nnamespace TextToAnimation.Rig;\r\n\r\n/// <summary>\r\n/// A single pose of a skeleton: one parent-relative local transform per bone,\r\n/// indexed exactly like the skeleton's bone list.\r\n/// </summary>\r\npublic sealed class Pose\r\n{\r\n /// <summary>Parent-relative local transforms, one per bone (skeleton bone order).</summary>\r\n public XForm[] Locals { get; }\r\n\r\n /// <summary>Wraps an existing local-transform array (not copied).</summary>\r\n public Pose(XForm[] locals)\r\n {\r\n Locals = locals ?? throw new ArgumentNullException(nameof(locals));\r\n }\r\n\r\n /// <summary>Creates a pose initialized to the skeleton's rest local transforms.</summary>\r\n public static Pose Rest(Skeleton skeleton)\r\n {\r\n ArgumentNullException.ThrowIfNull(skeleton);\r\n var locals = new XForm[skeleton.Count];\r\n for (var i = 0; i < locals.Length; i++)\r\n locals[i] = skeleton[i].RestLocal;\r\n return new Pose(locals);\r\n }\r\n\r\n /// <summary>\r\n /// Forward kinematics: composes local transforms down the hierarchy and returns\r\n /// one world transform per bone.\r\n /// </summary>\r\n /// <exception cref=\"ArgumentException\">Thrown when the pose length does not match the skeleton.</exception>\r\n public XForm[] ToWorld(Skeleton skeleton)\r\n {\r\n ArgumentNullException.ThrowIfNull(skeleton);\r\n if (Locals.Length != skeleton.Count)\r\n throw new ArgumentException(\r\n $\"Pose has {Locals.Length} bones but skeleton has {skeleton.Count}.\", nameof(skeleton));\r\n\r\n var world = new XForm[Locals.Length];\r\n for (var i = 0; i < Locals.Length; i++)\r\n {\r\n var parent = skeleton[i].ParentIndex;\r\n world[i] = parent < 0 ? Locals[i] : XForm.Compose(world[parent], Locals[i]);\r\n }\r\n return world;\r\n }\r\n}\r\n"
}
]
}