diff --git a/src/KappaDuck.Quack/Graphics/Rendering/Renderer.cs b/src/KappaDuck.Quack/Graphics/Rendering/Renderer.cs
index 5747241..c71435a 100644
--- a/src/KappaDuck.Quack/Graphics/Rendering/Renderer.cs
+++ b/src/KappaDuck.Quack/Graphics/Rendering/Renderer.cs
@@ -9,7 +9,6 @@
using KappaDuck.Quack.Video;
using KappaDuck.Quack.Video.Pixels;
using KappaDuck.Quack.Windows;
-using System.Buffers;
using System.ComponentModel;
namespace KappaDuck.Quack.Graphics.Rendering;
@@ -23,7 +22,7 @@ namespace KappaDuck.Quack.Graphics.Rendering;
///
public sealed class Renderer : IRenderTarget, IDisposable
{
- private const int MaxStackVertices = 64;
+ private const int MaxStackVertices = 256;
private Window? _window;
@@ -890,6 +889,26 @@ public Surface ReadPixels(RectI? area = null)
return new Surface(surface);
}
+ ///
+ /// Set the color used for drawing operations.
+ ///
+ /// The color to use for drawing operations.
+ public void SetDrawingColor(Color color)
+ {
+ ThrowIfDisposed();
+ SDLThrowHelper.ThrowIfFailed(SDL3.SetRenderDrawColor(Handle, color.R, color.G, color.B, color.A));
+ }
+
+ ///
+ /// Set the color used for drawing operations.
+ ///
+ /// The color to use for drawing operations.
+ public void SetDrawingColor(ColorF color)
+ {
+ ThrowIfDisposed();
+ SDLThrowHelper.ThrowIfFailed(SDL3.SetRenderDrawColorFloat(Handle, color.R, color.G, color.B, color.A));
+ }
+
///
/// Redirects drawing into until the returned scope is disposed.
///
@@ -908,23 +927,31 @@ public RenderTargetScope Target(Texture texture)
}
///
- /// Set the color used for drawing operations.
+ /// Redirects drawing to the given view: sets from and returns a
+ /// scope carrying the view's transform, until the scope is disposed.
///
- /// The color to use for drawing operations.
- public void SetDrawingColor(Color color)
+ ///
+ ///
+ /// Pass to for everything drawn through this view.
+ ///
+ ///
+ /// Uses , which accounts for logical , so the view's
+ /// normalized stays correct whether or not logical presentation is in effect.
+ ///
+ ///
+ /// The view to draw through.
+ /// A scope carrying the view's render state that restores the full-target viewport when disposed.
+ /// Failed to set the viewport.
+ /// The renderer is disposed.
+ public ViewScope View(View view)
{
ThrowIfDisposed();
- SDLThrowHelper.ThrowIfFailed(SDL3.SetRenderDrawColor(Handle, color.R, color.G, color.B, color.A));
- }
- ///
- /// Set the color used for drawing operations.
- ///
- /// The color to use for drawing operations.
- public void SetDrawingColor(ColorF color)
- {
- ThrowIfDisposed();
- SDLThrowHelper.ThrowIfFailed(SDL3.SetRenderDrawColorFloat(Handle, color.R, color.G, color.B, color.A));
+ RectI pixelViewport = view.ComputeViewport(CurrentOutputSize);
+ Viewport = pixelViewport;
+
+ RenderState state = RenderState.Default with { Transform = view.GetTransform(pixelViewport.Size) };
+ return new ViewScope(this, state);
}
private void DrawGeometry(ReadOnlySpan vertices, ReadOnlySpan indices, RenderState state)
@@ -942,8 +969,7 @@ private void DrawGeometry(ReadOnlySpan vertices, ReadOnlySpan indic
int count = vertices.Length;
- Vertex[]? rented = count > MaxStackVertices ? ArrayPool.Shared.Rent(count) : null;
- Span transformed = rented ?? stackalloc Vertex[count];
+ Span transformed = count > MaxStackVertices ? new Vertex[count] : stackalloc Vertex[count];
for (int i = 0; i < count; i++)
{
@@ -954,9 +980,6 @@ private void DrawGeometry(ReadOnlySpan vertices, ReadOnlySpan indic
}
Submit(texture, transformed, indices);
-
- if (rented is not null)
- ArrayPool.Shared.Return(rented);
}
private void Submit(SDL_Texture* texture, ReadOnlySpan vertices, ReadOnlySpan indices)
diff --git a/src/KappaDuck.Quack/Graphics/Rendering/View.cs b/src/KappaDuck.Quack/Graphics/Rendering/View.cs
new file mode 100644
index 0000000..cb1abcc
--- /dev/null
+++ b/src/KappaDuck.Quack/Graphics/Rendering/View.cs
@@ -0,0 +1,261 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Geometry;
+
+namespace KappaDuck.Quack.Graphics.Rendering;
+
+///
+/// A 2D camera that defines what part of the scene is visible and where it is displayed on the render target.
+///
+///
+///
+/// A view is described by two independent things: , and
+/// control what part of the scene is visible, while controls where on the render target it is
+/// displayed, as a rectangle normalized to the target's size. The default viewport, (0, 0, 1, 1), covers the whole
+/// target.
+///
+///
+/// Combine multiple views to build split-screens, minimaps, or picture-in-picture panels: draw the scene once per
+/// view, each with its own . Obtain a from
+/// to draw through a view.
+///
+///
+public sealed class View
+{
+ ///
+ /// Creates a view centered at the origin with the given size and a viewport covering the whole target.
+ ///
+ /// The width and height of the visible scene, in world units.
+ /// has a negative or zero width or height.
+ public View(SizeF size) : this(PointF.Origin, size)
+ {
+ }
+
+ ///
+ /// Creates a view with the given center and size and a viewport covering the whole target.
+ ///
+ /// The point of the scene that appears at the center of the viewport.
+ /// The width and height of the visible scene, in world units.
+ /// has a negative or zero width or height.
+ public View(PointF center, SizeF size)
+ {
+ Center = center;
+ Size = size;
+ }
+
+ ///
+ /// Creates a view that shows the given rectangle of the scene, with a viewport covering the whole target.
+ ///
+ /// The rectangle of the scene to show, in world units.
+ /// has a negative or zero width or height.
+ public View(Rect rect) : this(rect.Center, rect.Size)
+ {
+ }
+
+ ///
+ /// Gets or sets the rectangle, in world units, that the rendered content is confined to, or
+ /// to let the view move freely.
+ ///
+ ///
+ ///
+ /// Use this to stop a camera from showing past the edges of a level: set it to the level's world rectangle, and
+ /// move however you like for example following a player. The visible content stays
+ /// confined to without itself being altered.
+ ///
+ ///
+ /// Along an axis where is larger than . For example while zoomed out,
+ /// or on a minimap. The axis is centered on instead of clamped, since no position keeps
+ /// the view entirely inside it.
+ ///
+ ///
+ /// Ignores : the confinement treats as an axis-aligned extent, so a
+ /// rotated view can still show a sliver past the edge at its corners.
+ ///
+ ///
+ public Rect? Bounds { get; set; }
+
+ ///
+ /// Gets or sets the point of the scene that appears at the center of the viewport. Defaults to (0, 0).
+ ///
+ ///
+ /// Kept exactly as set, even outside . Only the rendered content is confined. Reading it
+ /// back (for example to check how far a followed target has wandered) never reflects clamping.
+ ///
+ public PointF Center { get; set; }
+
+ ///
+ /// Gets or sets the rotation of the view, clockwise on screen. Defaults to no rotation.
+ ///
+ public Angle Rotation { get; set; }
+
+ ///
+ /// Gets or sets the width and height of the visible scene, in world units.
+ ///
+ ///
+ /// Shrinking the size zooms in; growing it zooms out. The aspect ratio is independent from the viewport's, so a
+ /// mismatch stretches the scene. Match them (or letterbox) if you need it undistorted.
+ ///
+ /// The width or height is negative or zero.
+ public SizeF Size
+ {
+ get;
+ set
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value.Width);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value.Height);
+
+ field = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the rectangle, normalized to the render target's size, where this view is displayed.
+ ///
+ ///
+ /// Expressed in the 0–1 range so it stays correct across different target sizes. (0, 0, 1, 1), the default,
+ /// covers the whole target. Use a quarter of the target for split-screen (e.g. (0, 0, 0.5, 0.5) for the
+ /// top-left player) or a small corner rectangle for a minimap (e.g. (0.75, 0.75, 0.2, 0.2)).
+ ///
+ public Rect Viewport { get; set; } = new(0f, 0f, 1f, 1f);
+
+ ///
+ /// Computes the pixel rectangle this view occupies on a render target of the given size, from .
+ ///
+ /// Assign the result to before drawing through this view.
+ ///
+ /// The size of the render target, accounting for logical presentation. Typically
+ /// , not .
+ ///
+ /// The viewport, in pixels, clamped to at least 1x1.
+ public RectI ComputeViewport(Size targetSize)
+ {
+ int x = (int)MathF.Round(Viewport.X * targetSize.Width);
+ int y = (int)MathF.Round(Viewport.Y * targetSize.Height);
+ int width = Math.Max(1, (int)MathF.Round(Viewport.Width * targetSize.Width));
+ int height = Math.Max(1, (int)MathF.Round(Viewport.Height * targetSize.Height));
+
+ return new RectI(x, y, width, height);
+ }
+
+ ///
+ /// Computes the transform that maps the scene into this view, ready to combine into a .
+ ///
+ ///
+ /// must be the pixel size this view is displayed at the size from
+ /// and not the full render target, so the scene fills the viewport correctly.
+ ///
+ /// The pixel size of this view's viewport.
+ /// A transform mapping scene coordinates to viewport-local render coordinates.
+ public Transform GetTransform(Size viewportSize)
+ {
+ Vector2 scale = new(viewportSize.Width / Size.Width, viewportSize.Height / Size.Height);
+ PointF center = new(viewportSize.Width / 2f, viewportSize.Height / 2f);
+
+ // Rotation is negated: rotating the camera clockwise makes the scene appear to turn counter-clockwise.
+ return Transform.Create(center, -Rotation, scale, GetConfinedCenter());
+ }
+
+ ///
+ /// Moves the view relative to its current .
+ ///
+ /// The displacement to add to the center.
+ public void Move(Vector2 offset) => Center += offset;
+
+ ///
+ /// Moves towards a target by at most , stopping exactly at
+ /// it once within reach.
+ ///
+ /// Call every frame with the point you want to follow (e.g. a player's position) for a smooth chase camera.
+ /// The point to move towards.
+ /// The maximum distance to move this step. Should be non-negative.
+ public void MoveTowards(PointF target, float maxDistance)
+ {
+ Vector2 displacement = target - Center;
+ float distance = displacement.Magnitude;
+
+ if (distance <= maxDistance || MathF.ApproximatelyZero(distance))
+ {
+ Center = target;
+ return;
+ }
+
+ Center += displacement / distance * maxDistance;
+ }
+
+ ///
+ /// Rotates the view relative to its current .
+ ///
+ /// The rotation to add.
+ public void Rotate(Angle angle) => Rotation += angle;
+
+ ///
+ /// Resets the view to show the given rectangle of the scene, clearing any rotation.
+ ///
+ /// The rectangle of the scene to show, in world units.
+ /// has a negative or zero width or height.
+ public void Reset(Rect rect)
+ {
+ Center = rect.Center;
+ Size = rect.Size;
+ Rotation = Angle.Zero;
+ }
+
+ ///
+ /// Zooms the view by scaling around its current value.
+ ///
+ /// Values greater than 1 zoom out (show more of the scene); values between 0 and 1 zoom in.
+ /// is negative or zero.
+ public void Zoom(float factor)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(factor);
+ Size = new SizeF(Size.Width * factor, Size.Height * factor);
+ }
+
+ private PointF GetConfinedCenter()
+ {
+ if (Bounds is not { } bounds)
+ return Center;
+
+ float halfWidth = Size.Width / 2f;
+ float halfHeight = Size.Height / 2f;
+
+ float x = Size.Width >= bounds.Width
+ ? bounds.Center.X
+ : Math.Clamp(Center.X, bounds.Left + halfWidth, bounds.Right - halfWidth);
+
+ float y = Size.Height >= bounds.Height
+ ? bounds.Center.Y
+ : Math.Clamp(Center.Y, bounds.Top + halfHeight, bounds.Bottom - halfHeight);
+
+ return new PointF(x, y);
+ }
+}
+
+///
+/// A scope obtained from that carries the render state to draw through a view,
+/// and restores the renderer's full-target viewport when disposed.
+///
+///
+/// Pass to for everything drawn through the view.
+///
+public readonly ref struct ViewScope : IDisposable
+{
+ private readonly Renderer _renderer;
+
+ internal ViewScope(Renderer renderer, RenderState state)
+ {
+ _renderer = renderer;
+ State = state;
+ }
+
+ ///
+ /// Gets the render state carrying the view's transform. Pass it to every draw call made through this view.
+ ///
+ public RenderState State { get; }
+
+ ///
+ /// Restores the renderer's viewport to cover the whole target.
+ ///
+ public void Dispose() => _renderer.Viewport = null;
+}
diff --git a/tests/Unit.Tests/Graphics/Rendering/ViewTests.cs b/tests/Unit.Tests/Graphics/Rendering/ViewTests.cs
new file mode 100644
index 0000000..a4d16fc
--- /dev/null
+++ b/tests/Unit.Tests/Graphics/Rendering/ViewTests.cs
@@ -0,0 +1,339 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Geometry;
+using KappaDuck.Quack.Graphics.Rendering;
+
+namespace Unit.Tests.Graphics.Rendering;
+
+internal sealed class ViewTests
+{
+ [Test]
+ public async Task ConstructorWithSizeShouldDefaultCenterToOrigin()
+ {
+ View view = new(new SizeF(200f, 100f));
+
+ await view.Center.X.Should().BeEqualTo(0f);
+ await view.Center.Y.Should().BeEqualTo(0f);
+ }
+
+ [Test]
+ public async Task ConstructorWithSizeShouldSetSize()
+ {
+ View view = new(new SizeF(200f, 100f));
+
+ await view.Size.Width.Should().BeEqualTo(200f);
+ await view.Size.Height.Should().BeEqualTo(100f);
+ }
+
+ [Test]
+ public async Task ConstructorWithCenterAndSizeShouldSetBoth()
+ {
+ View view = new(new PointF(50f, 75f), new SizeF(200f, 100f));
+
+ await view.Center.X.Should().BeEqualTo(50f);
+ await view.Center.Y.Should().BeEqualTo(75f);
+ await view.Size.Width.Should().BeEqualTo(200f);
+ await view.Size.Height.Should().BeEqualTo(100f);
+ }
+
+ [Test]
+ public async Task ConstructorWithRectShouldMatchTheRectsCenterAndSize()
+ {
+ Rect rect = new(100f, 200f, 400f, 300f);
+ View view = new(rect);
+
+ await view.Center.X.Should().BeEqualTo(300f);
+ await view.Center.Y.Should().BeEqualTo(350f);
+ await view.Size.Width.Should().BeEqualTo(400f);
+ await view.Size.Height.Should().BeEqualTo(300f);
+ }
+
+ [Test]
+ public async Task ConstructorShouldDefaultViewportToTheFullTarget()
+ {
+ View view = new(new SizeF(200f, 100f));
+
+ await view.Viewport.X.Should().BeEqualTo(0f);
+ await view.Viewport.Y.Should().BeEqualTo(0f);
+ await view.Viewport.Width.Should().BeEqualTo(1f);
+ await view.Viewport.Height.Should().BeEqualTo(1f);
+ }
+
+ [Test]
+ public async Task ConstructorShouldDefaultRotationToZero()
+ {
+ View view = new(new SizeF(200f, 100f));
+
+ await view.Rotation.Degrees.Should().BeEqualTo(0f);
+ }
+
+ [Test]
+ public async Task ConstructorShouldDefaultBoundsToNull()
+ {
+ View view = new(new SizeF(200f, 100f));
+
+ await view.Bounds.HasValue.Should().BeFalse();
+ }
+
+ [Test]
+ [Arguments(0f, 100f)]
+ [Arguments(-10f, 100f)]
+ [Arguments(100f, 0f)]
+ [Arguments(100f, -10f)]
+ public async Task ConstructorWithNonPositiveSizeShouldThrow(float width, float height)
+ {
+ await Assert.That(() => new View(new SizeF(width, height)))
+ .ThrowsExactly();
+ }
+
+ [Test]
+ public async Task SizeSetterShouldUpdateTheSize()
+ {
+ View view = new(new SizeF(200f, 100f))
+ {
+ Size = new SizeF(400f, 300f)
+ };
+
+ await view.Size.Width.Should().BeEqualTo(400f);
+ await view.Size.Height.Should().BeEqualTo(300f);
+ }
+
+ [Test]
+ [Arguments(0f, 100f)]
+ [Arguments(100f, 0f)]
+ public async Task SizeSetterWithNonPositiveValueShouldThrow(float width, float height)
+ {
+ View view = new(new SizeF(200f, 100f));
+
+ await Assert.That(() => view.Size = new SizeF(width, height))
+ .ThrowsExactly();
+ }
+
+ [Test]
+ public async Task MoveShouldOffsetCenterByTheGivenVector()
+ {
+ View view = new(new PointF(10f, 20f), new SizeF(200f, 100f));
+ view.Move(new Vector2(5f, -5f));
+
+ await view.Center.X.Should().BeEqualTo(15f);
+ await view.Center.Y.Should().BeEqualTo(15f);
+ }
+
+ [Test]
+ public async Task RotateShouldAddToCurrentRotation()
+ {
+ View view = new(new SizeF(200f, 100f))
+ {
+ Rotation = Angle.FromDegrees(30f)
+ };
+
+ view.Rotate(Angle.FromDegrees(15f));
+
+ await view.Rotation.Degrees.Should().BeCloseTo(45f, 1e-3f);
+ }
+
+ [Test]
+ public async Task ZoomShouldScaleSizeByFactor()
+ {
+ View view = new(new SizeF(200f, 100f));
+ view.Zoom(2f);
+
+ await view.Size.Width.Should().BeEqualTo(400f);
+ await view.Size.Height.Should().BeEqualTo(200f);
+ }
+
+ [Test]
+ [Arguments(0f)]
+ [Arguments(-1f)]
+ public async Task ZoomWithNonPositiveFactorShouldThrow(float factor)
+ {
+ View view = new(new SizeF(200f, 100f));
+
+ await Assert.That(() => view.Zoom(factor))
+ .ThrowsExactly();
+ }
+
+ [Test]
+ public async Task ResetShouldSetCenterAndSizeFromTheRectAndClearRotation()
+ {
+ View view = new(new SizeF(200f, 100f))
+ {
+ Rotation = Angle.FromDegrees(90f)
+ };
+
+ view.Reset(new Rect(0f, 0f, 800f, 600f));
+
+ await view.Center.X.Should().BeEqualTo(400f);
+ await view.Center.Y.Should().BeEqualTo(300f);
+ await view.Size.Width.Should().BeEqualTo(800f);
+ await view.Size.Height.Should().BeEqualTo(600f);
+ await view.Rotation.Degrees.Should().BeEqualTo(0f);
+ }
+
+ [Test]
+ public async Task MoveTowardsShouldMoveExactlyMaxDistanceWhenTargetIsFarther()
+ {
+ View view = new(new PointF(0f, 0f), new SizeF(200f, 100f));
+ view.MoveTowards(new PointF(100f, 0f), maxDistance: 30f);
+
+ await view.Center.X.Should().BeEqualTo(30f);
+ await view.Center.Y.Should().BeEqualTo(0f);
+ }
+
+ [Test]
+ public async Task MoveTowardsShouldSnapToTargetWhenWithinMaxDistance()
+ {
+ View view = new(new PointF(0f, 0f), new SizeF(200f, 100f));
+ view.MoveTowards(new PointF(10f, 0f), maxDistance: 30f);
+
+ await view.Center.X.Should().BeEqualTo(10f);
+ await view.Center.Y.Should().BeEqualTo(0f);
+ }
+
+ [Test]
+ public async Task MoveTowardsShouldSnapToTargetWhenExactlyAtMaxDistance()
+ {
+ View view = new(new PointF(0f, 0f), new SizeF(200f, 100f));
+ view.MoveTowards(new PointF(30f, 0f), maxDistance: 30f);
+
+ await view.Center.X.Should().BeEqualTo(30f);
+ await view.Center.Y.Should().BeEqualTo(0f);
+ }
+
+ [Test]
+ public async Task MoveTowardsShouldLeaveCenterUnchangedWhenAlreadyAtTarget()
+ {
+ View view = new(new PointF(50f, 50f), new SizeF(200f, 100f));
+ view.MoveTowards(new PointF(50f, 50f), maxDistance: 30f);
+
+ await view.Center.X.Should().BeEqualTo(50f);
+ await view.Center.Y.Should().BeEqualTo(50f);
+ }
+
+ [Test]
+ public async Task ComputeViewportShouldReturnTheFullTargetByDefault()
+ {
+ View view = new(new SizeF(200f, 100f));
+ RectI pixels = view.ComputeViewport(new Size(1920, 1080));
+
+ await pixels.X.Should().BeEqualTo(0);
+ await pixels.Y.Should().BeEqualTo(0);
+ await pixels.Width.Should().BeEqualTo(1920);
+ await pixels.Height.Should().BeEqualTo(1080);
+ }
+
+ [Test]
+ public async Task ComputeViewportShouldScaleByTheNormalizedRect()
+ {
+ View view = new(new SizeF(200f, 100f))
+ {
+ Viewport = new Rect(0.5f, 0f, 0.5f, 1f)
+ };
+
+ RectI pixels = view.ComputeViewport(new Size(1920, 1080));
+
+ await pixels.X.Should().BeEqualTo(960);
+ await pixels.Y.Should().BeEqualTo(0);
+ await pixels.Width.Should().BeEqualTo(960);
+ await pixels.Height.Should().BeEqualTo(1080);
+ }
+
+ [Test]
+ public async Task ComputeViewportShouldClampToAtLeastOnePixelWhenTheNormalizedRectRoundsToZero()
+ {
+ View view = new(new SizeF(200f, 100f))
+ {
+ Viewport = new Rect(0f, 0f, 0.0001f, 0.0001f)
+ };
+
+ RectI pixels = view.ComputeViewport(new Size(100, 100));
+
+ await pixels.Width.Should().BeEqualTo(1);
+ await pixels.Height.Should().BeEqualTo(1);
+ }
+
+ [Test]
+ public async Task GetTransformShouldMapCenterToTheMiddleOfTheViewport()
+ {
+ View view = new(new PointF(500f, 300f), new SizeF(200f, 100f));
+ Transform transform = view.GetTransform(new Size(200, 100));
+
+ PointF result = transform.TransformPoint(new PointF(500f, 300f));
+
+ await result.X.Should().BeEqualTo(100f);
+ await result.Y.Should().BeEqualTo(50f);
+ }
+
+ [Test]
+ public async Task GetTransformShouldMapTheSceneEdgeToTheViewportEdgeWhenSizeMatchesTheViewport()
+ {
+ View view = new(new PointF(0f, 0f), new SizeF(200f, 100f));
+ Transform transform = view.GetTransform(new Size(200, 100));
+
+ PointF result = transform.TransformPoint(new PointF(100f, 50f));
+
+ await result.X.Should().BeEqualTo(200f);
+ await result.Y.Should().BeEqualTo(100f);
+ }
+
+ [Test]
+ public async Task GetTransformShouldScaleWhenSizeDiffersFromTheViewport()
+ {
+ View view = new(new PointF(0f, 0f), new SizeF(400f, 400f));
+ Transform transform = view.GetTransform(new Size(200, 200));
+
+ PointF result = transform.TransformPoint(new PointF(200f, 0f));
+
+ await result.X.Should().BeEqualTo(200f);
+ await result.Y.Should().BeEqualTo(100f);
+ }
+
+ [Test]
+ public async Task GetTransformShouldRotateTheSceneOppositeToTheViewsRotation()
+ {
+ View view = new(new PointF(0f, 0f), new SizeF(200f, 200f))
+ {
+ Rotation = Angle.FromDegrees(90f)
+ };
+
+ Transform transform = view.GetTransform(new Size(200, 200));
+ PointF result = transform.TransformPoint(new PointF(100f, 0f));
+
+ await result.X.Should().BeCloseTo(100f, 1e-3f);
+ await result.Y.Should().BeCloseTo(0f, 1e-3f);
+ }
+
+ [Test]
+ public async Task GetTransformShouldClampTheEffectiveCenterToBoundsWithoutMutatingCenter()
+ {
+ View view = new(new PointF(5000f, 5000f), new SizeF(200f, 100f))
+ {
+ Bounds = new Rect(0f, 0f, 1000f, 600f)
+ };
+
+ Transform transform = view.GetTransform(new Size(200, 100));
+ PointF viewportCenter = transform.TransformPoint(new PointF(900f, 550f));
+
+ await viewportCenter.X.Should().BeEqualTo(100f);
+ await viewportCenter.Y.Should().BeEqualTo(50f);
+
+ await view.Center.X.Should().BeEqualTo(5000f);
+ await view.Center.Y.Should().BeEqualTo(5000f);
+ }
+
+ [Test]
+ public async Task GetTransformShouldCenterOnBoundsWhenTheViewIsLargerThanBoundsOnBothAxes()
+ {
+ View view = new(new PointF(9999f, 9999f), new SizeF(500f, 500f))
+ {
+ Bounds = new Rect(0f, 0f, 100f, 100f)
+ };
+
+ Transform transform = view.GetTransform(new Size(500, 500));
+ PointF viewportCenter = transform.TransformPoint(new PointF(50f, 50f));
+
+ await viewportCenter.X.Should().BeEqualTo(250f);
+ await viewportCenter.Y.Should().BeEqualTo(250f);
+ }
+}