Editor/Services/TimelineInteraction.cs

Editor utility for timeline interactions in the weapon animator. It provides frame/time conversion, range resolving, zoom/pan/resize operations, tick spacing calculation, marquee projection, selection combination and helper math for positioning keys and rows.

Reflection
#nullable enable annotations

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

namespace SboxWeaponAnimator.Editor;

internal readonly record struct TimelineFrameRange( int StartFrame, int EndFrame )
{
	public int Span => Math.Max( EndFrame - StartFrame, 0 );
}

internal readonly record struct TimelineTickSpacing( int MinorFrames, int MajorFrames );

internal readonly record struct TimelineMarqueeBounds(
	float Left,
	float Top,
	float Right,
	float Bottom );

internal readonly record struct TimelineKeyMarkerPosition( float X, float Y );

internal static class TimelineInteraction
{
	public const int MinimumVisibleFrameIntervals = 2;
	public const float KeyMarkerRadius = 5;

	public static int LastFrame( WeaponAnimationClip clip ) =>
		Math.Max( 1, (int)MathF.Floor(
			MathF.Max( clip.Duration, 0 ) * MathF.Max( clip.SampleRate, 1 ) + 0.0001f ) );

	public static int TimeToFrame( float time, float sampleRate ) =>
		Math.Max( 0, (int)MathF.Round( MathF.Max( time, 0 ) * MathF.Max( sampleRate, 1 ) ) );

	public static float FrameToTime( int frame, float sampleRate ) =>
		Math.Max( frame, 0 ) / MathF.Max( sampleRate, 1 );

	public static float SnapTime( WeaponAnimationClip clip, float time ) =>
		FrameToTime(
			Math.Clamp( TimeToFrame( time, clip.SampleRate ), 0, LastFrame( clip ) ),
			clip.SampleRate );

	public static TimelineFrameRange ResolveRange(
		WeaponAnimationClip clip,
		TimelineViewState? state )
	{
		var last = LastFrame( clip );
		if ( state is null || state.VisibleEnd <= state.VisibleStart )
			return new TimelineFrameRange( 0, last );

		var start = Math.Clamp( TimeToFrame( state.VisibleStart, clip.SampleRate ), 0, last );
		var end = Math.Clamp( TimeToFrame( state.VisibleEnd, clip.SampleRate ), 0, last );
		return NormalizeRange( new TimelineFrameRange( start, end ), last );
	}

	public static TimelineFrameRange Zoom(
		TimelineFrameRange range,
		int lastFrame,
		bool zoomIn )
	{
		range = NormalizeRange( range, lastFrame );
		var minimum = Math.Min( MinimumVisibleFrameIntervals, lastFrame );
		var requested = zoomIn
			? Math.Max( minimum, (int)MathF.Floor( range.Span * 0.8f ) )
			: Math.Min( lastFrame, (int)MathF.Ceiling( range.Span * 1.25f ) );
		if ( requested == range.Span )
			requested = Math.Clamp(
				range.Span + (zoomIn ? -1 : 1),
				minimum,
				lastFrame );

		// Preserve the current midpoint while the range changes size.
		var center = (range.StartFrame + range.EndFrame) * 0.5f;
		var start = (int)MathF.Round( center - requested * 0.5f );
		return NormalizeRange(
			new TimelineFrameRange( start, start + requested ),
			lastFrame,
			requested );
	}

	public static TimelineFrameRange Pan(
		TimelineFrameRange range,
		int deltaFrames,
		int lastFrame )
	{
		range = NormalizeRange( range, lastFrame );
		var start = Math.Clamp(
			range.StartFrame + deltaFrames,
			0,
			Math.Max( lastFrame - range.Span, 0 ) );
		return new TimelineFrameRange( start, start + range.Span );
	}

	public static TimelineFrameRange ResizeStart(
		TimelineFrameRange range,
		int startFrame,
		int lastFrame )
	{
		range = NormalizeRange( range, lastFrame );
		var minimum = Math.Min( MinimumVisibleFrameIntervals, lastFrame );
		return new TimelineFrameRange(
			Math.Clamp( startFrame, 0, Math.Max( range.EndFrame - minimum, 0 ) ),
			range.EndFrame );
	}

	public static TimelineFrameRange ResizeEnd(
		TimelineFrameRange range,
		int endFrame,
		int lastFrame )
	{
		range = NormalizeRange( range, lastFrame );
		var minimum = Math.Min( MinimumVisibleFrameIntervals, lastFrame );
		return new TimelineFrameRange(
			range.StartFrame,
			Math.Clamp( endFrame, Math.Min( range.StartFrame + minimum, lastFrame ), lastFrame ) );
	}

	public static TimelineTickSpacing TickSpacing( float pixelsPerFrame )
	{
		var minor = NiceFrameStep( 6 / MathF.Max( pixelsPerFrame, 0.0001f ) );
		var major = NiceFrameStep( 68 / MathF.Max( pixelsPerFrame, 0.0001f ) );
		return new TimelineTickSpacing(
			Math.Max( minor, 1 ),
			Math.Max( major, minor ) );
	}

	public static int ClampGroupFrameDelta(
		IEnumerable<int> selectedFrames,
		int requestedDelta,
		int lastFrame )
	{
		var frames = selectedFrames.ToArray();
		if ( frames.Length == 0 )
			return 0;
		return Math.Clamp(
			requestedDelta,
			-frames.Min(),
			lastFrame - frames.Max() );
	}

	public static HashSet<Guid> CombineKeySelection(
		IEnumerable<Guid> original,
		IEnumerable<Guid> hits,
		bool additive,
		bool toggle )
	{
		var result = additive || toggle
			? original.ToHashSet()
			: [];
		if ( toggle )
		{
			foreach ( var hit in hits )
			{
				if ( !result.Remove( hit ) )
					result.Add( hit );
			}
			return result;
		}

		result.UnionWith( hits );
		return result;
	}

	public static TimelineKeyMarkerPosition KeyMarkerPosition(
		float timelineX,
		float rowTop,
		float graphLeft,
		float graphRight,
		float trackHeight )
	{
		var minimum = graphLeft + KeyMarkerRadius;
		var maximum = graphRight - KeyMarkerRadius;
		var x = maximum >= minimum
			? Math.Clamp( timelineX, minimum, maximum )
			: (graphLeft + graphRight) * 0.5f;
		return new TimelineKeyMarkerPosition(
			MathF.Round( x ),
			MathF.Round( rowTop + trackHeight * 0.5f ) );
	}

	public static TimelineMarqueeBounds ProjectMarquee(
		float startX,
		float startContentY,
		float currentX,
		float currentContentY,
		float verticalScroll,
		float minimumX,
		float maximumX )
	{
		var left = MathF.Max( MathF.Min( startX, currentX ), minimumX );
		var right = MathF.Min( MathF.Max( startX, currentX ), maximumX );
		var top = MathF.Min( startContentY, currentContentY ) - verticalScroll;
		var bottom = MathF.Max( startContentY, currentContentY ) - verticalScroll;
		return new TimelineMarqueeBounds(
			left,
			top,
			MathF.Max( right, left ),
			MathF.Max( bottom, top ) );
	}

	public static int TrackRowCount(
		WeaponAnimationDocument document,
		WeaponAnimationClip clip ) =>
		document.Rig.VisibilityParts.Count + clip.Tracks.Count + 1;

	private static TimelineFrameRange NormalizeRange(
		TimelineFrameRange range,
		int lastFrame,
		int? requestedSpan = null )
	{
		lastFrame = Math.Max( lastFrame, 1 );
		var minimum = Math.Min( MinimumVisibleFrameIntervals, lastFrame );
		var span = Math.Clamp(
			requestedSpan ?? range.Span,
			minimum,
			lastFrame );
		var start = Math.Clamp( range.StartFrame, 0, lastFrame - span );
		return new TimelineFrameRange( start, start + span );
	}

	private static int NiceFrameStep( float requested )
	{
		if ( requested <= 1 )
			return 1;

		var scale = 1;
		while ( scale < 1_000_000 )
		{
			foreach ( var multiplier in new[] { 1, 2, 5 } )
			{
				var candidate = multiplier * scale;
				if ( candidate >= requested )
					return candidate;
			}
			scale *= 10;
		}
		return scale;
	}
}