Record type that constructs a combined Skeleton and per-frame transforms for props appended to a character for bone-only export. It validates sizes, builds bone definitions including prop bones transformed into character space or kept as independent roots, and produces frames by sampling prop motion and inserting transformed local XForms after the character bones.
using System;
using System.Collections.Generic;
using System.Linq;
using HumanoidMocap.Core.Maths;
using HumanoidMocap.Core.Skeleton;
using SkeletonModel = HumanoidMocap.Core.Skeleton.Skeleton;
namespace HumanoidMocap.Core.Motion;
/// <summary>Combines the character and explicit prop armatures for portable bone-only export.
/// Prop roots remain independent of character bones; contact solving cannot move them.</summary>
public sealed record PropAnimation(SkeletonModel Skeleton,IReadOnlyList<XForm[]> Frames)
{
public static PropAnimation Append(SkeletonModel character,IReadOnlyList<XForm[]> frames,
IReadOnlyList<double> sourceTimes,PropContactMotion tracks,CapturePlacement placement)
{
if(tracks.Objects.Count==0)return new(character,frames);
int count=character.Count+tracks.Objects.Sum(p=>p.Bones.Count);
if(count>1024||(long)count*frames.Count>Formats.Fbx.FbxAnimationWriter.MaximumTransformSamples)
throw new ArgumentException("Character and prop armatures exceed the export budget.");
if(sourceTimes.Count!=frames.Count)throw new ArgumentException("Prop and character sample times differ.");
var definitions=character.Bones.Select(b=>new BoneDefinition(b.Name,b.ParentIndex<0?null:character[b.ParentIndex].Name,b.RestLocal)).ToList();
var restById=new Dictionary<string,XForm[]>();
for(var p=0;p<tracks.Objects.Count;p++)
{
var prop=tracks.Objects[p];var rest=new XForm[prop.Bones.Count];
for(var b=0;b<rest.Length;b++)
{
var bone=prop.Bones[b];var local=new XForm(MotionDocument.V(bone.RestPosition),MotionDocument.Q(bone.RestRotation));
var parent=bone.Parent<0?(string.IsNullOrEmpty(prop.ParentObject)?XForm.Identity:restById[prop.ParentObject][0]):rest[bone.Parent];
rest[b]=XForm.Compose(parent,local);
var output=bone.Parent<0?placement.Transform(rest[b]):new XForm(local.Pos*placement.Units,local.Rot);
definitions.Add(new(Name(p,bone.Name),bone.Parent<0?null:Name(p,prop.Bones[bone.Parent].Name),output));
}
restById.Add(prop.Id,rest);
}
var skeleton=SkeletonModel.Create(definitions);var result=new List<XForm[]>(frames.Count);
for(var f=0;f<frames.Count;f++)
{
var frame=new XForm[count];Array.Copy(frames[f],frame,character.Count);int offset=character.Count;
foreach(var prop in tracks.Objects)
{
if(!tracks.TrySample(prop.Id,sourceTimes[f],out var world,out var available)||available.Any(a=>!a))
throw new InvalidOperationException($"Prop '{prop.Id}' has missing motion at {sourceTimes[f]:F3}s. Trim the clip or explicitly fill the object track before export.");
for(var b=0;b<world.Length;b++)
{
var parent=prop.Bones[b].Parent;
var local=parent<0?world[b]:XForm.Compose(world[parent].Inverse(),world[b]);
frame[offset++]=parent<0?placement.Transform(local):new XForm(local.Pos*placement.Units,local.Rot);
}
}
result.Add(frame);
}
return new(skeleton,result);
}
public static string Name(int objectIndex,string bone)=>$"hm_prop_{objectIndex}_{bone}";
}