Editor/RigControlEditor/RigControlWindow.cs
using Editor;
using Marionette;
using Sandbox;
using System;
using System.Linq;
namespace Marionette.Tools;
/// <summary>
/// The dockable RigControlEditor window - a 3D viewport with per-bone FK gizmos, a per-bone
/// keyframe timeline beneath it, and a tabbed right panel (BonesObject / AnimEvents /
/// Constraints). Edits a RigAnimDocument (.riganim); its Rig Asset Path points at a separate
/// RigDocument (.ctrlrig) that owns the model reference for posing and the IK/Limit constraints,
/// so several clips can share one rig. Built directly on DockWindow/IAssetEditor rather than
/// AnimGraph's own DocumentWindow base (ClipWindow.cs, SkeletonWindow.cs) - that base's undo/
/// dirty machinery lives in the AnimGraph package itself and isn't referenceable from project
/// code, so this re-does the smaller save/dirty part it actually needs.
/// </summary>
[EditorForAssetType( "riganim" )]
[EditorApp( "Marionette", "accessibility_new", "Pose and keyframe a skinned model's bones, author IK/Limit rig constraints, and place per-frame prop-attach events" )]
public sealed class RigControlWindow : DockWindow, IAssetEditor
{
public bool CanOpenMultipleAssets => false;
private Asset _asset;
private RigAnimDocument _anim;
private RigDocument _rig;
private Model _lastModel;
private bool _dirty;
private RigViewport _viewport;
private RigTimeline _timeline;
private RigEventProperties _events;
private RigBonesPanel _bones;
private RigConstraintsPanel _constraints;
private RigInspectorPanel _inspector;
private DockWidget _centralDock;
private Option _saveOption;
// Opened from the Tools menu (EditorApp) as well as by double-clicking a .riganim asset
// (EditorForAssetType) - the docks have to exist before either path can populate them, so
// they're built here with a blank in-memory document rather than inside AssetOpen, the same
// shape SpriteEditor's own Window.cs uses.
/// <summary>
/// The Citizen first-person arms, from the base citizen addon - so it's present for everyone
/// rather than being something of mine.
///
/// A blank document opens with these already loaded. Opening an animation tool to an empty
/// black viewport tells you nothing and makes the first move "go find a model", which is the
/// least interesting decision in the process. Arms are also what most people are here for:
/// first-person animation is the gap in s&box nobody has filled.
/// </summary>
public const string DefaultModelPath = "models/first_person/first_person_arms_preview.vmdl";
public RigControlWindow()
{
DeleteOnClose = true;
// Roomier by default. The side panels were pinched enough at 1400 that step text and
// property names clipped, and a tool whose first impression is truncated labels reads as
// broken before anyone has used it.
Size = new Vector2( 1760, 1040 );
SetWindowIcon( "accessibility_new" );
_anim = new RigAnimDocument
{
SourceModel = Model.Load( DefaultModelPath )
};
BuildMenuBar();
// Docks first - BuildToolbar sets its buttons' initial icon state immediately (e.g.
// UpdateLinkOption reads _viewport.AutoKeyEnabled), so _viewport/_timeline have to exist
// before it runs. Building it first was the bug: null _viewport, NullReferenceException
// straight out of the constructor.
BuildDocks();
BuildToolbar();
BuildEditMenu();
BuildViewMenu();
BuildHelpMenu();
BuildStatusBar();
ResetDirty();
ResetBaseline();
Show();
ShowTutorialIfWanted();
}
/// <summary>
/// Opens the Tutorial dock on startup unless the reader has opted out.
///
/// Done HERE rather than only in BuildDefaultLayout, because that method only runs when there
/// is no saved layout to restore. Anyone who has opened the tool before has one - and if the
/// Tutorial dock was closed in it, the default layout never runs again and nothing ever
/// reopens the panel. It simply stops existing, with no way to tell that from it being
/// broken. SetDockState works against the restored layout too, so this covers both cases.
/// </summary>
private void ShowTutorialIfWanted()
{
if ( !RigTutorial.OpenOnStartup )
return;
DockManager.SetDockState( "Tutorial", true );
DockManager.RaiseDock( "Tutorial" );
RefreshTutorial();
}
public void AssetOpen( Asset asset )
{
if ( asset is null )
return;
Raise();
LoadAsset( asset );
}
public void SelectMember( string memberName )
{
}
private void LoadAsset( Asset asset )
{
_asset = asset;
if ( !asset.TryLoadResource( out _anim ) || _anim is null )
_anim = new RigAnimDocument();
LoadRig();
_lastModel = _anim.SourceModel;
_viewport.SetModel( _lastModel );
ApplyRigToPanels();
_timeline.SetAsset( _anim );
_events.SetAsset( _anim );
_bones.SetAsset( _asset, _anim );
_bones.Rebuild();
ResetDirty();
// History belongs to the document that was open. Carrying it across a load would let
// Ctrl+Z paste the previous clip's tracks into this one.
_undoStack.Clear();
ResetBaseline();
// THE POSE HAS TO BE APPLIED HERE OR OPENING A CLIP SHOWS NOTHING IT CONTAINS.
//
// Loading fills in the document and moves the playhead to 0, but RigTimeline.Playhead's
// setter deliberately doesn't raise Scrubbed - that fires on user interaction, so that
// moving the playhead in code can't recurse. The consequence was that nothing ever drove
// the model after a load: EvaluatePose was never called, every bone kept its bind pose,
// and a clip you had just saved opened showing none of its own keyframes.
//
// That reads exactly like the save silently failing, which is what it was reported as.
// The keyframes were on disk and loaded correctly the whole time; nothing asked the
// viewport to use them.
OnScrub( _timeline.Playhead );
}
private void OpenPicker()
{
var picker = AssetPicker.Create( null, AssetType.FromType( typeof( RigAnimDocument ) ), new AssetPicker.PickerOptions() );
picker.Title = "Open Rig Animation";
picker.OnAssetPicked = assets =>
{
if ( assets.FirstOrDefault() is { } asset )
LoadAsset( asset );
};
picker.Show();
}
private void LoadRig()
{
_rig = _anim.RigAsset;
}
private void BuildMenuBar()
{
var file = MenuBar.FindOrCreateMenu( "File" );
file.Clear();
file.AddOption( "New", "common/new.png", New );
file.AddOption( "Open...", "folder_open", OpenPicker, "editor.open" );
file.AddSeparator();
_saveOption = file.AddOption( "Save", "common/save.png", Save, "editor.save" );
file.AddSeparator();
file.AddOption( "Close", "close", Close );
}
private void BuildEditMenu()
{
var edit = MenuBar.FindOrCreateMenu( "Edit" );
_undoOption = edit.AddOption( "Undo", "undo", Undo, "editor.undo" );
_redoOption = edit.AddOption( "Redo", "redo", Redo, "editor.redo" );
UpdateUndoOptions();
edit.AddSeparator();
edit.AddOption( "Delete Selected Keyframe", "delete", () => _timeline.DeleteSelectedKeyframe() )
.StatusTip = "Same as pressing Delete with a keyframe selected on the Timeline";
edit.AddSeparator();
edit.AddOption( "Clear Keyframes on Selected Bone", "clear_all", ClearSelectedBoneKeyframes )
.StatusTip = "Remove every keyframe from whichever bone is selected in the viewport, so it can be re-posed from scratch";
}
/// <summary>Wipes one bone's whole track - the fast way to redo a bone that was posed wrong
/// throughout, instead of hunting down and deleting each of its keyframes by hand.</summary>
private void ClearSelectedBoneKeyframes()
{
if ( _anim is null || _viewport.SelectedBone is not { } bone )
return;
var track = _anim.FindTrack( bone );
if ( track is null || track.Keyframes.Count == 0 )
return;
track.Keyframes.Clear();
_timeline.Refresh();
MarkDirty( $"Clear Keyframes on {bone}" );
}
// There was no way to get a closed dock back before this - close BonesObject and it was
// gone for the rest of the session. My first attempt (FindDockWidget/OpenDock by hand) was
// wrong - the real pattern, confirmed from ShaderGraph's and AnimGraph's own shipped View
// menus, is DockTypes + IsDockOpen + SetDockState: a checkable option per registered dock
// that DockManager itself knows how to open/close correctly, including re-attaching one
// that was fully closed.
private RigStatusBar _statusBar;
private RigTutorialPanel _tutorialPanel;
private readonly RigTutorial _tutorial = new();
/// <summary>Hover hints only. The tutorial used to live in here too and was unreadable - see
/// RigTutorialPanel.</summary>
private void BuildStatusBar()
{
// Window.StatusBar, not Layout.Add - a DockWindow has no Layout of its own (the dock
// manager owns the window's whole client area), so adding to it threw a
// NullReferenceException straight out of the constructor.
_statusBar = new RigStatusBar( this );
StatusBar = _statusBar;
RefreshTutorial();
}
/// <summary>Steps tick themselves off by watching the document, so this runs after anything
/// that could have satisfied one.</summary>
private void RefreshTutorial()
{
_tutorial.Evaluate( _anim, _viewport?.SelectedBone );
_tutorialPanel?.Rebuild();
}
private void BuildHelpMenu()
{
var help = MenuBar.FindOrCreateMenu( "Help" );
help.AddOption( "Start Animation Tutorial", "school", () =>
{
_tutorial.Restart();
// Starting it should show it. Restarting a tutorial whose panel is closed, and saying
// nothing, is the kind of dead menu item this tool has had enough of.
DockManager.SetDockState( "Tutorial", true );
DockManager.RaiseDock( "Tutorial" );
RefreshTutorial();
} ).StatusTip = "Walks through building a reach-and-flip-a-switch animation, one step at a time";
help.AddOption( "Dismiss Tutorial", "close", () =>
{
_tutorial.Dismiss();
_tutorialPanel?.Rebuild();
} );
}
private void BuildViewMenu()
{
var view = MenuBar.FindOrCreateMenu( "View" );
// A floating Asset Browser, not an embedded one - AssetBrowser's bare constructor throws
// (BuildLocationsPanel NullReferenceException, confirmed from the exception itself, not
// a guess) because it expects setup MainAssetBrowser's own constructor provides and a
// plain `new AssetBrowser(this)` skips. CreateFloating goes through that real path instead.
view.AddOption( "Open Assets Browser", "folder_open", () => MainAssetBrowser.CreateFloating() );
view.AddSeparator();
// Same label and icon every other editor window uses - ShaderGraph, AnimGraph,
// ActionGraph, Sprite, Sound and Rect all have this identical line. Panels are draggable,
// so sooner or later everyone lands somewhere they didn't intend and can't remember what
// the default was; matching the wording means people already know what to look for.
view.AddOption( "Restore To Default", "settings_backup_restore", ResetLayout );
view.AddSeparator();
foreach ( var dock in DockManager.DockTypes )
{
var option = view.AddOption( dock.Title, dock.Icon );
option.Checkable = true;
option.Checked = DockManager.IsDockOpen( dock.Title );
option.Toggled += b => DockManager.SetDockState( dock.Title, b );
}
}
private Option _linkOption;
private Option _dragModeOption;
private void BuildToolbar()
{
var bar = new ToolBar( this, "RigControlToolbar" );
bar.SetIconSize( 24 );
AddToolBar( bar, ToolbarPosition.Top );
bar.AddOption( new Option( "New", "common/new.png", New ) { ToolTip = "New" } );
bar.AddOption( new Option( "Open", "common/open.png", OpenPicker ) { ToolTip = "Open" } );
bar.AddOption( new Option( "Save", "common/save.png", Save ) { ToolTip = "Save", ShortcutName = "editor.save" } );
bar.AddSeparator();
_linkOption = bar.AddOption( new Option( "Link", "link", ToggleAutoKey )
{
ToolTip = "Auto-Key - posing a bone writes a keyframe at the playhead. Off: pose without keying."
} );
UpdateLinkOption();
bar.AddSeparator();
// The explicit "make a keyframe" action. Until this existed the only way to create one was
// as a side effect of dragging a bone with auto-key on - which works, but is invisible:
// nothing on screen said keyframes were a thing you could make on purpose.
// The key button itself lives on the Timeline's transport, not here - keying happens AT a
// frame, so it belongs beside the controls that choose the frame. The shortcut (K) is
// still registered on the window so it works wherever focus is.
//
// Transport (play, step, loop, speed, frame rate) deliberately does NOT live here - it's
// all on the Timeline's own bar, next to the thing it moves. This toolbar is for actions
// on the document and the posing tool itself.
_dragModeOption = bar.AddOption( new Option( "Drag Mode", "3d_rotation", ToggleDragMode ) );
UpdateDragModeOption();
bar.AddSeparator();
// Painted, not a font icon - see RigIconButton. Red because this is the one control up here
// that throws something away.
bar.AddWidget( new RigIconButton( this, RigIcon.Delete, Theme.Red, "",
"Remove the currently loaded model from the viewport", ClearModel ) );
}
private void ToggleAutoKey()
{
_viewport.AutoKeyEnabled = !_viewport.AutoKeyEnabled;
UpdateLinkOption();
}
private void UpdateLinkOption()
{
if ( _linkOption is null ) return;
_linkOption.Icon = _viewport.AutoKeyEnabled ? "link" : "link_off";
}
/// <summary>Keys whichever bone is selected at the playhead, from its pose right now.
///
/// Deliberately keys the CURRENT pose rather than requiring you to nudge the bone first, so
/// holding a pose across a span - the ordinary way you stop a limb drifting between two other
/// keys - is one keypress rather than a fake drag.</summary>
[Shortcut( "rig.keybone", "K", ShortcutType.Window )]
private void KeySelectedBone()
{
if ( _anim is null )
return;
if ( _viewport.SelectedBone is not { } bone )
{
RigStatusBar.Show( "Select a bone in the viewport first - there's nothing to key yet" );
return;
}
if ( !_viewport.TryGetLocalTransform( bone, out var local ) )
return;
var frame = (int)MathF.Round( _timeline.Playhead );
_anim.GetOrAddTrack( bone ).SetKeyframe( frame, local );
_timeline.Refresh();
MarkDirty( $"Key {bone}" );
RigStatusBar.Show( $"Keyed {bone} at frame {frame}" );
}
private void ToggleDragMode()
{
_viewport.DragMode = _viewport.DragMode == BoneDragMode.Rotate ? BoneDragMode.Move : BoneDragMode.Rotate;
UpdateDragModeOption();
}
private void UpdateDragModeOption()
{
if ( _dragModeOption is null ) return;
var rotate = _viewport.DragMode == BoneDragMode.Rotate;
_dragModeOption.Icon = rotate ? "3d_rotation" : "open_with";
_dragModeOption.ToolTip = rotate
? "Dragging a bone rotates it. Hold E to move instead. Rotation is what you want for almost all posing - joints pivot, they don't slide."
: "Dragging a bone moves it. Hold E to rotate instead. Moving a bone stretches the skin, so it's mainly for root and IK-target bones.";
}
private void New()
{
void Proceed()
{
_asset = null;
// Same default as opening the tool: a new clip starts with something to pose.
_anim = new RigAnimDocument
{
SourceModel = Model.Load( DefaultModelPath )
};
_rig = null;
_lastModel = null;
ApplyRigToPanels();
_timeline.SetAsset( _anim );
_events.SetAsset( _anim );
_bones.SetAsset( _asset, _anim );
_bones.Rebuild();
ResetDirty();
_undoStack.Clear();
ResetBaseline();
}
if ( !_dirty )
{
Proceed();
return;
}
var confirm = new PopupWindow( "Unsaved Changes",
$"\"{_asset?.Name ?? "untitled"}\" has unsaved changes. Would you like to save now?", "Cancel",
new System.Collections.Generic.Dictionary<string, System.Action>
{
{ "Don't Save", Proceed },
{ "Save", () => { Save(); Proceed(); } }
} );
confirm.Show();
}
private void BuildDocks()
{
_viewport = new RigViewport( this );
_viewport.BoneSelected += bone =>
{
_bones?.Rebuild();
// Selection is one shared idea, not one per panel - picking a bone anywhere highlights
// its row on the timeline too, and clicking a timeline row selects it in the viewport.
if ( _timeline is not null )
{
_timeline.SelectedBone = bone;
_timeline.Refresh();
}
_inspector?.Refresh();
RefreshTutorial();
};
_viewport.BonePosed += OnBonePosed;
_viewport.BoneDragStarted += OnBoneDragStarted;
_viewport.BoneDragEnded += OnBoneDragEnded;
// Same three-signal shape as a bone drag, for the same reason: the move fires every frame
// and would bury the undo stack under hundreds of one-pixel entries if it recorded there.
_viewport.ReferencePropDragStarted += () =>
{
_undoStack.Push( _baseline?.WithLabel( "Move Reference Prop" ) );
_baseline = RigSnapshot.Capture( _anim, _rig );
UpdateUndoOptions();
};
_viewport.ReferencePropMoved += () =>
{
// The panel shows the same numbers the gizmo is changing, so it has to follow.
_bones?.Rebuild();
MarkDirtyOnly();
};
_viewport.ReferencePropDragEnded += ResetBaseline;
// Hiding edits the .ctrlrig, so it's a real document change - dirty, saved, and undoable
// like any other. The bones panel rebuilds so its tree can show what's hidden.
_viewport.BoneVisibilityChanged += () =>
{
_bones?.Rebuild();
MarkDirty( "Change Bone Visibility" );
};
_centralDock = DockManager.SetCentralWidget( _viewport );
_timeline = new RigTimeline( this )
{
Scrubbed = OnScrub,
Edited = () => MarkDirty( "Timeline Edit" ),
KeyRequested = KeySelectedBone,
BoneRowSelected = bone => _viewport.Select( bone ),
};
_events = new RigEventProperties( this )
{
Edited = () => MarkDirty( "Edit Anim Event" ),
};
_bones = new RigBonesPanel( this, _viewport )
{
Edited = () =>
{
LoadRig();
// ApplyRigToPanels respawns the reference props, which is what picks up a model
// being assigned to one.
ApplyRigToPanels();
MarkDirty( "Edit Rig Source" );
},
};
_constraints = new RigConstraintsPanel( this )
{
Edited = () => MarkDirty( "Edit Constraint" ),
};
// SetLocalTransform fires BonePosed on its way through, so the keyframe and the dirty flag
// are already handled by the time this runs. All that's left is the undo step - one per
// field edit, unlike a drag, which is one per drag.
_inspector = new RigInspectorPanel( this, _viewport )
{
Edited = () => MarkDirty( "Edit Bone Transform" ),
};
_tutorialPanel = new RigTutorialPanel( this )
{
Tutorial = _tutorial,
Changed = RefreshTutorial,
// Opening the dock the step is talking about, rather than naming it and hoping.
RevealPanel = title =>
{
DockManager.SetDockState( title, true );
DockManager.RaiseDock( title );
},
};
DockManager.RegisterDock( new() { Title = "Tutorial", Icon = "school", Area = DockArea.Hidden, CreateAction = () => _tutorialPanel } );
DockManager.RegisterDock( new() { Title = "AnimEvents", Icon = "bolt", Area = DockArea.Hidden, CreateAction = () => _events } );
DockManager.RegisterDock( new() { Title = "BonesObject", Icon = "polyline", Area = DockArea.Hidden, CreateAction = () => _bones } );
DockManager.RegisterDock( new() { Title = "Constraints", Icon = "link", Area = DockArea.Hidden, CreateAction = () => _constraints } );
DockManager.RegisterDock( new() { Title = "Inspector", Icon = "tune", Area = DockArea.Hidden, CreateAction = () => _inspector } );
// THE EDITOR'S OWN CONSOLE, not one of ours.
//
// Half the tool's diagnostics go to the log - rig_test_pose, rig_debug_drag, the sample
// and wave builders - and reading them meant leaving the tool for the main editor window,
// which on a second monitor is fine and on one monitor means losing sight of the thing you
// are debugging.
//
// ConsoleWidget isn't publicly constructible, so it's created by name through the type
// library. That's not a workaround - it's exactly what ShaderGraph does to dock the same
// widget (ShaderGraph/Code/MainWindow.cs:1180), and it means this is the real console with
// real command input, not a log view that reimplements a third of one.
if ( EditorTypeLibrary.Create( "ConsoleWidget", typeof( Widget ), new object[] { this } ) is Widget console )
DockManager.RegisterDock( new() { Title = "Console", Icon = "text_snippet", Area = DockArea.Hidden, CreateAction = () => console } );
DockManager.RegisterDock( new() { Title = "Timeline", Icon = "view_timeline", Area = DockArea.Hidden, CreateAction = () => _timeline } );
// Bumped from "RigControlEditor" - the blank-window bug shipped its first broken layout
// (nothing opened) under that cookie, and would otherwise keep restoring that empty state
// forever instead of ever calling BuildDefaultLayout again.
// Bumped for the Tutorial dock. A saved layout under the old cookie has no Tutorial in it,
// and DockWindow only calls BuildDefaultLayout when there's nothing to restore - so
// without this, the new panel would silently never appear for anyone who had opened the
// tool before.
// Bumped again for the wider default split. Splitter proportions live in the saved layout,
// so without a new cookie anyone who has already opened the tool keeps the narrow columns
// forever and never sees the change.
// Bumped again for the Inspector dock, for the same reason the Tutorial bump was needed:
// a layout saved under the old cookie has no Inspector in it, and a dock that only exists
// in BuildDefaultLayout never appears for anyone who has opened the tool before.
// Bumped again for the Tutorial moving out of the tab group into its own row, and the new
// splitter proportions. Both live entirely in the saved layout, so a restored Marionette3
// layout would keep the old arrangement and BuildDefaultLayout would never run again.
// Bumped again for the taller timeline row, and again for the Console dock.
StateCookie = "Marionette6";
_lastModel = _anim.SourceModel;
_viewport.SetModel( _lastModel );
ApplyRigToPanels();
_timeline.SetAsset( _anim );
_events.SetAsset( _anim );
_bones.SetAsset( _asset, _anim );
_bones.Rebuild();
}
// Registering a dock (RegisterDock) only tells the DockManager it exists - actually placing
// it on screen has to happen here. DockWindow calls this itself once there's no saved layout
// for StateCookie to restore instead; calling OpenDock inline in the constructor (the bug
// that shipped first) registers docks nobody ever tells to open.
protected override void BuildDefaultLayout()
{
// THE TUTORIAL IS ITS OWN DOCK, NOT A TAB. Tabbed alongside the property sheets it was
// either covering them or being covered by them, so following a step meant flipping back
// and forth between the instruction and the panel the instruction is about. Below them in
// the same column, both are on screen at once, which is the only arrangement where a
// written step and the fields it refers to are usable together.
var bonesDock = DockManager.OpenDock( "BonesObject", DockArea.Right, _centralDock );
// A quarter of the width. Narrower than the 0.62/0.38 it replaced, which was sized for a
// column that had to hold the tutorial's prose as well; the tutorial now has its own space
// underneath, so the sheets only need to fit label-and-field rows.
DockManager.SetSplitterProportions( bonesDock, 0.75f, 0.25f );
// Center, not Right - Right would split the space into three columns (the bug that
// shipped first); Center stacks a dock as a tab alongside whatever's already there.
DockManager.OpenDock( "Inspector", DockArea.Center, bonesDock );
// AnimEvents and Constraints are registered but deliberately NOT opened here. Both are
// for work that comes after you can already pose and key - and four tabs across a quarter
// width column truncates every one of their titles. They're one click away in View.
DockManager.RaiseDock( "BonesObject" );
if ( RigTutorial.OpenOnStartup )
{
var tutorialDock = DockManager.OpenDock( "Tutorial", DockArea.Bottom, bonesDock );
DockManager.SetSplitterProportions( tutorialDock, 0.5f, 0.5f );
}
// A third of the height, up from a fifth. The timeline is where the actual work of timing
// happens and it was the most cramped thing on screen - the viewport had space to spare.
var timeline = DockManager.OpenDock( "Timeline", DockArea.Bottom, _centralDock );
DockManager.SetSplitterProportions( timeline, 0.67f, 0.33f );
// Tabbed behind the timeline rather than beside it. The console is for when something has
// gone wrong or a builder command has been run - worth one click, not worth permanent
// space next to the thing you use every second.
DockManager.OpenDock( "Console", DockArea.Center, timeline );
DockManager.RaiseDock( "Timeline" );
}
private void ApplyRigToPanels()
{
_constraints.SetRig( _rig );
_viewport.Rig = _rig;
_viewport.SetReferenceProps( _anim?.ReferenceProps );
// SourceModel on the clip itself wins; the rig's own model is only a fallback for a clip
// that hasn't set one yet. Previously this only ever updated the viewport in the fallback
// case (SourceModel is null) - setting Model directly in BonesObject never reached the
// viewport at all, which is why it stayed blank after picking a model there.
var wanted = _anim.SourceModel ?? _rig?.SourceModel;
if ( wanted != _lastModel )
{
_lastModel = wanted;
_viewport.SetModel( _lastModel );
}
}
/// <summary>Toolbar's Clear Model - drops the loaded model (and everything the viewport
/// spawned for it) without touching keyframes/events already authored, so a broken or wrong
/// test model can be backed out of instead of being stuck staring at it.</summary>
private void ClearModel()
{
if ( _anim is not null )
_anim.SourceModel = null;
_lastModel = null;
_viewport.SetModel( null );
_bones.SetAsset( _asset, _anim );
_bones.Rebuild();
MarkDirty( "Clear Model" );
}
private void OnBonePosed( string bone, Transform local )
{
if ( _anim is null )
return;
var track = _anim.GetOrAddTrack( bone );
track.SetKeyframe( (int)MathF.Round( _timeline.Playhead ), local );
_timeline.Refresh();
// The numbers follow the gizmo. Guarded on its side against writing them back, so a drag
// updating the fields can't turn into the fields re-posing the bone mid-drag.
_inspector?.Refresh();
// No undo step here - this fires every frame of a drag, and the whole drag was already
// recorded as one step by OnBoneDragStarted.
MarkDirtyOnly();
}
private void OnScrub( float frame )
{
if ( _anim is null )
return;
_viewport.EvaluatePose( bone => _anim.FindTrack( bone ) is { } track && track.Keyframes.Count > 0 ? track.Evaluate( frame ) : null );
// Scrubbing changes the pose without any drag, so nothing else would update the fields.
_inspector?.Refresh();
}
private void Scrub( float frame ) => _timeline.Playhead = frame;
// UNDO
//
// The panels mutate the document and then tell us about it, so by the time MarkDirty runs the
// pre-edit state is already gone. _baseline is that missing piece: a snapshot of the last
// committed state, kept up to date, pushed onto the stack when an edit lands and immediately
// re-taken. Snapshotting inside MarkDirty instead would record the RESULT of the edit, and
// undo would restore the thing you were trying to undo.
private readonly RigUndoStack _undoStack = new();
private RigSnapshot _baseline;
private Option _undoOption;
private Option _redoOption;
private void ResetBaseline()
{
_baseline = RigSnapshot.Capture( _anim, _rig );
UpdateUndoOptions();
}
/// <summary>A bone drag fires BonePosed every frame, so undo is recorded once here at the
/// start and suppressed until the drag ends - one drag, one undo step.</summary>
private void OnBoneDragStarted( string bone )
{
_undoStack.Push( _baseline?.WithLabel( $"Pose {bone}" ) );
_baseline = RigSnapshot.Capture( _anim, _rig );
UpdateUndoOptions();
}
/// <summary>The drag's result becomes the new baseline, so the next edit undoes back to the
/// posed state rather than to before the drag.</summary>
private void OnBoneDragEnded() => ResetBaseline();
// ShortcutType.Window, matching ShaderGraph's MainWindow. Without it the shortcut registers at
// the wrong scope and never reaches this window - which is its own reason Ctrl+Z appeared to
// do nothing, entirely separate from whether undo itself worked.
[Shortcut( "editor.undo", "CTRL+Z", ShortcutType.Window )]
private void Undo()
{
var restored = _undoStack.Undo( RigSnapshot.Capture( _anim, _rig ) );
if ( restored is null )
return;
ApplyRestoredSnapshot( restored );
}
// CTRL+Y, which is what the editor's own asset editors bind redo to. CTRL+SHIFT+Z was my
// habit, not this editor's convention.
[Shortcut( "editor.redo", "CTRL+Y", ShortcutType.Window )]
private void Redo()
{
var restored = _undoStack.Redo( RigSnapshot.Capture( _anim, _rig ) );
if ( restored is null )
return;
ApplyRestoredSnapshot( restored );
}
private void ApplyRestoredSnapshot( RigSnapshot snapshot )
{
snapshot.RestoreTo( _anim, _rig );
// The document changed underneath every panel, so all of them are stale.
_timeline.Refresh();
_events.SetAsset( _anim );
_bones.Rebuild();
_constraints.Rebuild();
// And the viewport is still showing the pose from before the undo.
OnScrub( _timeline.Playhead );
_baseline = RigSnapshot.Capture( _anim, _rig );
_dirty = true;
UpdateTitle();
if ( _saveOption is not null )
_saveOption.Enabled = true;
UpdateUndoOptions();
}
private void UpdateUndoOptions()
{
if ( _undoOption is not null )
{
_undoOption.Enabled = _undoStack.CanUndo;
_undoOption.Text = _undoStack.CanUndo ? $"Undo {_undoStack.UndoLabel}" : "Undo";
}
if ( _redoOption is not null )
{
_redoOption.Enabled = _undoStack.CanRedo;
_redoOption.Text = _undoStack.CanRedo ? $"Redo {_undoStack.RedoLabel}" : "Redo";
}
}
/// <summary>Records an undo step, then marks the document dirty.</summary>
private void MarkDirty( string undoLabel = "Edit" )
{
_undoStack.Push( _baseline?.WithLabel( undoLabel ) );
_baseline = RigSnapshot.Capture( _anim, _rig );
UpdateUndoOptions();
MarkDirtyOnly();
}
/// <summary>Marks dirty WITHOUT recording an undo step - for edits already covered by one.
///
/// This replaced a _suppressUndo flag that MarkDirty checked. The flag was set when a bone
/// drag started and cleared when it ended, and any path where a drag stopped without its end
/// firing left it stuck on, silently disabling undo for the rest of the session. A flag whose
/// failure mode is "undo quietly stops existing" is the wrong mechanism; whether an edit is
/// part of a larger action is known at the call site, so the call site picks.</summary>
private void MarkDirtyOnly()
{
_dirty = true;
UpdateTitle();
if ( _saveOption is not null )
_saveOption.Enabled = true;
RefreshTutorial();
}
private void ResetDirty()
{
_dirty = false;
UpdateTitle();
if ( _saveOption is not null )
_saveOption.Enabled = false;
}
private void UpdateTitle() => Title = $"Marionette - {_asset?.Path ?? "nothing open"}{(_dirty ? "*" : "")}";
[Shortcut( "editor.save", "CTRL+S", ShortcutType.Window )]
private void Save()
{
if ( _anim is null )
return;
// A window opened blank (Tools menu, or New) has no backing file - Save silently did
// nothing here before, since _asset is only ever set inside LoadAsset. This is the
// "saving doesn't actually function" bug: there was no Save As for that case at all.
if ( _asset is null && !PickSaveLocation() )
return;
_asset.SaveToDisk( _anim );
if ( _rig is not null )
{
var rigAsset = AssetSystem.FindByPath( _rig.ResourcePath );
rigAsset?.SaveToDisk( _rig );
}
ResetDirty();
MainAssetBrowser.Instance?.Local.UpdateAssetList();
}
/// <summary>First save of a blank document - prompts for where to put it, creates the
/// .riganim there, and points _asset at it so every save after this is a normal overwrite.</summary>
private bool PickSaveLocation()
{
var fd = new FileDialog( null )
{
Title = "Save Rig Animation As...",
DefaultSuffix = ".riganim",
Directory = Project.Current?.GetAssetsPath() ?? "",
};
fd.SelectFile( "untitled.riganim" );
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter( "Rig Animation (*.riganim)" );
if ( !fd.Execute() )
return false;
var created = AssetSystem.CreateResource( "riganim", fd.SelectedFile );
if ( created is null )
return false;
_asset = created;
UpdateTitle();
return true;
}
protected override bool OnClose()
{
if ( !_dirty )
return true;
var confirm = new PopupWindow( "Unsaved Changes",
$"\"{_asset?.Name ?? "untitled"}\" has unsaved changes. Would you like to save now?", "Cancel",
new System.Collections.Generic.Dictionary<string, System.Action>
{
{ "Don't Save", () => { _dirty = false; Close(); } },
{ "Save", () => { Save(); Close(); } }
} );
confirm.Show();
return false;
}
}