Editor UI panel for rig animation bones. It shows header fields (model, asset paths, animation speed, frame count, reference props), a tree of bones built from the RigViewport roots, and a separate objects panel; it wires selection between the tree and viewport and rebuilds when the document changes.
using Editor;
using Sandbox;
using Marionette;
using System;
using System.Linq;
namespace Marionette.Tools;
/// <summary>
/// The BonesObject tab - header fields (Model / Asset Path / Rig Asset Path / Animation Speed)
/// over a real tree of the rig's bones, matching the reference video: pelvis's chain and a
/// separate root_IK utility chain (aim_matrix/foot_IK_target/hand_IK_attach) both as top-level
/// roots, each bone shown with its actual children rather than a flat list.
/// </summary>
internal sealed class RigBonesPanel : Widget
{
private readonly RigViewport _viewport;
private TreeView _tree;
private ControlSheet _header;
private Editor.Label _assetPathValue;
public Action Edited { get; set; }
private RigObjectsPanel _objects;
/// <summary>The objects tree under the bones. The window wires its import and its edits.</summary>
public RigObjectsPanel ObjectsPanel => _objects;
private RigAnimDocument _anim;
private Asset _asset;
public RigBonesPanel( Widget parent, RigViewport viewport ) : base( parent )
{
Name = "BonesObject";
WindowTitle = "BonesObject";
SetWindowIcon( "polyline" );
_viewport = viewport;
Layout = Layout.Column();
Layout.Add( RigHelpBox.Create( this,
"Which model this clip poses, where the clip file lives, and a live tree of every " +
"bone in the model.",
new[]
{
RigHelpBox.S( "Model",
"The skinned model this clip is authored against. Set it here (or drag a " +
"model in) to load it into the viewport - nothing will show up there until " +
"this is set." ),
RigHelpBox.S( "Asset Path",
"Read-only - shows where this .riganim file lives on disk once it's been " +
"saved." ),
RigHelpBox.S( "Rig Asset Path",
"Points at a separate Control Rig (.ctrlrig) asset holding this model's " +
"IK/Limit constraints - its own file rather than living inside this clip, " +
"because several clips can share one rig. Drag a .ctrlrig asset onto this " +
"field, or click the folder icon to browse for one. Optional - only needed if " +
"you're using the Constraints tab. Don't have one yet? Right-click in the " +
"Assets panel -> New -> Control Rig." ),
RigHelpBox.S( "Animation Speed",
"Frames per second this clip plays at, used by the Timeline's transport here " +
"and by RigAnimPlayerComponent when it plays this clip back in-game." ),
RigHelpBox.S( "Frame Count",
"How long the clip is. The Timeline shows exactly this many frames - if it " +
"looks too short to fit what you're animating, this is the number to raise, " +
"not the timeline's zoom. At 30 fps, 30 frames is one second." ),
RigHelpBox.S( "Bone tree",
"Every bone in Model, arranged the way it's actually rigged. Click a bone here " +
"to select it, same as clicking its dot in the viewport - either way brings up " +
"its rotate ring so you can pose it." ),
RigHelpBox.S( "Objects",
"Under the bones: everything else in the clip. Import OBJ brings in a file as one " +
"object with a part per o/g group; Add Model brings in a compiled model. Click a " +
"row to select it, right-click to rename, hide, duplicate or delete it, and set " +
"its numbers in the Inspector. Each object and part keys with K like a bone." ),
RigHelpBox.S( "Playing in game",
"For an interaction (open a fridge, pull a lever): save the .riganim, add " +
"RigAnimPlayerComponent next to the SkinnedModelRenderer, assign the clip, " +
"uncheck Loop and Play On Start, call Play() from your use code. Tween the " +
"prop with player.NormalizedTime so it shares the clip's clock. File → Export " +
"Animation is only if you need a named sequence on a compiled .vmdl." ),
} ) );
// Its own row, outside the ControlSheet entirely - AddLayout( Layout.Row() ) rendered as
// a plain, unstyled label with no value visible next to it (confirmed by report: it read
// as missing altogether, not just differently styled from the other rows). A persistent
// widget built once here, with only its text updated on rebuild, sidesteps that.
var assetPathRow = new Widget( this ) { Layout = Layout.Row() };
assetPathRow.Layout.Margin = new Sandbox.UI.Margin( 8, 4 );
assetPathRow.Layout.Spacing = 8;
assetPathRow.Layout.Add( new Editor.Label( "Asset Path" ) { FixedWidth = 110 } );
_assetPathValue = assetPathRow.Layout.Add( new Editor.Label( "" ) { Enabled = false }, 1 );
Layout.Add( assetPathRow );
_header = new ControlSheet();
Layout.Add( _header );
_tree = new TreeView( this );
_tree.OnSelectionChanged = objs =>
{
if ( objs?.FirstOrDefault() is string bone )
_viewport.Select( bone );
};
Layout.Add( _tree, 1 );
// THE OBJECTS GO BELOW THE BONES, in their own tree. See RigObjectsPanel for why they are
// not extra rows in the bone tree: an imported door with forty parts would bury the arm you
// are posing, and two trees can be collapsed independently.
_objects = new RigObjectsPanel( this, _viewport );
Layout.Add( _objects, 1 );
Rebuild();
}
public void SetAsset( Asset asset, RigAnimDocument anim )
{
_asset = asset;
_anim = anim;
_objects?.SetDocument( anim );
RebuildHeader();
Rebuild();
}
private void RebuildHeader()
{
_header.Clear( true );
_assetPathValue.Text = _asset is null ? "(not saved yet - Ctrl+S)" : $"{_asset.Name} {_asset.Path}";
if ( _anim is null )
return;
var serialized = EditorTypeLibrary.GetSerializedObject( _anim );
serialized.OnPropertyChanged += _ =>
{
Rebuild();
Edited?.Invoke();
};
if ( serialized.TryGetProperty( nameof( RigAnimDocument.SourceModel ), out var model ) )
_header.AddRow( model );
if ( serialized.TryGetProperty( nameof( RigAnimDocument.RigAsset ), out var rigAsset ) )
_header.AddRow( rigAsset );
if ( serialized.TryGetProperty( nameof( RigAnimDocument.AnimationSpeed ), out var speed ) )
_header.AddRow( speed );
// HOW LONG THE CLIP IS. Missing from this sheet until now, which meant FrameCount was
// stuck at its default of 30 forever - the timeline drew the whole clip correctly and the
// clip simply could not be made longer. It reads as the timeline being truncated, or as
// scrolling being broken, because those are the explanations that occur to you when the
// real one isn't on screen anywhere.
if ( serialized.TryGetProperty( nameof( RigAnimDocument.FrameCount ), out var frameCount ) )
_header.AddRow( frameCount );
// Reference props - static models to pose against. Sits with the source model rather than
// in its own tab because it answers the same question: what is in this viewport.
if ( serialized.TryGetProperty( nameof( RigAnimDocument.ReferenceProps ), out var props ) )
_header.AddRow( props );
// NO ROW FOR OBJECTS. There was one, and it opened the engine's generic list editor as a
// floating panel over the viewport and the timeline - a second, worse place to edit the
// same things the objects tree below already manages. RigObjectsPanel is the only surface.
}
public void Rebuild()
{
_objects?.Rebuild();
_tree.Clear();
foreach ( var root in _viewport.RootBoneNames() )
{
var node = _tree.AddItem( new BoneTreeNode( root, _viewport ) );
_tree.Open( node );
}
}
private sealed class BoneTreeNode : TreeNode<string>
{
private readonly RigViewport _viewport;
public BoneTreeNode( string bone, RigViewport viewport ) : base( bone )
{
_viewport = viewport;
}
public override void OnPaint( VirtualWidget item )
{
PaintSelection( item );
Paint.SetPen( Theme.Blue );
Paint.DrawIcon( item.Rect, "fiber_manual_record", 12, TextFlag.LeftCenter );
// The bone, with the object it belongs to after it - the raw value is the qualified
// track name, and a tree of "magazine/follower" reads as a file path rather than as a
// skeleton.
Paint.SetPen( Theme.Text );
Paint.DrawText( item.Rect.Shrink( 20, 0, 0, 0 ), RigTrackName.Display( Value ), TextFlag.LeftCenter );
}
protected override void BuildChildren()
{
Clear();
AddItems( _viewport.ChildBoneNames( Value ).Select( c => new BoneTreeNode( c, _viewport ) ) );
}
}
}