Viewport is a pure pan/zoom model for a 2D canvas. It stores Pan and Zoom, converts between world and screen coordinates, applies pan deltas, and performs zoom-to-cursor while clamping zoom between MinZoom and MaxZoom.
using System;
using Sandbox;
namespace Goo;
/// <summary>
/// Pure pan/zoom transform state for building canvases. No Panel dependency, fully unit-tested.
/// Model: <c>screen = world * Zoom + Pan</c>, origin top-left. A consumer drives <see cref="PanBy"/>
/// from drag deltas and <see cref="ZoomAt"/> from the mouse wheel, then feeds <see cref="Zoom"/>/<see cref="Pan"/>
/// into a <c>Transform = Scale(Zoom).Translate(Pan)</c> on the world container (with its transform-origin
/// pinned to top-left so the scale pivots at the local origin, matching this math).
/// </summary>
public sealed class Viewport
{
/// <summary>Translation in screen pixels.</summary>
public Vector2 Pan { get; private set; }
/// <summary>Uniform scale factor; 1 = no zoom. Always within [<see cref="MinZoom"/>, <see cref="MaxZoom"/>].</summary>
public float Zoom { get; private set; } = 1f;
/// <summary>Lower zoom clamp.</summary>
public float MinZoom { get; init; } = 0.1f;
/// <summary>Upper zoom clamp.</summary>
public float MaxZoom { get; init; } = 10f;
/// <summary>Maps a world-space point to its screen-space position.</summary>
public Vector2 WorldToScreen(Vector2 world) => world * Zoom + Pan;
/// <summary>Maps a screen-space point back to world space.</summary>
public Vector2 ScreenToWorld(Vector2 screen) => (screen - Pan) / Zoom;
/// <summary>Pans by a screen-space delta (e.g. a drag movement).</summary>
public void PanBy(Vector2 screenDelta) => Pan += screenDelta;
/// <summary>
/// Multiplies zoom by <paramref name="factor"/> (clamped) while keeping the world point currently
/// under <paramref name="screenAnchor"/> fixed under it - i.e. zoom-to-cursor.
/// </summary>
public void ZoomAt(Vector2 screenAnchor, float factor)
{
var newZoom = Math.Clamp(Zoom * factor, MinZoom, MaxZoom);
var worldAtAnchor = ScreenToWorld(screenAnchor);
Zoom = newZoom;
Pan = screenAnchor - worldAtAnchor * newZoom;
}
}