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;