diff --git a/TombEditor.Tests/FlybyTimeline/FlybyPreviewTests.cs b/TombEditor.Tests/FlybyTimeline/FlybyPreviewTests.cs index 5e87a42a5..d8db688c1 100644 --- a/TombEditor.Tests/FlybyTimeline/FlybyPreviewTests.cs +++ b/TombEditor.Tests/FlybyTimeline/FlybyPreviewTests.cs @@ -1,4 +1,5 @@ using System.Numerics; +using TombEditor.Controls.FlybyTimeline; using TombEditor.Controls.FlybyTimeline.Preview; using TombLib; using TombLib.Graphics; @@ -88,4 +89,38 @@ public void BeginExternalUpdate_AtEndOfSequenceMarksPreviewFinished() Assert.IsTrue(preview.IsFinished); Assert.AreEqual(preview.Cache.TotalDuration, preview.GetCurrentTimeSeconds(), 0.001f); } + + [TestMethod] + public void BuildViewProjection_ClampsFieldOfViewBelowOneEightyDegrees() + { + var savedCamera = new FreeCamera(Vector3.Zero, 0.0f, 0.0f, -MathF.PI * 0.5f, MathF.PI * 0.5f, MathC.DegToRad(60.0f)); + using var preview = new FlybyPreview(savedCamera); + var previewCamera = new FreeCamera(Vector3.Zero, 0.0f, 0.0f, -MathF.PI * 0.5f, MathF.PI * 0.5f, MathC.DegToRad(60.0f)); + var frame = new FlybyFrameState + { + Position = new Vector3(256.0f, 128.0f, 64.0f), + RotationY = 0.0f, + RotationX = 0.0f, + Fov = MathC.DegToRad(220.0f) + }; + + preview.SetStaticFrame(previewCamera, frame); + + var viewProjection = preview.BuildViewProjection(1920.0f, 1080.0f, MathC.DegToRad(220.0f)); + + Assert.AreEqual(FlybyConstants.MaxPreviewFieldOfViewRadians, previewCamera.FieldOfView, 0.001f); + Assert.IsTrue(float.IsFinite(viewProjection.M11)); + Assert.IsTrue(float.IsFinite(viewProjection.M22)); + Assert.IsTrue(float.IsFinite(viewProjection.M33)); + } + + [TestMethod] + public void FromDegrees_ClampsInvalidFieldOfViewIntoPreviewRange() + { + var lowFrame = FlybyFrameState.FromDegrees(Vector3.Zero, 0.0f, 0.0f, 0.0f, -15.0f); + var highFrame = FlybyFrameState.FromDegrees(Vector3.Zero, 0.0f, 0.0f, 0.0f, 220.0f); + + Assert.AreEqual(MathC.DegToRad(FlybyConstants.DefaultPreviewFieldOfViewDegrees), lowFrame.Fov, 0.001f); + Assert.AreEqual(FlybyConstants.MaxPreviewFieldOfViewRadians, highFrame.Fov, 0.001f); + } } diff --git a/TombEditor/Controls/FlybyTimeline/FlybyConstants.cs b/TombEditor/Controls/FlybyTimeline/FlybyConstants.cs index d6f174a1e..de72d30d8 100644 --- a/TombEditor/Controls/FlybyTimeline/FlybyConstants.cs +++ b/TombEditor/Controls/FlybyTimeline/FlybyConstants.cs @@ -1,4 +1,5 @@ -using TombLib.LevelData; +using TombLib; +using TombLib.LevelData; namespace TombEditor.Controls.FlybyTimeline; @@ -60,7 +61,45 @@ public static class FlybyConstants /// /// Lower bound for preview FOV values to avoid invalid or near-zero projection setup. /// - public const float PreviewMinFieldOfView = 0.01f; + public const float PreviewMinFieldOfViewRadians = 0.01f; + + /// + /// Upper bound for flyby camera field-of-view values, expressed in degrees. + /// + public const float MaxFlybyFieldOfViewDegrees = 179.99f; + + /// + /// Default preview field of view, expressed in degrees, used as a fallback when flyby data is invalid or non-finite. + /// + public const float DefaultPreviewFieldOfViewDegrees = 80.0f; + + /// + /// Upper bound for preview projection field-of-view values, expressed in radians. + /// + public static readonly float MaxPreviewFieldOfViewRadians = MathC.DegToRad(MaxFlybyFieldOfViewDegrees); + + /// + /// Clamps a flyby camera field-of-view value expressed in degrees to the valid persisted range. + /// + public static float ClampFlybyFieldOfViewDegrees(float fovDegrees) + { + if (!float.IsFinite(fovDegrees)) + return DefaultPreviewFieldOfViewDegrees; + + return Math.Clamp(fovDegrees, 0.0f, MaxFlybyFieldOfViewDegrees); + } + + /// + /// Clamps a preview field-of-view value expressed in radians to the valid projection range. + /// Invalid or too-small values fall back to the default preview field of view. + /// + public static float ClampPreviewFieldOfViewRadians(float fovRadians) + { + if (!float.IsFinite(fovRadians) || fovRadians < PreviewMinFieldOfViewRadians) + return MathC.DegToRad(DefaultPreviewFieldOfViewDegrees); + + return Math.Min(fovRadians, MaxPreviewFieldOfViewRadians); + } // Timeline constants. diff --git a/TombEditor/Controls/FlybyTimeline/Preview/FlybyFrameState.cs b/TombEditor/Controls/FlybyTimeline/Preview/FlybyFrameState.cs index 3873a1424..1838555b2 100644 --- a/TombEditor/Controls/FlybyTimeline/Preview/FlybyFrameState.cs +++ b/TombEditor/Controls/FlybyTimeline/Preview/FlybyFrameState.cs @@ -26,6 +26,6 @@ public struct FlybyFrameState RotationY = MathC.DegToRad(rotationY), RotationX = -MathC.DegToRad(rotationX), Roll = MathC.DegToRad(roll), - Fov = MathC.DegToRad(fov) + Fov = FlybyConstants.ClampPreviewFieldOfViewRadians(MathC.DegToRad(fov)) }; } diff --git a/TombEditor/Controls/FlybyTimeline/Preview/FlybyPreview.cs b/TombEditor/Controls/FlybyTimeline/Preview/FlybyPreview.cs index d29197149..2535169de 100644 --- a/TombEditor/Controls/FlybyTimeline/Preview/FlybyPreview.cs +++ b/TombEditor/Controls/FlybyTimeline/Preview/FlybyPreview.cs @@ -212,7 +212,7 @@ public static void ApplyFrame(Camera camera, FlybyFrameState frame) camera.Position = frame.Position; camera.RotationY = frame.RotationY; camera.RotationX = frame.RotationX; - camera.FieldOfView = frame.Fov; + camera.FieldOfView = FlybyConstants.ClampPreviewFieldOfViewRadians(frame.Fov); var rotation = CreateFrameRotation(frame); var look = MathC.HomogenousTransform(Vector3.UnitZ, rotation); @@ -254,10 +254,8 @@ public Matrix4x4 BuildViewProjection(float width, float height, float defaultFov } var target = frame.Position + (Level.SectorSizeUnit * look); - float fov = frame.Fov > FlybyConstants.PreviewMinFieldOfView ? frame.Fov : defaultFov; - - if (fov < FlybyConstants.PreviewMinFieldOfView) - fov = MathC.DegToRad(80); + float fov = FlybyConstants.ClampPreviewFieldOfViewRadians( + frame.Fov > FlybyConstants.PreviewMinFieldOfViewRadians ? frame.Fov : defaultFov); var view = MathC.Matrix4x4CreateLookAtLH(frame.Position, target, up); float aspectRatio = height != 0.0f ? width / height : 1.0f; @@ -271,6 +269,5 @@ public Matrix4x4 BuildViewProjection(float width, float height, float defaultFov /// private static Matrix4x4 CreateFrameRotation(FlybyFrameState frame) => Matrix4x4.CreateFromYawPitchRoll(frame.RotationY, frame.RotationX, 0); - #endregion Static frame helpers } diff --git a/TombEditor/Controls/FlybyTimeline/UI/FlybyTimelineControl.Input.cs b/TombEditor/Controls/FlybyTimeline/UI/FlybyTimelineControl.Input.cs index f7991d433..4b71146c3 100644 --- a/TombEditor/Controls/FlybyTimeline/UI/FlybyTimelineControl.Input.cs +++ b/TombEditor/Controls/FlybyTimeline/UI/FlybyTimelineControl.Input.cs @@ -277,8 +277,8 @@ private bool TryHandleTimelineShortcut(Key key) /// /// Expands the viewport to show the full sequence range. /// - public void ZoomToFit() - => ApplyViewport(0.0f, _totalDurationSeconds * FlybyConstants.TimelineZoomOutScale, FlybyConstants.TimelineSmoothZoomEnabled); + public void ZoomToFit(bool smooth = FlybyConstants.TimelineSmoothZoomEnabled) + => ApplyViewport(0.0f, _totalDurationSeconds * FlybyConstants.TimelineZoomOutScale, smooth); /// /// Pans the viewport left by one configured step. diff --git a/TombEditor/Controls/FlybyTimeline/UI/FlybyTimelineView.xaml.cs b/TombEditor/Controls/FlybyTimeline/UI/FlybyTimelineView.xaml.cs index ca93bf898..ba80e1d9e 100644 --- a/TombEditor/Controls/FlybyTimeline/UI/FlybyTimelineView.xaml.cs +++ b/TombEditor/Controls/FlybyTimeline/UI/FlybyTimelineView.xaml.cs @@ -17,6 +17,7 @@ public partial class FlybyTimelineView : UserControl { private FlybyTimelineViewModel? _viewModel; private bool _zoomToFitQueued; + private bool _initialZoomToFitApplied; /// /// Creates the timeline host control. @@ -59,6 +60,7 @@ public void Cleanup() _viewModel.Cleanup(); DataContext = null; _viewModel = null; + _initialZoomToFitApplied = false; } /// @@ -168,7 +170,8 @@ private void QueueZoomToFit() Dispatcher.BeginInvoke(DispatcherPriority.Background, new System.Action(() => { _zoomToFitQueued = false; - timelineControl.ZoomToFit(); + timelineControl.ZoomToFit(_initialZoomToFitApplied); + _initialZoomToFitApplied = true; })); } diff --git a/TombEditor/Controls/FlybyTimeline/ViewModel/FlybyTimelineViewModel.Properties.cs b/TombEditor/Controls/FlybyTimeline/ViewModel/FlybyTimelineViewModel.Properties.cs index 352c07638..b3c9da8f1 100644 --- a/TombEditor/Controls/FlybyTimeline/ViewModel/FlybyTimelineViewModel.Properties.cs +++ b/TombEditor/Controls/FlybyTimeline/ViewModel/FlybyTimelineViewModel.Properties.cs @@ -52,7 +52,7 @@ partial void OnCameraSpeedChanged(float value) /// Applies a field-of-view edit to the selected camera. /// partial void OnCameraFovChanged(float value) - => ApplyPropertyToCamera(c => c.Fov = value, invalidateSequenceTiming: false, refreshTimeline: false); + => ApplyPropertyToCamera(c => c.Fov = FlybyConstants.ClampFlybyFieldOfViewDegrees(value), invalidateSequenceTiming: false, refreshTimeline: false); /// /// Applies a roll edit to the selected camera. diff --git a/TombEditor/Controls/FlybyTimeline/ViewModel/FlybyTimelineViewModel.Selection.cs b/TombEditor/Controls/FlybyTimeline/ViewModel/FlybyTimelineViewModel.Selection.cs index 4c2a6d4c8..ba8905be5 100644 --- a/TombEditor/Controls/FlybyTimeline/ViewModel/FlybyTimelineViewModel.Selection.cs +++ b/TombEditor/Controls/FlybyTimeline/ViewModel/FlybyTimelineViewModel.Selection.cs @@ -179,7 +179,7 @@ private void SetSelectedCameras(IEnumerable cameras, } /// - /// Pushes current timeline selection into as an . + /// Pushes current timeline selection into . /// private void SyncEditorSelection() { @@ -190,7 +190,7 @@ private void SyncEditorSelection() try { - SetEditorSelection(GetMergedEditorSelection()); + SetEditorSelection([.. _selectedCameras]); } finally { @@ -303,40 +303,17 @@ private static IReadOnlyList GetSelectedFlybyCameras(Object return []; } - /// - /// Merges timeline-selected cameras with non-flyby editor selection objects. - /// - private IReadOnlyList GetMergedEditorSelection() - { - var mergedSelection = GetEditorSelectionObjects() - .Where(objectInstance => objectInstance is not FlybyCameraInstance) - .ToList(); - - mergedSelection.AddRange(_selectedCameras); - - return [.. mergedSelection.Distinct()]; - } - - /// - /// Returns the current editor selection as position-based objects. - /// - private IReadOnlyList GetEditorSelectionObjects() - { - if (_editor.SelectedObject is ObjectGroup group) - return [.. group.Cast()]; - - if (_editor.SelectedObject is PositionBasedObjectInstance positionBased) - return [positionBased]; - - return []; - } - /// /// Applies a new selection back into the editor. /// private void SetEditorSelection(IReadOnlyList selectedObjects) { - var currentSelection = GetEditorSelectionObjects(); + IReadOnlyList currentSelection = _editor.SelectedObject switch + { + ObjectGroup group => [.. group.Cast()], + PositionBasedObjectInstance positionBased => [positionBased], + _ => [] + }; if (currentSelection.Count == selectedObjects.Count && currentSelection.All(selectedObjects.Contains)) return; diff --git a/TombEditor/Forms/FormFlybyCamera.Designer.cs b/TombEditor/Forms/FormFlybyCamera.Designer.cs index 0b21d5229..bc26256ea 100644 --- a/TombEditor/Forms/FormFlybyCamera.Designer.cs +++ b/TombEditor/Forms/FormFlybyCamera.Designer.cs @@ -561,4 +561,4 @@ private void InitializeComponent() private DarkNumericUpDown numRotationX; private DarkNumericUpDown numRotationY; } -} \ No newline at end of file +} diff --git a/TombEditor/Forms/FormFlybyCamera.cs b/TombEditor/Forms/FormFlybyCamera.cs index 8a9fdac06..3e0f188f1 100644 --- a/TombEditor/Forms/FormFlybyCamera.cs +++ b/TombEditor/Forms/FormFlybyCamera.cs @@ -41,6 +41,7 @@ public FormFlybyCamera(FlybyCameraInstance flyByCamera) _editor = Editor.Instance; InitializeComponent(); + numFOV.Maximum = (decimal)FlybyConstants.MaxFlybyFieldOfViewDegrees; LoadWindowState(); } diff --git a/TombEditor/ToolWindows/MainView.Designer.cs b/TombEditor/ToolWindows/MainView.Designer.cs index 37347947b..217d8a96e 100644 --- a/TombEditor/ToolWindows/MainView.Designer.cs +++ b/TombEditor/ToolWindows/MainView.Designer.cs @@ -86,6 +86,7 @@ private void InitializeComponent() panelStepHeightOptions = new System.Windows.Forms.Panel(); comboStepHeight = new DarkUI.Controls.DarkComboBox(); panelFlybyTimeline = new System.Windows.Forms.Panel(); + panelFlybyTimelineDivider = new System.Windows.Forms.Panel(); flybyTimelineHost = new System.Windows.Forms.Integration.ElementHost(); flybyTimelineView = new Controls.FlybyTimeline.UI.FlybyTimelineView(); panelMainView = new System.Windows.Forms.Panel(); @@ -876,6 +877,7 @@ private void InitializeComponent() // panelFlybyTimeline // panelFlybyTimeline.Controls.Add(flybyTimelineHost); + panelFlybyTimeline.Controls.Add(panelFlybyTimelineDivider); panelFlybyTimeline.Dock = System.Windows.Forms.DockStyle.Top; panelFlybyTimeline.Location = new System.Drawing.Point(5, 5); panelFlybyTimeline.Name = "panelFlybyTimeline"; @@ -883,6 +885,15 @@ private void InitializeComponent() panelFlybyTimeline.TabIndex = 17; panelFlybyTimeline.Visible = false; // + // panelFlybyTimelineDivider + // + panelFlybyTimelineDivider.BackColor = System.Drawing.Color.FromArgb(81, 81, 81); + panelFlybyTimelineDivider.Dock = System.Windows.Forms.DockStyle.Bottom; + panelFlybyTimelineDivider.Location = new System.Drawing.Point(0, 67); + panelFlybyTimelineDivider.Name = "panelFlybyTimelineDivider"; + panelFlybyTimelineDivider.Size = new System.Drawing.Size(1495, 1); + panelFlybyTimelineDivider.TabIndex = 1; + // // flybyTimelineHost // flybyTimelineHost.Dock = System.Windows.Forms.DockStyle.Fill; @@ -1010,6 +1021,7 @@ private void InitializeComponent() private DarkUI.Controls.DarkLabel lblStepHeight; private DarkUI.Controls.DarkComboBox comboStepHeight; private System.Windows.Forms.Panel panelFlybyTimeline; + private System.Windows.Forms.Panel panelFlybyTimelineDivider; private System.Windows.Forms.Integration.ElementHost flybyTimelineHost; private Controls.FlybyTimeline.UI.FlybyTimelineView flybyTimelineView; private System.Windows.Forms.ToolStripButton butMirror;