Editor/WeaponAnimatorController.cs

Editor controller for the weapon animation editor. Manages the WeaponAnimationDocument, undo/redo history, timeline playback, selection and key editing (copy/cut/paste/move/reverse), curve editing options, visibility parts, and dispatches change events to the UI.

File AccessNetworking
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public sealed class WeaponAnimatorController
{
	private const int MaximumHistory = 256;
	private static string KeyClipboard = "";
	private readonly List<DocumentSnapshot> _undo = [];
	private readonly List<DocumentSnapshot> _redo = [];
	private string? _continuousBefore;
	private string _continuousDescription = "";
	private bool _continuousWasDirty;
	private bool _isPlaying;
	private float _playbackTime;

	public WeaponAnimationDocument Document { get; private set; } = WeaponAnimationDocument.CreateDefault();
	public bool IsDirty { get; private set; }
	public string LastAction { get; private set; } = "";
	public IReadOnlyCollection<Guid> SelectedKeys => _selectedKeys;
	public bool IsPlaying => _isPlaying;

	// Gizmo drag drivers must be able to tell that an unrelated Mutate closed their continuous
	// edit, otherwise UpdateContinuousEdit silently discards the rest of the drag.
	public bool IsContinuousEditActive => _continuousBefore is not null;

	private readonly HashSet<Guid> _selectedKeys = [];

	public event Action? DocumentChanged;
	public event Action? PoseChanged;
	public event Action? SelectionChanged;
	public event Action? KeySelectionChanged;
	public event Action? DirtyChanged;
	public event Action? TimelineChanged;
	public event Action? TimelineViewChanged;
	public event Action? PlaybackChanged;
	public event Action? ClipPlaybackSettingsChanged;

	public bool CanUndo => _undo.Count > 0;
	public bool CanRedo => _redo.Count > 0;

	public void SetDocument( WeaponAnimationDocument document )
	{
		_continuousBefore = null;
		_continuousDescription = "";
		Document = document ?? WeaponAnimationDocument.CreateDefault();
		_undo.Clear();
		_redo.Clear();
		_selectedKeys.Clear();
		_isPlaying = false;
		_playbackTime = 0;
		IsDirty = false;
		LastAction = "";
		DocumentChanged?.Invoke();
		SelectionChanged?.Invoke();
		KeySelectionChanged?.Invoke();
		DirtyChanged?.Invoke();
		PlaybackChanged?.Invoke();
	}

	public void Mutate( string description, Action<WeaponAnimationDocument> mutation )
	{
		if ( !ApplyMutation( description, mutation ) )
			return;

		DocumentChanged?.Invoke();
	}

	private bool ApplyMutation(
		string description,
		Action<WeaponAnimationDocument> mutation )
	{
		EndContinuousEdit();
		var before = Serialize( Document );
		mutation( Document );
		var after = Serialize( Document );
		if ( before == after )
			return false;

		_undo.Add( new DocumentSnapshot( description, before ) );
		if ( _undo.Count > MaximumHistory )
			_undo.RemoveAt( 0 );
		_redo.Clear();
		LastAction = description;
		SetDirty( true );
		return true;
	}

	public void UpdateWorkspacePreference(
		string description,
		Action<WorkspaceState> mutation )
	{
		EndContinuousEdit();
		var before = Json.Serialize( Document.Workspace );
		mutation( Document.Workspace );
		if ( before == Json.Serialize( Document.Workspace ) )
			return;

		// Viewport-only preferences are read every frame and do not need a full panel rebuild.
		_redo.Clear();
		LastAction = description;
		SetDirty( true );
	}

	public void MarkWorkspacePreferenceChanged( string description )
	{
		LastAction = description;
		SetDirty( true );
	}

	public void BeginContinuousEdit( string description )
	{
		EndContinuousEdit();
		_continuousBefore = Serialize( Document );
		_continuousDescription = description;
		_continuousWasDirty = IsDirty;
	}

	public void UpdateContinuousEdit( Action<WeaponAnimationDocument> mutation )
	{
		if ( _continuousBefore is null )
			return;

		mutation( Document );
		LastAction = _continuousDescription;
		SetDirty( true );
		PoseChanged?.Invoke();
	}

	public void EndContinuousEdit()
	{
		if ( _continuousBefore is null )
			return;

		var before = _continuousBefore;
		var description = _continuousDescription;
		_continuousBefore = null;
		_continuousDescription = "";
		var after = Serialize( Document );
		if ( before == after )
		{
			SetDirty( _continuousWasDirty );
			return;
		}

		_undo.Add( new DocumentSnapshot( description, before ) );
		if ( _undo.Count > MaximumHistory )
			_undo.RemoveAt( 0 );
		_redo.Clear();
		LastAction = description;
		SetDirty( true );
		DocumentChanged?.Invoke();
	}

	public void ReplaceWithoutHistory( WeaponAnimationDocument document, bool dirty )
	{
		Document = document;
		SetDirty( dirty );
		DocumentChanged?.Invoke();
		SelectionChanged?.Invoke();
		KeySelectionChanged?.Invoke();
	}

	public void Undo()
	{
		EndContinuousEdit();
		SetPlaying( false );
		if ( _undo.Count == 0 )
			return;

		var snapshot = _undo[^1];
		_undo.RemoveAt( _undo.Count - 1 );
		_redo.Add( new DocumentSnapshot( snapshot.Description, Serialize( Document ) ) );
		Document = Deserialize( snapshot.Json );
		LastAction = $"Undo {snapshot.Description}";
		SetDirty( true );
		DocumentChanged?.Invoke();
		SelectionChanged?.Invoke();
		KeySelectionChanged?.Invoke();
	}

	public void Redo()
	{
		EndContinuousEdit();
		SetPlaying( false );
		if ( _redo.Count == 0 )
			return;

		var snapshot = _redo[^1];
		_redo.RemoveAt( _redo.Count - 1 );
		_undo.Add( new DocumentSnapshot( snapshot.Description, Serialize( Document ) ) );
		Document = Deserialize( snapshot.Json );
		LastAction = $"Redo {snapshot.Description}";
		SetDirty( true );
		DocumentChanged?.Invoke();
		SelectionChanged?.Invoke();
		KeySelectionChanged?.Invoke();
	}

	public void MarkSaved()
	{
		SetDirty( false );
	}

	public void SelectBone( string name )
	{
		if ( Document.Workspace.SelectedBone == name )
			return;

		Document.Workspace.SelectedBone = name ?? "";
		Document.Workspace.SelectedControl = "";
		SelectionChanged?.Invoke();
	}

	public void SelectControl( string name )
	{
		if ( Document.Workspace.SelectedControl == name )
			return;

		Document.Workspace.SelectedControl = name ?? "";
		Document.Workspace.SelectedBone = "";
		SelectionChanged?.Invoke();
	}

	public void SelectClip( Guid clipId )
	{
		if ( Document.Workspace.SelectedClipId == clipId )
			return;

		Document.Workspace.SelectedClipId = clipId;
		Document.Workspace.TimelineTime = 0;
		_selectedKeys.Clear();
		SetPlaying( false );
		SelectionChanged?.Invoke();
		KeySelectionChanged?.Invoke();
		DocumentChanged?.Invoke();
	}

	public void RenameCustomClip( Guid clipId, string name )
	{
		name = name?.Trim() ?? "";
		if ( string.IsNullOrWhiteSpace( name ) )
			return;

		Mutate( "Rename custom clip", document =>
		{
			var clip = document.Clips.FirstOrDefault( item =>
				item.Id == clipId && item.Role == WeaponClipRole.Custom );
			if ( clip is null || clip.Name == name )
				return;

			clip.Name = name;
			clip.GeneratedSequenceName = "";
			WeaponAnimationNames.RepairCustomSequenceNames( document );
		} );
	}

	public void DeleteCustomClip( Guid clipId )
	{
		var removed = false;
		Mutate( "Delete custom clip", document =>
		{
			var clip = document.Clips.FirstOrDefault( item =>
				item.Id == clipId && item.Role == WeaponClipRole.Custom );
			if ( clip is null )
				return;

			removed = document.Clips.Remove( clip );
			document.Workspace.ClearWorkingPoses( clipId );
			document.Workspace.TimelineViews.RemoveAll( item => item.ClipId == clipId );
			document.Workspace.CurveViews.RemoveAll( item => item.ClipId == clipId );
			if ( document.Workspace.SelectedClipId == clipId )
			{
				document.Workspace.SelectedClipId =
					document.Clips.FirstOrDefault( item => item.Role == WeaponClipRole.Idle )?.Id
					?? document.Clips.FirstOrDefault()?.Id
					?? Guid.Empty;
				document.Workspace.TimelineTime = 0;
			}
		} );

		if ( !removed )
			return;

		_selectedKeys.Clear();
		SetPlaying( false );
		SelectionChanged?.Invoke();
		KeySelectionChanged?.Invoke();
		TimelineChanged?.Invoke();
	}

	public void ToggleSelectedClipLoop()
	{
		var clipId = Document.Workspace.SelectedClipId;
		if ( !ApplyMutation( "Toggle clip loop", document =>
		{
			var clip = document.Clips.FirstOrDefault( item => item.Id == clipId );
			if ( clip is not null )
				clip.Loop = !clip.Loop;
		} ) )
			return;

		// Looping changes playback/export behavior but does not invalidate any editor panels.
		ClipPlaybackSettingsChanged?.Invoke();
	}

	public void SetTimelineTime( float time )
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null )
			return;
		SetPlaying( false );
		SetTimelineFrameInternal(
			TimelineInteraction.TimeToFrame( time, clip.SampleRate ),
			clip );
	}

	public void SetTimelineFrame( int frame )
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null )
			return;
		SetPlaying( false );
		SetTimelineFrameInternal( frame, clip );
	}

	public void StepTimelineFrame( int delta )
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null )
			return;
		var current = TimelineInteraction.TimeToFrame(
			Document.Workspace.TimelineTime,
			clip.SampleRate );
		SetTimelineFrame( current + delta );
	}

	public void JumpToFirstFrame() => SetTimelineFrame( 0 );

	public void JumpToLastFrame()
	{
		var clip = Document.GetSelectedClip();
		if ( clip is not null )
			SetTimelineFrame( TimelineInteraction.LastFrame( clip ) );
	}

	public void TogglePlayback()
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null )
			return;
		if ( _isPlaying )
		{
			SetPlaying( false );
			return;
		}

		var frame = TimelineInteraction.TimeToFrame(
			Document.Workspace.TimelineTime,
			clip.SampleRate );
		if ( frame >= TimelineInteraction.LastFrame( clip ) )
			SetTimelineFrameInternal( 0, clip );
		_playbackTime = Document.Workspace.TimelineTime;
		SetPlaying( true );
	}

	public void PausePlayback() => SetPlaying( false );

	public void AdvancePlayback( float delta )
	{
		var clip = Document.GetSelectedClip();
		if ( !_isPlaying || clip is null || clip.Duration <= 0 )
			return;

		_playbackTime += MathF.Max( delta, 0 );
		if ( _playbackTime > clip.Duration )
		{
			if ( clip.Loop )
				_playbackTime %= clip.Duration;
			else
			{
				_playbackTime = TimelineInteraction.FrameToTime(
					TimelineInteraction.LastFrame( clip ),
					clip.SampleRate );
				SetPlaying( false );
			}
		}

		SetTimelineFrameInternal(
			TimelineInteraction.TimeToFrame( _playbackTime, clip.SampleRate ),
			clip,
			syncPlaybackTime: false );
	}

	internal TimelineFrameRange GetTimelineRange( WeaponAnimationClip clip ) =>
		TimelineInteraction.ResolveRange(
			clip,
			Document.Workspace.GetTimelineView( clip.Id ) );

	internal void SetTimelineRange( WeaponAnimationClip clip, TimelineFrameRange range )
	{
		var current = GetTimelineRange( clip );
		if ( current == range )
			return;

		var state = Document.Workspace.EnsureTimelineView( clip.Id, clip.Duration );
		state.VisibleStart = TimelineInteraction.FrameToTime(
			range.StartFrame,
			clip.SampleRate );
		state.VisibleEnd = TimelineInteraction.FrameToTime(
			range.EndFrame,
			clip.SampleRate );
		LastAction = "Timeline zoom";
		SetDirty( true );
		TimelineViewChanged?.Invoke();
	}

	internal void SetTimelineVerticalScroll( WeaponAnimationClip clip, float value )
	{
		var state = Document.Workspace.EnsureTimelineView( clip.Id, clip.Duration );
		value = MathF.Max( value, 0 );
		if ( MathF.Abs( state.VerticalScroll - value ) <= 0.5f )
			return;

		state.VerticalScroll = value;
		LastAction = "Timeline scroll";
		SetDirty( true );
		TimelineViewChanged?.Invoke();
	}

	internal void SetCurveEditorVisible( bool visible )
	{
		if ( Document.Workspace.CurveEditorVisible == visible )
			return;
		Document.Workspace.CurveEditorVisible = visible;
		if ( visible && Document.GetSelectedClip() is { } clip )
			CurveEditingService.ResolveSelectedTrack( Document, clip );
		LastAction = visible ? "Open curve editor" : "Open key editor";
		SetDirty( true );
		TimelineViewChanged?.Invoke();
	}

	internal void SelectCurveTrack( WeaponAnimationClip clip, Guid trackId )
	{
		var track = clip.Tracks.FirstOrDefault( x => x.Id == trackId && x.Keys.Count > 0 );
		if ( track is null )
			return;
		var view = Document.Workspace.EnsureCurveView( clip.Id );
		if ( view.SelectedTrackId == trackId )
			return;
		view.SelectedTrackId = trackId;
		view.VisibleChannels = CurveEditingService.DefaultVisibleChannels( track );
		view.HasVerticalRange = false;
		if ( !track.Target.StartsWith( "@", StringComparison.Ordinal ) )
		{
			Document.Workspace.SelectedBone = track.Target;
			Document.Workspace.SelectedControl = "";
		}
		else
		{
			Document.Workspace.SelectedControl = track.Target;
			Document.Workspace.SelectedBone = "";
		}
		LastAction = $"Curve track {track.Target}";
		SetDirty( true );
		SelectionChanged?.Invoke();
		TimelineViewChanged?.Invoke();
	}

	internal void SetCurveMode( WeaponAnimationClip clip, CurveEditorMode mode )
	{
		var view = Document.Workspace.EnsureCurveView( clip.Id );
		if ( view.Mode == mode )
			return;
		view.Mode = mode;
		view.HasVerticalRange = false;
		LastAction = $"Curve mode {mode}";
		SetDirty( true );
		TimelineViewChanged?.Invoke();
	}

	internal void SetCurveChannels(
		WeaponAnimationClip clip,
		TransformCurveChannel channels )
	{
		var view = Document.Workspace.EnsureCurveView( clip.Id );
		channels &= TransformCurveChannel.All;
		if ( channels == TransformCurveChannel.None || view.VisibleChannels == channels )
			return;
		view.VisibleChannels = channels;
		view.HasVerticalRange = false;
		LastAction = "Curve channels";
		SetDirty( true );
		TimelineViewChanged?.Invoke();
	}

	internal void SetCurveSearch( WeaponAnimationClip clip, string search )
	{
		var view = Document.Workspace.EnsureCurveView( clip.Id );
		search ??= "";
		if ( view.Search == search )
			return;
		view.Search = search;
		view.TrackScroll = 0;
		LastAction = "Curve search";
		SetDirty( true );
		TimelineViewChanged?.Invoke();
	}

	internal void SetCurveTrackScroll( WeaponAnimationClip clip, float value )
	{
		var view = Document.Workspace.EnsureCurveView( clip.Id );
		value = MathF.Max( value, 0 );
		if ( MathF.Abs( view.TrackScroll - value ) <= 0.5f )
			return;
		view.TrackScroll = value;
		LastAction = "Curve track scroll";
		SetDirty( true );
		TimelineViewChanged?.Invoke();
	}

	internal void SetCurveVerticalRange(
		WeaponAnimationClip clip,
		float minimum,
		float maximum )
	{
		if ( !WeaponAnimationMath.IsFinite( minimum )
			|| !WeaponAnimationMath.IsFinite( maximum )
			|| maximum - minimum < 0.0001f )
			return;
		var view = Document.Workspace.EnsureCurveView( clip.Id );
		view.HasVerticalRange = true;
		view.VerticalMinimum = minimum;
		view.VerticalMaximum = maximum;
		LastAction = "Curve vertical range";
		SetDirty( true );
		TimelineViewChanged?.Invoke();
	}

	internal void FitCurveVerticalRange( WeaponAnimationClip clip )
	{
		var view = Document.Workspace.EnsureCurveView( clip.Id );
		if ( !view.HasVerticalRange )
			return;
		view.HasVerticalRange = false;
		LastAction = "Fit curves";
		SetDirty( true );
		TimelineViewChanged?.Invoke();
	}

	internal void ApplyCurvePreset( CurvePreset preset )
	{
		var clip = Document.GetSelectedClip();
		var track = clip is null
			? null
			: CurveEditingService.ResolveSelectedTrack( Document, clip );
		if ( clip is null || track is null || track.Keys.Count < 2 )
			return;
		var view = Document.Workspace.EnsureCurveView( clip.Id );
		Mutate( $"Curve {preset}", _ =>
		{
			IdleBindPoseService.MarkAuthored( clip );
			CurveEditingService.ApplyPreset(
				track,
				_selectedKeys,
				view.Mode,
				view.VisibleChannels,
				preset );
			CurveEditingService.RepairTrack( track );
		} );
	}

	internal void AlignSelectedCurveHandles()
		=> SetSelectedCurveHandleMode( CurveHandleMode.Aligned );

	internal void SetSelectedCurveHandleMode( CurveHandleMode mode )
	{
		var clip = Document.GetSelectedClip();
		var track = clip is null
			? null
			: CurveEditingService.ResolveSelectedTrack( Document, clip );
		if ( clip is null || track is null )
			return;
		var view = Document.Workspace.EnsureCurveView( clip.Id );
		var keys = track.Keys.Where( x => _selectedKeys.Contains( x.Id ) ).ToArray();
		if ( keys.Length == 0 )
			return;
		Mutate(
			mode == CurveHandleMode.Aligned
				? "Align curve handles"
				: "Free curve handles",
			_ =>
		{
			if ( view.Mode == CurveEditorMode.Speed )
			{
				foreach ( var span in track.CurveSpans.Where( x => x.HasSpeedCurve ) )
				{
					if ( _selectedKeys.Contains( span.StartKeyId ) )
						span.Speed.StartHandleMode = mode;
					if ( _selectedKeys.Contains( span.EndKeyId ) )
						span.Speed.EndHandleMode = mode;
				}
			}
			else
			{
				foreach ( var key in keys )
				{
					if ( mode == CurveHandleMode.Aligned )
						CurveEditingService.AlignHandles( key, view.VisibleChannels );
					else
						CurveEditingService.FreeHandles( key, view.VisibleChannels );
				}
			}
		} );
	}

	public void SelectKeys( IEnumerable<Guid> keyIds, bool additive )
	{
		if ( !additive )
			_selectedKeys.Clear();

		foreach ( var key in keyIds )
			_selectedKeys.Add( key );
		KeySelectionChanged?.Invoke();
	}

	public void SetSelectedKeys( IEnumerable<Guid> keyIds )
	{
		var next = keyIds.ToHashSet();
		if ( _selectedKeys.SetEquals( next ) )
			return;
		_selectedKeys.Clear();
		foreach ( var keyId in next )
			_selectedKeys.Add( keyId );
		KeySelectionChanged?.Invoke();
	}

	public void ToggleSelectedKeys( IEnumerable<Guid> keyIds )
	{
		foreach ( var keyId in keyIds )
		{
			if ( !_selectedKeys.Remove( keyId ) )
				_selectedKeys.Add( keyId );
		}
		KeySelectionChanged?.Invoke();
	}

	public void BeginSelectedKeyMove()
	{
		if ( _selectedKeys.Count > 0 )
		{
			BeginContinuousEdit( "Move keys" );
		}
	}

	public void UpdateSelectedKeyMove(
		IReadOnlyDictionary<Guid, float> startTimes,
		int deltaFrames )
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null || startTimes.Count == 0 )
			return;

		UpdateContinuousEdit( _ =>
		{
			IdleBindPoseService.MarkAuthored( clip );
			ApplySelectedKeyMove( clip, startTimes, deltaFrames );
		} );
	}

	public void EndSelectedKeyMove(
		IReadOnlyDictionary<Guid, float> startTimes,
		int deltaFrames )
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null || startTimes.Count == 0 )
		{
			EndContinuousEdit();
			return;
		}

		UpdateContinuousEdit( _ =>
		{
			ApplySelectedKeyMove( clip, startTimes, deltaFrames );
			RemoveKeyCollisions( clip );
		} );
		EndContinuousEdit();
	}

	internal void EndCurvePointMove()
	{
		var clip = Document.GetSelectedClip();
		if ( clip is not null )
		{
			UpdateContinuousEdit( _ =>
			{
				RemoveKeyCollisions( clip );
				foreach ( var track in clip.Tracks )
				{
					track.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );
					CurveEditingService.RepairTrack( track );
				}
			} );
		}
		EndContinuousEdit();
	}

	public void CopySelectedKeys()
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null || _selectedKeys.Count == 0 )
			return;

		var payload = new KeyClipboardPayload
		{
			Origin = clip.Tracks.SelectMany( x => x.Keys )
				.Select( x => (x.Id, x.Time) )
				.Concat( clip.VisibilityTracks.SelectMany( x => x.Keys )
					.Select( x => (x.Id, x.Time) ) )
				.Where( x => _selectedKeys.Contains( x.Id ) )
				.Select( x => x.Time )
				.DefaultIfEmpty( 0 )
				.Min()
		};

		foreach ( var track in clip.Tracks )
		{
			var keys = track.Keys.Where( x => _selectedKeys.Contains( x.Id ) ).ToList();
			if ( keys.Count == 0 )
				continue;

			payload.Tracks.Add( new ClipboardTrack
			{
				Target = track.Target,
				Kind = track.Kind,
				Interpolation = track.Interpolation,
				Keys = keys,
				CurveSpans = track.CurveSpans
					.Where( x =>
						_selectedKeys.Contains( x.StartKeyId )
						&& _selectedKeys.Contains( x.EndKeyId ) )
					.ToList()
			} );
		}

		foreach ( var track in clip.VisibilityTracks )
		{
			var keys = track.Keys.Where( x => _selectedKeys.Contains( x.Id ) ).ToList();
			if ( keys.Count == 0 )
				continue;

			payload.VisibilityTracks.Add( new ClipboardVisibilityTrack
			{
				PartId = track.PartId,
				Keys = keys
			} );
		}

		KeyClipboard = Json.Serialize( payload );
	}

	public void CutSelectedKeys()
	{
		CopySelectedKeys();
		DeleteSelectedKeys();
	}

	public void PasteKeys()
	{
		if ( string.IsNullOrWhiteSpace( KeyClipboard ) )
			return;

		var payload = Json.Deserialize<KeyClipboardPayload>( KeyClipboard );
		var clip = Document.GetSelectedClip();
		if ( payload is null || clip is null )
			return;

		var pasteTime = Document.Workspace.TimelineTime;
		Mutate( "Paste keys", _ =>
		{
			IdleBindPoseService.MarkAuthored( clip );
			_selectedKeys.Clear();
			foreach ( var sourceTrack in payload.Tracks )
			{
				var targetTrack = clip.EnsureTrack( sourceTrack.Target );
				targetTrack.Kind = sourceTrack.Kind;
				targetTrack.Interpolation = sourceTrack.Interpolation;
				var previousOrder = CurveEditingService.CaptureOrder( targetTrack );

				var remappedKeys = new Dictionary<Guid, Guid>();
				foreach ( var sourceKey in sourceTrack.Keys )
				{
					var key = Json.Deserialize<TransformKey>( Json.Serialize( sourceKey ) )!;
					var sourceId = key.Id;
					key.Id = Guid.NewGuid();
					remappedKeys[sourceId] = key.Id;
					key.Time = TimelineInteraction.SnapTime(
						clip,
						pasteTime + sourceKey.Time - payload.Origin );
					targetTrack.Keys.Add( key );
					_selectedKeys.Add( key.Id );
				}

				var pastedFrames = targetTrack.Keys
					.Where( x => _selectedKeys.Contains( x.Id ) )
					.Select( x => TimelineInteraction.TimeToFrame( x.Time, clip.SampleRate ) )
					.ToHashSet();
				targetTrack.Keys.RemoveAll( x =>
					!_selectedKeys.Contains( x.Id )
					&& pastedFrames.Contains(
						TimelineInteraction.TimeToFrame( x.Time, clip.SampleRate ) ) );

				foreach ( var sourceSpan in sourceTrack.CurveSpans )
				{
					if ( !remappedKeys.TryGetValue( sourceSpan.StartKeyId, out var startId )
						|| !remappedKeys.TryGetValue( sourceSpan.EndKeyId, out var endId ) )
						continue;
					var span = Json.Deserialize<TransformCurveSpan>(
						Json.Serialize( sourceSpan ) )!;
					span.Id = Guid.NewGuid();
					span.StartKeyId = startId;
					span.EndKeyId = endId;
					targetTrack.CurveSpans.Add( span );
				}
				targetTrack.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );
				CurveEditingService.RepairTopology( targetTrack, previousOrder );
			}

			foreach ( var sourceTrack in payload.VisibilityTracks )
			{
				if ( Document.Rig.VisibilityParts.All( x => x.Id != sourceTrack.PartId ) )
					continue;
				var targetTrack = clip.EnsureVisibilityTrack( sourceTrack.PartId );
				foreach ( var sourceKey in sourceTrack.Keys )
				{
					var key = Json.Deserialize<VisibilityKey>( Json.Serialize( sourceKey ) )!;
					key.Id = Guid.NewGuid();
					key.Time = TimelineInteraction.SnapTime(
						clip,
						pasteTime + sourceKey.Time - payload.Origin );
					targetTrack.Keys.Add( key );
					_selectedKeys.Add( key.Id );
				}

				targetTrack.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );
			}
		} );

		KeySelectionChanged?.Invoke();
	}

	public void DeleteSelectedKeys()
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null || _selectedKeys.Count == 0 )
			return;

		Mutate( "Delete keys", _ =>
		{
			IdleBindPoseService.MarkAuthored( clip );
			foreach ( var track in clip.Tracks )
			{
				CurveEditingService.RemoveKeysAndRepair(
					track,
					x => _selectedKeys.Contains( x.Id ) );
			}
			foreach ( var track in clip.VisibilityTracks )
				track.Keys.RemoveAll( x => _selectedKeys.Contains( x.Id ) );
			_selectedKeys.Clear();
		} );

		KeySelectionChanged?.Invoke();
	}

	public void ReverseKeys()
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null )
			return;

		var allKeyIds = clip.Tracks
			.SelectMany( x => x.Keys.Select( key => key.Id ) )
			.Concat( clip.VisibilityTracks.SelectMany( x => x.Keys.Select( key => key.Id ) ) )
			.ToHashSet();
		var reverseSelection = _selectedKeys.Where( allKeyIds.Contains ).ToHashSet();
		var hasSelection = reverseSelection.Count > 0;
		if ( !hasSelection )
			reverseSelection = allKeyIds;
		if ( reverseSelection.Count == 0 )
			return;

		var selectedFrames = clip.Tracks
			.SelectMany( x => x.Keys )
			.Select( x => (x.Id, x.Time) )
			.Concat( clip.VisibilityTracks
				.SelectMany( x => x.Keys )
				.Select( x => (x.Id, x.Time) ) )
			.Where( x => reverseSelection.Contains( x.Id ) )
			.Select( x => TimelineInteraction.TimeToFrame( x.Time, clip.SampleRate ) )
			.ToArray();
		var firstFrame = hasSelection ? selectedFrames.Min() : 0;
		var lastFrame = hasSelection
			? selectedFrames.Max()
			: TimelineInteraction.LastFrame( clip );
		if ( firstFrame == lastFrame )
			return;

		SetPlaying( false );
		Mutate( hasSelection ? "Reverse selected keys" : "Reverse clip keys", _ =>
		{
			IdleBindPoseService.MarkAuthored( clip );
			foreach ( var track in clip.Tracks )
				ReverseTransformTrack(
					clip,
					track,
					reverseSelection,
					firstFrame,
					lastFrame );
			foreach ( var track in clip.VisibilityTracks )
				ReverseVisibilityTrack(
					clip,
					track,
					reverseSelection,
					firstFrame,
					lastFrame );
		} );
	}

	// Keeps an already-open pre-update window safe during S&box hotload.
	[Obsolete( "Use ReverseKeys." )]
	public void MirrorSelectedKeys() => ReverseKeys();

	public void UpsertSelectedTransformKey( string target, RigControlKind kind, Transform value )
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null )
			return;

		var snapped = TimelineInteraction.SnapTime(
			clip,
			Document.Workspace.TimelineTime );

		Mutate( $"Key {target}", _ =>
		{
			IdleBindPoseService.MarkAuthored( clip );
			var track = clip.EnsureTrack( target );
			track.Kind = kind;
			var key = WeaponAnimationMath.UpsertKey( track, snapped, value );
			CurveEditingService.RepairTrack( track );
			Document.Workspace.RemoveWorkingPose( clip.Id, target );
			_selectedKeys.Clear();
			_selectedKeys.Add( key.Id );
			clip.Readiness = clip.Role == WeaponClipRole.Idle
				? ClipReadiness.Ready
				: ClipReadiness.Draft;
		} );

		KeySelectionChanged?.Invoke();
	}

	public void KeySelectedTransform()
	{
		var selectedControl = Document.Workspace.SelectedControl;
		if ( !string.IsNullOrWhiteSpace( selectedControl ) )
		{
			var target = selectedControl switch
			{
				"@primary_hand" => Document.Binding.PrimaryHand,
				"@support_hand" => Document.Binding.SupportHand,
				"@primary_elbow" => Document.Binding.PrimaryElbowPole,
				"@support_elbow" => Document.Binding.SupportElbowPole,
				_ => null
			};
			if ( target is null )
				return;

			var clip = Document.GetSelectedClip();
			var fallback = clip?.Tracks.FirstOrDefault( x =>
					x.Target.Equals( selectedControl, StringComparison.OrdinalIgnoreCase ) ) is { } track
				? WeaponAnimationMath.SampleTrack(
					track,
					Document.Workspace.TimelineTime,
					target.Transform )
				: target.Transform;
			CommitWorkingPose( selectedControl, RigControlKind.Arm, fallback );
			return;
		}

		var selectedBone = Document.Workspace.SelectedBone;
		if ( string.IsNullOrWhiteSpace( selectedBone ) )
			return;
		var weaponSelection = selectedBone.Equals(
				"weapon_root",
				StringComparison.OrdinalIgnoreCase )
			|| Document.Rig.RetainedBones().Any( definition =>
				definition.Name.Equals( selectedBone, StringComparison.OrdinalIgnoreCase ) );
		var skeleton = HostSkeletonBuilder.BuildCached(
			Document,
			includeArmProfile: !weaponSelection );
		if ( !skeleton.ByName.TryGetValue( selectedBone, out var bone ) )
			return;

		var selectedClip = Document.GetSelectedClip();
		var bind = skeleton.GetBindLocal( bone );
		var selectedTrack = selectedClip?.Tracks.FirstOrDefault( x =>
			x.Target.Equals( selectedBone, StringComparison.OrdinalIgnoreCase ) );
		var value = selectedTrack is null
			? bind
			: WeaponAnimationMath.SampleTrack(
				selectedTrack,
				Document.Workspace.TimelineTime,
				bind );
		CommitWorkingPose(
			selectedBone,
			bone.IsWeaponBone ? RigControlKind.Weapon : RigControlKind.Arm,
			value );
	}

	public void ApplyTransformEdit( string target, RigControlKind kind, Transform value )
	{
		if ( Document.Workspace.AutoKey )
		{
			UpsertSelectedTransformKey( target, kind, value );
			return;
		}

		var clip = Document.GetSelectedClip();
		if ( clip is null )
			return;
		Mutate(
			$"Pose {target}",
			document => document.Workspace.SetWorkingPose( clip.Id, target, kind, value ) );
	}

	public void UpdateTransformEditContinuous(
		string target,
		RigControlKind kind,
		Transform value )
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null )
			return;

		UpdateContinuousEdit( document =>
		{
			if ( !document.Workspace.AutoKey )
			{
				document.Workspace.SetWorkingPose( clip.Id, target, kind, value );
				return;
			}

			var snapped = TimelineInteraction.SnapTime(
				clip,
				document.Workspace.TimelineTime );
			IdleBindPoseService.MarkAuthored( clip );
			var track = clip.EnsureTrack( target );
			track.Kind = kind;
			WeaponAnimationMath.UpsertKey( track, snapped, value );
			CurveEditingService.RepairTrack( track );
			document.Workspace.RemoveWorkingPose( clip.Id, target );
			clip.Readiness = clip.Role == WeaponClipRole.Idle
				? ClipReadiness.Ready
				: ClipReadiness.Draft;
		} );
	}

	public void CommitWorkingPose(
		string target,
		RigControlKind kind,
		Transform fallback )
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null )
			return;
		var value = Document.Workspace.GetWorkingPose( clip.Id, target )?.Transform ?? fallback;
		UpsertSelectedTransformKey( target, kind, value );
	}

	public void DiscardWorkingPose( string target )
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null || Document.Workspace.GetWorkingPose( clip.Id, target ) is null )
			return;
		Mutate(
			$"Revert {target}",
			document => document.Workspace.RemoveWorkingPose( clip.Id, target ) );
	}

	public bool HasKeyAtPlayhead( string target )
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null )
			return false;
		var snapped = TimelineInteraction.SnapTime(
			clip,
			Document.Workspace.TimelineTime );
		return clip.Tracks
			.FirstOrDefault( x => x.Target.Equals( target, StringComparison.OrdinalIgnoreCase ) )?
			.Keys.Any( x => MathF.Abs( x.Time - snapped ) <= 0.0001f ) == true;
	}

	public WeaponVisibilityPart? GetVisibilityPart( string boneName ) =>
		Document.Rig.VisibilityParts.FirstOrDefault( x =>
			x.BoneName.Equals( boneName, StringComparison.OrdinalIgnoreCase ) );

	public void AddVisibilityPart( string boneName )
	{
		var definition = Document.Rig.RetainedBones().FirstOrDefault( x =>
			x.Name.Equals( boneName, StringComparison.OrdinalIgnoreCase ) );
		if ( definition is null || GetVisibilityPart( boneName ) is not null )
			return;

		Mutate( $"Enable visibility for {boneName}", document =>
		{
			document.Rig.VisibilityParts.Add( new WeaponVisibilityPart
			{
				Name = DisplayVisibilityName( boneName ),
				BoneId = definition.Id,
				BoneName = definition.Name
			} );
		} );
	}

	public void RemoveVisibilityPart( Guid partId )
	{
		var part = Document.Rig.VisibilityParts.FirstOrDefault( x => x.Id == partId );
		if ( part is null )
			return;

		Mutate( $"Remove visibility from {part.Name}", document =>
		{
			document.Rig.VisibilityParts.RemoveAll( x => x.Id == partId );
			foreach ( var clip in document.Clips )
				clip.VisibilityTracks.RemoveAll( x => x.PartId == partId );
			_selectedKeys.Clear();
		} );
		KeySelectionChanged?.Invoke();
	}

	public void UpdateVisibilityPart(
		Guid partId,
		string description,
		Action<WeaponVisibilityPart> mutation )
	{
		var part = Document.Rig.VisibilityParts.FirstOrDefault( x => x.Id == partId );
		if ( part is null )
			return;
		Mutate( description, _ => mutation( part ) );
	}

	public bool EvaluateVisibility( Guid partId )
	{
		var part = Document.Rig.VisibilityParts.FirstOrDefault( x => x.Id == partId );
		return part is not null && WeaponVisibilityEvaluator.Evaluate(
			part,
			Document.GetSelectedClip(),
			Document.Workspace.TimelineTime );
	}

	public bool HasVisibilityKeyAtPlayhead( Guid partId )
	{
		var clip = Document.GetSelectedClip();
		if ( clip is null )
			return false;
		var snapped = TimelineInteraction.SnapTime(
			clip,
			Document.Workspace.TimelineTime );
		return clip.VisibilityTracks.FirstOrDefault( x => x.PartId == partId )?
			.Keys.Any( x => MathF.Abs( x.Time - snapped ) <= 0.0001f ) == true;
	}

	public void UpsertVisibilityKey( Guid partId, bool visible )
	{
		var clip = Document.GetSelectedClip();
		var part = Document.Rig.VisibilityParts.FirstOrDefault( x => x.Id == partId );
		if ( clip is null || part is null )
			return;

		var snapped = TimelineInteraction.SnapTime(
			clip,
			Document.Workspace.TimelineTime );
		Mutate( $"{(visible ? "Show" : "Hide")} {part.Name}", _ =>
		{
			IdleBindPoseService.MarkAuthored( clip );
			var key = WeaponVisibilityEvaluator.UpsertKey(
				clip.EnsureVisibilityTrack( partId ),
				snapped,
				visible );
			_selectedKeys.Clear();
			_selectedKeys.Add( key.Id );
			clip.Readiness = clip.Role == WeaponClipRole.Idle
				? ClipReadiness.Ready
				: ClipReadiness.Draft;
		} );
		KeySelectionChanged?.Invoke();
	}

	public void RemoveVisibilityKeyAtPlayhead( Guid partId )
	{
		var clip = Document.GetSelectedClip();
		var part = Document.Rig.VisibilityParts.FirstOrDefault( x => x.Id == partId );
		if ( clip is null || part is null )
			return;
		var snapped = TimelineInteraction.SnapTime(
			clip,
			Document.Workspace.TimelineTime );
		Mutate( $"Remove {part.Name} visibility key", _ =>
		{
			var track = clip.VisibilityTracks.FirstOrDefault( x => x.PartId == partId );
			if ( track is null )
				return;
			track.Keys.RemoveAll( x => MathF.Abs( x.Time - snapped ) <= 0.0001f );
			if ( track.Keys.Count == 0 )
				clip.VisibilityTracks.Remove( track );
		} );
	}

	private static string DisplayVisibilityName( string boneName )
	{
		var words = boneName
			.Replace( '_', ' ' )
			.Replace( '-', ' ' )
			.Trim();
		return string.IsNullOrWhiteSpace( words )
			? "Visible Part"
			: string.Join( " ", words
				.Split( ' ', StringSplitOptions.RemoveEmptyEntries )
				.Select( x => char.ToUpperInvariant( x[0] ) + x[1..] ) );
	}

	private void SetTimelineFrameInternal(
		int frame,
		WeaponAnimationClip clip,
		bool syncPlaybackTime = true )
	{
		frame = Math.Clamp( frame, 0, TimelineInteraction.LastFrame( clip ) );
		var time = TimelineInteraction.FrameToTime( frame, clip.SampleRate );
		if ( syncPlaybackTime )
			_playbackTime = time;
		if ( MathF.Abs( Document.Workspace.TimelineTime - time ) <= 0.00001f )
			return;

		Document.Workspace.TimelineTime = time;
		TimelineChanged?.Invoke();
	}

	private void SetPlaying( bool value )
	{
		if ( _isPlaying == value )
			return;
		_isPlaying = value;
		PlaybackChanged?.Invoke();
	}

	private static void ApplySelectedKeyMove(
		WeaponAnimationClip clip,
		IReadOnlyDictionary<Guid, float> startTimes,
		int deltaFrames )
	{
		foreach ( var key in clip.Tracks.SelectMany( x => x.Keys ) )
		{
			if ( !startTimes.TryGetValue( key.Id, out var start ) )
				continue;
			var frame = TimelineInteraction.TimeToFrame( start, clip.SampleRate ) + deltaFrames;
			key.Time = TimelineInteraction.FrameToTime(
				Math.Clamp( frame, 0, TimelineInteraction.LastFrame( clip ) ),
				clip.SampleRate );
		}

		foreach ( var key in clip.VisibilityTracks.SelectMany( x => x.Keys ) )
		{
			if ( !startTimes.TryGetValue( key.Id, out var start ) )
				continue;
			var frame = TimelineInteraction.TimeToFrame( start, clip.SampleRate ) + deltaFrames;
			key.Time = TimelineInteraction.FrameToTime(
				Math.Clamp( frame, 0, TimelineInteraction.LastFrame( clip ) ),
				clip.SampleRate );
		}

		foreach ( var track in clip.Tracks )
		{
			track.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );
			CurveEditingService.RepairTrack( track );
		}
		foreach ( var track in clip.VisibilityTracks )
			track.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );
	}

	private static void ReverseTransformTrack(
		WeaponAnimationClip clip,
		TransformTrack track,
		IReadOnlySet<Guid> reversedKeys,
		int firstFrame,
		int lastFrame )
	{
		CurveEditingService.RepairTrack( track );
		var originalOrder = track.Keys
			.OrderBy( x => x.Time )
			.Select( x => x.Id )
			.ToArray();
		var originalAdjacent = originalOrder
			.Zip( originalOrder.Skip( 1 ), ( start, end ) => (start, end) )
			.ToHashSet();
		var originalSpans = track.CurveSpans
			.GroupBy( x => (x.StartKeyId, x.EndKeyId) )
			.ToDictionary( x => x.Key, x => CloneCurveSpan( x.First() ) );
		var originalTangents = track.Keys.ToDictionary(
			x => x.Id,
			x => CloneCurveTangents( x.CurveTangents ) );
		var originalLegacyTangents = track.Keys.ToDictionary(
			x => x.Id,
			x => (x.InTangent, x.OutTangent) );

		foreach ( var key in track.Keys.Where( x => reversedKeys.Contains( x.Id ) ) )
		{
			var frame = TimelineInteraction.TimeToFrame( key.Time, clip.SampleRate );
			key.Time = TimelineInteraction.FrameToTime(
				firstFrame + lastFrame - frame,
				clip.SampleRate );
		}

		RemoveUnselectedCollisions( clip, track.Keys, reversedKeys );
		track.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );
		track.CurveSpans.Clear();
		foreach ( var (start, end) in track.Keys.Zip(
			track.Keys.Skip( 1 ),
			( start, end ) => (start, end) ) )
		{
			if ( originalSpans.TryGetValue( (start.Id, end.Id), out var forwardSpan ) )
			{
				track.CurveSpans.Add( CloneCurveSpan( forwardSpan ) );
				continue;
			}

			if ( originalSpans.TryGetValue( (end.Id, start.Id), out var backwardSpan ) )
			{
				track.CurveSpans.Add( ReverseCurveSpan( backwardSpan ) );
				ApplyReversedTangents(
					start,
					end,
					originalTangents,
					originalLegacyTangents );
				continue;
			}

			if ( originalAdjacent.Contains( (end.Id, start.Id) ) )
			{
				ApplyReversedTangents(
					start,
					end,
					originalTangents,
					originalLegacyTangents );
				continue;
			}

			if ( originalAdjacent.Contains( (start.Id, end.Id) ) )
				continue;

			// New neighbors exposed by a collision use an explicit, predictable bridge.
			var bridge = track.EnsureCurveSpan( start.Id, end.Id );
			bridge.HasInterpolationOverride = true;
			bridge.Interpolation = TrackInterpolation.Linear;
		}

		CurveEditingService.RepairTrack( track );
	}

	private static void ReverseVisibilityTrack(
		WeaponAnimationClip clip,
		VisibilityTrack track,
		IReadOnlySet<Guid> reversedKeys,
		int firstFrame,
		int lastFrame )
	{
		foreach ( var key in track.Keys.Where( x => reversedKeys.Contains( x.Id ) ) )
		{
			var frame = TimelineInteraction.TimeToFrame( key.Time, clip.SampleRate );
			key.Time = TimelineInteraction.FrameToTime(
				firstFrame + lastFrame - frame,
				clip.SampleRate );
		}

		RemoveUnselectedCollisions( clip, track.Keys, reversedKeys );
		track.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );
	}

	private static void RemoveUnselectedCollisions(
		WeaponAnimationClip clip,
		List<TransformKey> keys,
		IReadOnlySet<Guid> reversedKeys )
	{
		var reversedFrames = keys
			.Where( x => reversedKeys.Contains( x.Id ) )
			.Select( x => TimelineInteraction.TimeToFrame( x.Time, clip.SampleRate ) )
			.ToHashSet();
		keys.RemoveAll( x =>
			!reversedKeys.Contains( x.Id )
			&& reversedFrames.Contains(
				TimelineInteraction.TimeToFrame( x.Time, clip.SampleRate ) ) );
	}

	private static void RemoveUnselectedCollisions(
		WeaponAnimationClip clip,
		List<VisibilityKey> keys,
		IReadOnlySet<Guid> reversedKeys )
	{
		var reversedFrames = keys
			.Where( x => reversedKeys.Contains( x.Id ) )
			.Select( x => TimelineInteraction.TimeToFrame( x.Time, clip.SampleRate ) )
			.ToHashSet();
		keys.RemoveAll( x =>
			!reversedKeys.Contains( x.Id )
			&& reversedFrames.Contains(
				TimelineInteraction.TimeToFrame( x.Time, clip.SampleRate ) ) );
	}

	private static TransformCurveSpan CloneCurveSpan( TransformCurveSpan source ) => new()
	{
		Id = source.Id,
		StartKeyId = source.StartKeyId,
		EndKeyId = source.EndKeyId,
		HasSpeedCurve = source.HasSpeedCurve,
		Speed = CloneMotionRateCurve( source.Speed ),
		HasInterpolationOverride = source.HasInterpolationOverride,
		Interpolation = source.Interpolation,
		CustomChannels = source.CustomChannels
	};

	private static TransformCurveSpan ReverseCurveSpan( TransformCurveSpan source )
	{
		var result = CloneCurveSpan( source );
		result.StartKeyId = source.EndKeyId;
		result.EndKeyId = source.StartKeyId;
		result.Speed = new MotionRateCurve
		{
			StartRate = source.Speed.EndRate,
			EndRate = source.Speed.StartRate,
			StartSlope = -source.Speed.EndSlope,
			EndSlope = -source.Speed.StartSlope,
			StartHandleMode = source.Speed.EndHandleMode,
			EndHandleMode = source.Speed.StartHandleMode
		};
		return result;
	}

	private static MotionRateCurve CloneMotionRateCurve( MotionRateCurve source ) => new()
	{
		StartRate = source.StartRate,
		EndRate = source.EndRate,
		StartSlope = source.StartSlope,
		EndSlope = source.EndSlope,
		StartHandleMode = source.StartHandleMode,
		EndHandleMode = source.EndHandleMode
	};

	private static TransformCurveTangents CloneCurveTangents(
		TransformCurveTangents source ) => new()
	{
		PositionIn = source.PositionIn,
		PositionOut = source.PositionOut,
		RotationIn = source.RotationIn,
		RotationOut = source.RotationOut,
		ScaleIn = source.ScaleIn,
		ScaleOut = source.ScaleOut,
		FreeHandles = source.FreeHandles
	};

	private static void ApplyReversedTangents(
		TransformKey start,
		TransformKey end,
		IReadOnlyDictionary<Guid, TransformCurveTangents> tangents,
		IReadOnlyDictionary<Guid, (Vector3 In, Vector3 Out)> legacyTangents )
	{
		var startSource = tangents[start.Id];
		var endSource = tangents[end.Id];
		start.CurveTangents.PositionOut = -startSource.PositionIn;
		start.CurveTangents.RotationOut = -startSource.RotationIn;
		start.CurveTangents.ScaleOut = -startSource.ScaleIn;
		end.CurveTangents.PositionIn = -endSource.PositionOut;
		end.CurveTangents.RotationIn = -endSource.RotationOut;
		end.CurveTangents.ScaleIn = -endSource.ScaleOut;

		start.OutTangent = -legacyTangents[start.Id].In;
		end.InTangent = -legacyTangents[end.Id].Out;
	}

	private void RemoveKeyCollisions( WeaponAnimationClip clip )
	{
		foreach ( var track in clip.Tracks )
		{
			var previousOrder = CurveEditingService.CaptureOrder( track );
			var occupied = track.Keys
				.Where( x => _selectedKeys.Contains( x.Id ) )
				.Select( x => TimelineInteraction.TimeToFrame( x.Time, clip.SampleRate ) )
				.ToHashSet();
			track.Keys.RemoveAll( x =>
				!_selectedKeys.Contains( x.Id )
				&& occupied.Contains(
					TimelineInteraction.TimeToFrame( x.Time, clip.SampleRate ) ) );
			CurveEditingService.RepairTopology( track, previousOrder );
		}

		foreach ( var track in clip.VisibilityTracks )
		{
			var occupied = track.Keys
				.Where( x => _selectedKeys.Contains( x.Id ) )
				.Select( x => TimelineInteraction.TimeToFrame( x.Time, clip.SampleRate ) )
				.ToHashSet();
			track.Keys.RemoveAll( x =>
				!_selectedKeys.Contains( x.Id )
				&& occupied.Contains(
					TimelineInteraction.TimeToFrame( x.Time, clip.SampleRate ) ) );
		}
	}

	private void SetDirty( bool value )
	{
		if ( IsDirty == value )
			return;

		IsDirty = value;
		DirtyChanged?.Invoke();
	}

	private static string Serialize( WeaponAnimationDocument document ) => Json.Serialize( document );
	private static WeaponAnimationDocument Deserialize( string json ) =>
		Json.Deserialize<WeaponAnimationDocument>( json ) ?? WeaponAnimationDocument.CreateDefault();

	private readonly record struct DocumentSnapshot( string Description, string Json );

	private sealed class KeyClipboardPayload
	{
		public float Origin { get; set; }
		public List<ClipboardTrack> Tracks { get; set; } = [];
		public List<ClipboardVisibilityTrack> VisibilityTracks { get; set; } = [];
	}

	private sealed class ClipboardTrack
	{
		public string Target { get; set; } = "";
		public RigControlKind Kind { get; set; }
		public TrackInterpolation Interpolation { get; set; }
		public List<TransformKey> Keys { get; set; } = [];
		public List<TransformCurveSpan> CurveSpans { get; set; } = [];
	}

	private sealed class ClipboardVisibilityTrack
	{
		public Guid PartId { get; set; }
		public List<VisibilityKey> Keys { get; set; } = [];
	}
}