diff --git a/UVtools.WPF/App.axaml b/UVtools.WPF/App.axaml
new file mode 100644
index 0000000..922d3b3
--- /dev/null
+++ b/UVtools.WPF/App.axaml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/UVtools.WPF/App.axaml.cs b/UVtools.WPF/App.axaml.cs
new file mode 100644
index 0000000..62a22e2
--- /dev/null
+++ b/UVtools.WPF/App.axaml.cs
@@ -0,0 +1,24 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace UVtools.WPF
+{
+ public class App : Application
+ {
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+ }
+}
diff --git a/UVtools.WPF/Controls/AdvancedImageBox.cs b/UVtools.WPF/Controls/AdvancedImageBox.cs
new file mode 100644
index 0000000..047feac
--- /dev/null
+++ b/UVtools.WPF/Controls/AdvancedImageBox.cs
@@ -0,0 +1,1167 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Skia;
+using Avalonia.Styling;
+using UVtools.Core.Extensions;
+
+
+
+namespace UVtools.WPF.Controls
+{
+ public class AdvancedImageBox : ScrollViewer, IStyleable
+ {
+ #region Sub Classes
+
+ ///
+ /// Represents available levels of zoom in an control
+ ///
+ public class ZoomLevelCollection : IList
+ {
+ #region Public Constructors
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ZoomLevelCollection()
+ {
+ List = new SortedList();
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The default values to populate the collection with.
+ /// Thrown if the collection parameter is null
+ public ZoomLevelCollection(IEnumerable collection)
+ : this()
+ {
+ if (collection == null)
+ {
+ throw new ArgumentNullException(nameof(collection));
+ }
+
+ AddRange(collection);
+ }
+
+ #endregion
+
+ #region Public Class Properties
+
+ ///
+ /// Returns the default zoom levels
+ ///
+ public static ZoomLevelCollection Default
+ {
+ get
+ {
+ return new ZoomLevelCollection(new[]
+ {
+ 7, 10, 15, 20, 25, 30, 50, 70, 100, 150, 200, 300, 400, 500, 600, 700, 800, 1200, 1600
+ });
+ }
+ }
+
+ #endregion
+
+ #region Public Properties
+
+ ///
+ /// Gets the number of elements contained in the .
+ ///
+ ///
+ /// The number of elements contained in the .
+ ///
+ public int Count => List.Count;
+
+ ///
+ /// Gets a value indicating whether the is read-only.
+ ///
+ /// true if this instance is read only; otherwise, false.
+ /// true if the is read-only; otherwise, false.
+ ///
+ public bool IsReadOnly => false;
+
+ ///
+ /// Gets or sets the zoom level at the specified index.
+ ///
+ /// The index.
+ public int this[int index]
+ {
+ get => List.Values[index];
+ set
+ {
+ List.RemoveAt(index);
+ Add(value);
+ }
+ }
+
+ #endregion
+
+ #region Protected Properties
+
+ ///
+ /// Gets or sets the backing list.
+ ///
+ protected SortedList List { get; set; }
+
+ #endregion
+
+ #region Public Members
+
+ ///
+ /// Adds an item to the .
+ ///
+ /// The object to add to the .
+ public void Add(int item)
+ {
+ List.Add(item, item);
+ }
+
+ ///
+ /// Adds a range of items to the .
+ ///
+ /// The items to add to the collection.
+ /// Thrown if the collection parameter is null.
+ public void AddRange(IEnumerable collection)
+ {
+ if (collection == null)
+ {
+ throw new ArgumentNullException(nameof(collection));
+ }
+
+ foreach (int value in collection)
+ {
+ Add(value);
+ }
+ }
+
+ ///
+ /// Removes all items from the .
+ ///
+ public void Clear()
+ {
+ List.Clear();
+ }
+
+ ///
+ /// Determines whether the contains a specific value.
+ ///
+ /// The object to locate in the .
+ /// true if is found in the ; otherwise, false.
+ public bool Contains(int item)
+ {
+ return List.ContainsKey(item);
+ }
+
+ ///
+ /// Copies a range of elements this collection into a destination .
+ ///
+ /// The that receives the data.
+ /// A 64-bit integer that represents the index in the at which storing begins.
+ public void CopyTo(int[] array, int arrayIndex)
+ {
+ for (int i = 0; i < this.Count; i++)
+ {
+ array[arrayIndex + i] = this.List.Values[i];
+ }
+ }
+
+ ///
+ /// Finds the index of a zoom level matching or nearest to the specified value.
+ ///
+ /// The zoom level.
+ public int FindNearest(int zoomLevel)
+ {
+ int nearestValue = this.List.Values[0];
+ int nearestDifference = Math.Abs(nearestValue - zoomLevel);
+ for (int i = 1; i < Count; i++)
+ {
+ int value = List.Values[i];
+ int difference = Math.Abs(value - zoomLevel);
+ if (difference < nearestDifference)
+ {
+ nearestValue = value;
+ nearestDifference = difference;
+ }
+ }
+ return nearestValue;
+ }
+
+ ///
+ /// Returns an enumerator that iterates through the collection.
+ ///
+ /// A that can be used to iterate through the collection.
+ public IEnumerator GetEnumerator()
+ {
+ return List.Values.GetEnumerator();
+ }
+
+ ///
+ /// Determines the index of a specific item in the .
+ ///
+ /// The object to locate in the .
+ /// The index of if found in the list; otherwise, -1.
+ public int IndexOf(int item)
+ {
+ return List.IndexOfKey(item);
+ }
+
+ ///
+ /// Not implemented.
+ ///
+ /// The index.
+ /// The item.
+ /// Not implemented
+ public void Insert(int index, int item)
+ {
+ throw new NotImplementedException();
+ }
+
+ ///
+ /// Returns the next increased zoom level for the given current zoom.
+ ///
+ /// The current zoom level.
+ /// The next matching increased zoom level for the given current zoom if applicable, otherwise the nearest zoom.
+ public int NextZoom(int zoomLevel)
+ {
+ var index = IndexOf(this.FindNearest(zoomLevel));
+ if (index < this.Count - 1)
+ {
+ index++;
+ }
+
+ return this[index];
+ }
+
+ ///
+ /// Returns the next decreased zoom level for the given current zoom.
+ ///
+ /// The current zoom level.
+ /// The next matching decreased zoom level for the given current zoom if applicable, otherwise the nearest zoom.
+ public int PreviousZoom(int zoomLevel)
+ {
+ var index = IndexOf(FindNearest(zoomLevel));
+ if (index > 0)
+ {
+ index--;
+ }
+
+ return this[index];
+ }
+
+ ///
+ /// Removes the first occurrence of a specific object from the .
+ ///
+ /// The object to remove from the .
+ /// true if was successfully removed from the ; otherwise, false. This method also returns false if is not found in the original .
+ public bool Remove(int item)
+ {
+ return List.Remove(item);
+ }
+
+ ///
+ /// Removes the element at the specified index of the .
+ ///
+ /// The zero-based index of the element to remove.
+ public void RemoveAt(int index)
+ {
+ List.RemoveAt(index);
+ }
+
+ ///
+ /// Copies the elements of the to a new array.
+ ///
+ /// An array containing copies of the elements of the .
+ public int[] ToArray()
+ {
+ int[] results;
+
+ results = new int[Count];
+ CopyTo(results, 0);
+
+ return results;
+ }
+
+ #endregion
+
+ #region IList Members
+
+ ///
+ /// Returns an enumerator that iterates through a collection.
+ ///
+ /// An object that can be used to iterate through the collection.
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+
+ #endregion
+ }
+
+ #endregion
+
+ #region Enums
+
+ ///
+ /// Determines the sizing mode of an image hosted in an control.
+ ///
+ public enum SizeModes : byte
+ {
+ ///
+ /// The image is disiplayed according to current zoom and scroll properties.
+ ///
+ Normal,
+
+ ///
+ /// The image is stretched to fill the client area of the control.
+ ///
+ Stretch,
+
+ ///
+ /// The image is stretched to fill as much of the client area of the control as possible, whilst retaining the same aspect ratio for the width and height.
+ ///
+ //Fit
+ }
+
+ [Flags]
+ public enum PanMouseButtons : byte
+ {
+ None = 0,
+ LeftButton = 1,
+ MiddleButton = 2,
+ RightButton = 4
+ }
+
+ ///
+ /// Describes the zoom action occuring
+ ///
+ [Flags]
+ public enum ImageZoomActions : byte
+ {
+ ///
+ /// No action.
+ ///
+ None = 0,
+
+ ///
+ /// The control is increasing the zoom.
+ ///
+ ZoomIn = 1,
+
+ ///
+ /// The control is decreasing the zoom.
+ ///
+ ZoomOut = 2,
+
+ ///
+ /// The control zoom was reset.
+ ///
+ ActualSize = 4
+ }
+
+ #endregion
+
+ #region Constants
+ public static readonly int MinZoom = 10;
+ public static readonly int MaxZoom = 3500;
+ #endregion
+
+ public static StyledProperty GridCellSizeProperty =
+ AvaloniaProperty.Register(nameof(GridCellSize), 8, false, BindingMode.TwoWay);
+
+ ///
+ /// Gets or sets the basic cell size
+ ///
+ public byte GridCellSize
+ {
+ get => GetValue(GridCellSizeProperty);
+ set => SetValue(GridCellSizeProperty, value);
+ }
+
+ ///
+ /// Gets or sets the color used to create the checkerboard style background
+ ///
+ public ISolidColorBrush GridColor { get; set; } = Brushes.Gainsboro;
+
+ ///
+ /// Gets or sets the color used to create the checkerboard style background
+ ///
+ public ISolidColorBrush GridColorAlternate { get; set; } = Brushes.White;
+
+ ///
+ /// Gets or sets the image to be displayed
+ ///
+ public Bitmap Image
+ {
+ get => _image;
+ set
+ {
+ _image = value;
+ SizedContainer.Width = _image.Size.Width;
+ SizedContainer.Height = _image.Size.Height;
+ InvalidateVisual();
+ }
+ }
+
+ public bool IsHorizontalBarVisible
+ {
+ get
+ {
+ if (Image is null) return false;
+ return ScaledImageWidth > Viewport.Width;
+ }
+ }
+
+ public bool IsVerticalBarVisible
+ {
+ get
+ {
+ if (Image is null) return false;
+ return ScaledImageHeight > Viewport.Height;
+ }
+ }
+
+ ///
+ /// Gets or sets if the checkerboard background should be displayed
+ ///
+ public bool ShowGrid { get; set; } = true;
+
+ public bool IsPanning
+ {
+ get => _isPanning;
+ protected set
+ {
+ if (_isPanning == value) return;
+ _isPanning = value;
+ _startScrollPosition = Offset;
+
+ if (value)
+ {
+ Cursor = new Cursor(StandardCursorType.SizeAll);
+ //this.OnPanStart(EventArgs.Empty);
+ }
+ else
+ {
+ Cursor = Cursor.Default;
+ //this.OnPanEnd(EventArgs.Empty);
+ }
+ }
+ }
+
+ public Point CenterPoint
+ {
+ get
+ {
+ Rect viewport = GetImageViewPort();
+ return new Point(viewport.Width / 2, viewport.Height / 2);
+ }
+ }
+
+ public bool AutoPan { get; set; } = true;
+
+ public PanMouseButtons PanWithMouseButtons { get; set; } = PanMouseButtons.LeftButton | PanMouseButtons.MiddleButton | PanMouseButtons.RightButton;
+
+ public bool PanWithArrows { get; set; } = true;
+
+ public bool InvertMouse { get; set; } = false;
+
+ public bool AutoCenter { get; set; } = true;
+
+ public SizeModes SizeMode { get; set; } = SizeModes.Normal;
+
+ private bool _allowZoom = true;
+ public virtual bool AllowZoom
+ {
+ get => _allowZoom;
+ set
+ {
+ if (AllowZoom != value)
+ {
+ _allowZoom = value;
+
+ //this.OnAllowZoomChanged(EventArgs.Empty);
+ }
+ }
+ }
+
+ ZoomLevelCollection _zoomLevels = ZoomLevelCollection.Default;
+ ///
+ /// Gets or sets the zoom levels.
+ ///
+ /// The zoom levels.
+ public virtual ZoomLevelCollection ZoomLevels
+ {
+ get => _zoomLevels;
+ set
+ {
+ if (ZoomLevels != value)
+ {
+ _zoomLevels = value;
+
+ //this.OnZoomLevelsChanged(EventArgs.Empty);
+ }
+ }
+ }
+
+ private int _zoom = 100;
+ ///
+ /// Gets or sets the zoom.
+ ///
+ /// The zoom.
+ public virtual int Zoom
+ {
+ get => _zoom;
+ set => SetZoom(value, value > Zoom ? ImageZoomActions.ZoomIn : ImageZoomActions.ZoomOut);
+ }
+
+ public virtual bool IsActualSize => Zoom == 100;
+
+
+ //Our render target we compile everything to and present to the user
+ private RenderTargetBitmap RenderTarget;
+ private ISkiaDrawingContextImpl SkiaContext;
+ private Point _startMousePosition;
+ private Vector _startScrollPosition;
+ private bool _isPanning;
+ private Bitmap _image;
+
+ public ContentControl FillContainer { get; } = new ContentControl
+ {
+ Background = Brushes.Transparent
+ };
+
+ public ContentControl SizedContainer { get; } = new ContentControl
+ {
+ Background = Brushes.Transparent
+ };
+
+ Type IStyleable.StyleKey => typeof(ScrollViewer);
+
+ public AdvancedImageBox()
+ {
+ Content = FillContainer;
+ FillContainer.Content = SizedContainer;
+ FillContainer.PointerWheelChanged += FillContainerOnPointerWheelChanged;
+
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Auto;
+ VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
+
+ //Container.PointerMoved += ScrollViewerOnPointerMoved;
+ //Container.PointerPressed += ScrollViewerOnPointerPressed;
+ //Container.PointerReleased += ScrollViewerOnPointerReleased;
+
+ PropertyChanged += OnPropertyChanged;
+ }
+
+ private void FillContainerOnPointerWheelChanged(object? sender, PointerWheelEventArgs e)
+ {
+ e.Handled = true;
+ if (AllowZoom && SizeMode == SizeModes.Normal)
+ {
+ // The MouseWheel event can contain multiple "spins" of the wheel so we need to adjust accordingly
+ double spins = Math.Abs(e.Delta.Y);
+ //Debug.WriteLine(e.GetPosition(this));
+ // TODO: Really should update the source method to handle multiple increments rather than calling it multiple times
+ for (int i = 0; i < spins; i++)
+ {
+ ProcessMouseZoom(e.Delta.Y > 0, e.GetPosition(this));
+ }
+
+ //InvalidateVisual();
+ }
+ }
+
+ private void ProcessMouseZoom(bool isZoomIn, Point cursorPosition)
+ => PerformZoom(isZoomIn ? ImageZoomActions.ZoomIn : ImageZoomActions.ZoomOut, true, cursorPosition);
+
+ ///
+ /// Returns an appropriate zoom level based on the specified action, relative to the current zoom level.
+ ///
+ /// The action to determine the zoom level.
+ /// Thrown if an unsupported action is specified.
+ private int GetZoomLevel(ImageZoomActions action)
+ {
+ int result;
+
+ switch (action)
+ {
+ case ImageZoomActions.None:
+ result = Zoom;
+ break;
+ case ImageZoomActions.ZoomIn:
+ result = ZoomLevels.NextZoom(Zoom);
+ break;
+ case ImageZoomActions.ZoomOut:
+ result = ZoomLevels.PreviousZoom(Zoom);
+ break;
+ case ImageZoomActions.ActualSize:
+ result = 100;
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(action));
+ }
+
+ return result;
+ }
+
+ ///
+ /// Resets the property whilsts retaining the original .
+ ///
+ protected void RestoreSizeMode()
+ {
+ if (SizeMode != SizeModes.Normal)
+ {
+ var previousZoom = Zoom;
+ SizeMode = SizeModes.Normal;
+ Zoom = previousZoom; // Stop the zoom getting reset to 100% before calculating the new zoom
+ }
+ }
+
+ private void PerformZoom(ImageZoomActions action, bool preservePosition)
+ => PerformZoom(action, preservePosition, CenterPoint);
+
+ private void PerformZoom(ImageZoomActions action, bool preservePosition, Point relativePoint)
+ {
+ Point currentPixel = PointToImage(relativePoint);
+ int currentZoom = Zoom;
+ int newZoom = GetZoomLevel(action);
+
+ RestoreSizeMode();
+ SetZoom(newZoom, action);
+
+ if (preservePosition && Zoom != currentZoom)
+ {
+ ScrollTo(currentPixel, relativePoint);
+ }
+ }
+
+ ///
+ /// Determines whether the specified point is located within the image view port
+ ///
+ /// The point.
+ ///
+ /// true if the specified point is located within the image view port; otherwise, false.
+ ///
+ public virtual bool IsPointInImage(Point point)
+ => GetImageViewPort().Contains(point);
+
+ ///
+ /// Determines whether the specified point is located within the image view port
+ ///
+ /// The X co-ordinate of the point to check.
+ /// The Y co-ordinate of the point to check.
+ ///
+ /// true if the specified point is located within the image view port; otherwise, false.
+ ///
+ public bool IsPointInImage(int x, int y)
+ => IsPointInImage(new Point(x, y));
+
+ ///
+ /// Determines whether the specified point is located within the image view port
+ ///
+ /// The X co-ordinate of the point to check.
+ /// The Y co-ordinate of the point to check.
+ ///
+ /// true if the specified point is located within the image view port; otherwise, false.
+ ///
+ public bool IsPointInImage(double x, double y)
+ => IsPointInImage(new Point(x, y));
+
+ ///
+ /// Converts the given client size point to represent a coordinate on the source image.
+ ///
+ /// The source point.
+ /// Point.Empty if the point could not be matched to the source image, otherwise the new translated point
+ public Point PointToImage(Point point)
+ => PointToImage(point, false);
+
+ ///
+ /// Converts the given client size point to represent a coordinate on the source image.
+ ///
+ /// The X co-ordinate of the point to convert.
+ /// The Y co-ordinate of the point to convert.
+ /// Point.Empty if the point could not be matched to the source image, otherwise the new translated point
+ public Point PointToImage(double x, double y)
+ => PointToImage(x, y, false);
+
+ ///
+ /// Converts the given client size point to represent a coordinate on the source image.
+ ///
+ /// The X co-ordinate of the point to convert.
+ /// The Y co-ordinate of the point to convert.
+ ///
+ /// if set to true and the point is outside the bounds of the source image, it will be mapped to the nearest edge.
+ ///
+ /// Point.Empty if the point could not be matched to the source image, otherwise the new translated point
+ public Point PointToImage(double x, double y, bool fitToBounds)
+ => PointToImage(new Point(x, y), fitToBounds);
+
+ ///
+ /// Converts the given client size point to represent a coordinate on the source image.
+ ///
+ /// The X co-ordinate of the point to convert.
+ /// The Y co-ordinate of the point to convert.
+ /// Point.Empty if the point could not be matched to the source image, otherwise the new translated point
+ public Point PointToImage(int x, int y)
+ {
+ return PointToImage(x, y, false);
+ }
+
+ ///
+ /// Converts the given client size point to represent a coordinate on the source image.
+ ///
+ /// The source point.
+ ///
+ /// if set to true and the point is outside the bounds of the source image, it will be mapped to the nearest edge.
+ ///
+ /// Point.Empty if the point could not be matched to the source image, otherwise the new translated point
+ public virtual Point PointToImage(Point point, bool fitToBounds)
+ {
+ double x;
+ double y;
+
+ var viewport = GetImageViewPort();
+
+ if (!fitToBounds || viewport.Contains(point))
+ {
+ x = (point.X + Offset.X - viewport.X) / ZoomFactor;
+ y = (point.Y + Offset.Y - viewport.Y) / ZoomFactor;
+
+ if (fitToBounds)
+ {
+ x = x.Clamp(0, Image.Size.Width);
+ y = y.Clamp(0, Image.Size.Height);
+ }
+ }
+ else
+ {
+ x = 0; // Return Point.Empty if we couldn't match
+ y = 0;
+ }
+
+ return new Point(x, y);
+ }
+
+ ///
+ /// Scrolls the control to the given point in the image, offset at the specified display point
+ ///
+ /// The X co-ordinate of the point to scroll to.
+ /// The Y co-ordinate of the point to scroll to.
+ /// The X co-ordinate relative to the x parameter.
+ /// The Y co-ordinate relative to the y parameter.
+ public void ScrollTo(double x, double y, double relativeX, double relativeY)
+ => ScrollTo(new Point(x, y), new Point(relativeX, relativeY));
+
+ ///
+ /// Scrolls the control to the given point in the image, offset at the specified display point
+ ///
+ /// The X co-ordinate of the point to scroll to.
+ /// The Y co-ordinate of the point to scroll to.
+ /// The X co-ordinate relative to the x parameter.
+ /// The Y co-ordinate relative to the y parameter.
+ public void ScrollTo(int x, int y, int relativeX, int relativeY)
+ => ScrollTo(new Point(x, y), new Point(relativeX, relativeY));
+
+ ///
+ /// Scrolls the control to the given point in the image, offset at the specified display point
+ ///
+ /// The point of the image to attempt to scroll to.
+ /// The relative display point to offset scrolling by.
+ public virtual void ScrollTo(Point imageLocation, Point relativeDisplayPoint)
+ {
+ var x = HorizontalScrollBarMaximum - imageLocation.X * ZoomFactor + relativeDisplayPoint.X;
+ var y = VerticalScrollBarMaximum - imageLocation.Y * ZoomFactor + relativeDisplayPoint.Y;
+
+ Offset = new Vector(x, y);
+ Debug.WriteLine($"{Offset} | {HorizontalScrollBarMaximum},{VerticalScrollBarMaximum} | {HorizontalScrollBarValue},{VerticalScrollBarValue}");
+ }
+
+ ///
+ /// Updates the current zoom.
+ ///
+ /// The new zoom value.
+ /// The zoom actions that caused the value to be updated.
+ /// The source of the zoom operation.
+ private void SetZoom(int value, ImageZoomActions actions)
+ {
+ var previousZoom = Zoom;
+ value = value.Clamp(MinZoom, MaxZoom);
+
+ if (_zoom != value)
+ {
+ _zoom = value;
+ if (!UpdateViewPort())
+ {
+ InvalidateArrange();
+ }
+
+
+ //this.OnZoomChanged(EventArgs.Empty);
+ //this.OnZoomed(new ImageBoxZoomEventArgs(actions, source, previousZoom, this.Zoom));
+ }
+ }
+
+ ///
+ /// Zooms into the image
+ ///
+ public virtual void ZoomIn()
+ => ZoomIn(true);
+
+ ///
+ /// Zooms into the image
+ ///
+ /// true if the current scrolling position should be preserved relative to the new zoom level, false to reset.
+ public virtual void ZoomIn(bool preservePosition)
+ {
+ //this.PerformZoomIn(ImageBoxActionSources.Unknown, preservePosition);
+ }
+
+ ///
+ /// Zooms out of the image
+ ///
+ public virtual void ZoomOut()
+ => ZoomOut(true);
+
+ ///
+ /// Zooms out of the image
+ ///
+ /// true if the current scrolling position should be preserved relative to the new zoom level, false to reset.
+ public virtual void ZoomOut(bool preservePosition)
+ {
+ // this.PerformZoomOut(ImageBoxActionSources.Unknown, preservePosition);
+ }
+
+ ///
+ /// Zooms to the maximum size for displaying the entire image within the bounds of the control.
+ ///
+ public virtual void ZoomToFit()
+ {
+ if (Image is null) return;
+
+ double zoom;
+ double aspectRatio;
+
+ if (Image.Size.Width > Image.Size.Height)
+ {
+ aspectRatio = Viewport.Width / Image.Size.Width;
+ zoom = aspectRatio * 100.0;
+
+ if (Viewport.Height < Image.Size.Height * zoom / 100.0)
+ {
+ aspectRatio = Viewport.Height / Image.Size.Height;
+ zoom = aspectRatio * 100.0;
+ }
+ }
+ else
+ {
+ aspectRatio = Viewport.Height / Image.Size.Height;
+ zoom = aspectRatio * 100.0;
+
+ if (Viewport.Width < Image.Size.Width * zoom / 100.0)
+ {
+ aspectRatio = Viewport.Width / Image.Size.Width;
+ zoom = aspectRatio * 100.0;
+ }
+ }
+
+ Zoom = (int)Math.Round(Math.Floor(zoom));
+ }
+
+ ///
+ /// Centers the given point in the image in the center of the control
+ ///
+ /// The point of the image to attempt to center.
+ public virtual void CenterAt(Point imageLocation)
+ => ScrollTo(imageLocation, new Point(Viewport.Width / 2, Viewport.Height / 2));
+
+ ///
+ /// Centers the given point in the image in the center of the control
+ ///
+ /// The X co-ordinate of the point to center.
+ /// The Y co-ordinate of the point to center.
+ public void CenterAt(int x, int y)
+ => CenterAt(new Point(x, y));
+
+ ///
+ /// Centers the given point in the image in the center of the control
+ ///
+ /// The X co-ordinate of the point to center.
+ /// The Y co-ordinate of the point to center.
+ public void CenterAt(double x, double y)
+ => CenterAt(new Point(x, y));
+
+ ///
+ /// Resets the viewport to show the center of the image.
+ ///
+ public virtual void CenterToImage()
+ {
+ Offset = new Point(HorizontalScrollBarMaximum / 2, VerticalScrollBarMaximum / 2);
+ }
+
+ private bool UpdateViewPort()
+ {
+ if (Image is null) return false;
+
+ var scaledImageWidth = ScaledImageWidth;
+ var scaledImageHeight = ScaledImageHeight;
+ var width = scaledImageWidth <= Viewport.Width ? Viewport.Width : scaledImageWidth;
+ var height = scaledImageHeight <= Viewport.Height ? Viewport.Height : scaledImageHeight;
+
+ bool changed = false;
+ if (SizedContainer.Width != width)
+ {
+ SizedContainer.Width = width;
+ changed = true;
+ }
+
+ if (SizedContainer.Height != height)
+ {
+ SizedContainer.Height = height;
+ changed = true;
+ }
+
+ if (changed)
+ {
+ Debug.WriteLine($"Update ViewPort: {DateTime.Now.Ticks}");
+ InvalidateArrange();
+ }
+
+ return changed;
+ }
+
+ ///
+ /// Resets the zoom to 100%.
+ ///
+ /// The source that initiated the action.
+ private void PerformActualSize()
+ {
+ SizeMode = SizeModes.Normal;
+ SetZoom(100, ImageZoomActions.ActualSize | (Zoom < 100 ? ImageZoomActions.ZoomIn : ImageZoomActions.ZoomOut));
+ }
+
+ private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ //Debug.WriteLine(e.Property.Name);
+ if (e.Property.Name == nameof(VerticalScrollBarValue) ||
+ e.Property.Name == nameof(HorizontalScrollBarValue))
+ {
+ InvalidateArrange();
+ return;
+ }
+
+ if(e.Property.Name.Equals(nameof(Viewport)))
+ {
+ //if (SupressViewPortPropertyChange) return;
+ UpdateViewPort();
+ return;
+ }
+ }
+
+ #region Overrides
+
+ /*public override void EndInit()
+ {
+ base.EndInit();
+ SKPaint SKBrush = new SKPaint { IsAntialias = true, Color = new SKColor(0, 0, 0) };
+ SKBrush.Shader = SKShader.CreateColor(SKBrush.Color);
+ //RenderTarget = new RenderTargetBitmap(new PixelSize((int)Viewport.Width, (int)Viewport.Height), new Vector(96, 96));
+
+ //var context = RenderTarget.CreateDrawingContext(null);
+ //SkiaContext = (context as ISkiaDrawingContextImpl);
+ //SkiaContext.SkCanvas.Clear(new SKColor(100, 100, 255));
+ }*/
+
+ #endregion
+
+ #region Methods
+
+
+
+ public void LoadImage(string path)
+ {
+ Image = new Bitmap(path);
+ //ImageControl.Source = Image;
+ //ImageControl.InvalidateVisual();
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ Debug.WriteLine($"Render: {DateTime.Now.Ticks}");
+ // base.Render(context);
+ if (ShowGrid)
+ {
+ // draw the background
+ var currentColor = GridColor;
+ for (int y = 0; y < Viewport.Height; y += GridCellSize)
+ {
+ var firstRowColor = currentColor;
+ for (int x = 0; x < Viewport.Width; x += GridCellSize)
+ {
+ context.FillRectangle(currentColor, new Rect(x, y, GridCellSize, GridCellSize));
+ currentColor = ReferenceEquals(currentColor, GridColor) ? GridColorAlternate : GridColor;
+ }
+
+ if(firstRowColor == currentColor) currentColor = ReferenceEquals(currentColor, GridColor) ? GridColorAlternate : GridColor;
+ }
+
+ }
+ else
+ {
+ context.FillRectangle(Background, new Rect(0, 0, Viewport.Width, Viewport.Height));
+ //SkiaContext.SkCanvas.Clear(new SKColor(100, 100, 255));
+ }
+
+ if (Image is null) return;
+
+ context.DrawImage(Image, 1.0,
+ GetSourceImageRegion(),
+ GetImageViewPort()
+ );
+ /*using (var surface = SKSurface.Create(SkiaContext.GrContext, RenderTarget))
+ {
+ surface.Canvas.DrawImage(Image, 0f, 0f);
+ surface.Canvas.Flush();
+ var textureImage = surface.Snapshot(); //This should be texture backed
+ }*/
+
+ /*context.DrawImage(RenderTarget, 1.0,
+ new Rect(0, 0, RenderTarget.PixelSize.Width, RenderTarget.PixelSize.Height),
+ new Rect(0, 0, Width, Height)
+ );*/
+ }
+
+ ///
+ /// Gets the source image region.
+ ///
+ ///
+ public virtual Rect GetSourceImageRegion()
+ {
+ if (Image is null) return new Rect(0, 0, 0, 0);
+
+ if (SizeMode != SizeModes.Stretch)
+ {
+ var viewPort = GetImageViewPort();
+ var sourceLeft = Offset.X / ZoomFactor;
+ var sourceTop = Offset.Y / ZoomFactor;
+ var sourceWidth = viewPort.Width / ZoomFactor;
+ var sourceHeight = viewPort.Height / ZoomFactor;
+
+ return new Rect(sourceLeft, sourceTop, sourceWidth, sourceHeight);
+ }
+
+ return new Rect(0, 0, Image.Size.Width, Image.Size.Height);
+ }
+
+ ///
+ /// Gets the image view port.
+ ///
+ ///
+ public virtual Rect GetImageViewPort()
+ {
+ if (Viewport.Width == 0 && Viewport.Height == 0) return Rect.Empty;
+
+ double xOffset = 0;
+ double yOffset = 0;
+ double width;
+ double height;
+
+ if (SizeMode != SizeModes.Stretch)
+ {
+ if (AutoCenter)
+ {
+ xOffset = !IsHorizontalBarVisible ? (Viewport.Width - ScaledImageWidth) / 2 : 0;
+ yOffset = !IsVerticalBarVisible ? (Viewport.Height - ScaledImageHeight) / 2 : 0;
+ }
+
+ width = Math.Min(ScaledImageWidth - Math.Abs(Offset.X), Viewport.Width);
+ height = Math.Min(ScaledImageHeight - Math.Abs(Offset.Y), Viewport.Height);
+ }
+ else
+ {
+ width = Viewport.Width;
+ height = Viewport.Height;
+ }
+
+ return new Rect(xOffset, yOffset, width, height);
+ }
+
+ ///
+ /// Gets the width of the scaled image.
+ ///
+ /// The width of the scaled image.
+ protected virtual double ScaledImageWidth => Image.Size.Width * ZoomFactor;
+
+ ///
+ /// Gets the height of the scaled image.
+ ///
+ /// The height of the scaled image.
+ protected virtual double ScaledImageHeight => Image.Size.Height * ZoomFactor;
+
+ public double ZoomFactor => Zoom / 100.0;
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (e.Handled) return;
+
+ var pointer = e.GetCurrentPoint(this);
+ if (!(
+ pointer.Properties.IsLeftButtonPressed && (PanWithMouseButtons & PanMouseButtons.LeftButton) != 0 ||
+ pointer.Properties.IsMiddleButtonPressed && (PanWithMouseButtons & PanMouseButtons.MiddleButton) != 0 ||
+ pointer.Properties.IsRightButtonPressed && (PanWithMouseButtons & PanMouseButtons.RightButton) != 0
+ )
+ || !AutoPan || IsPanning || Image == null) return;
+ var location = pointer.Position;
+
+ if (location.X > Viewport.Width) return;
+ if (location.Y > Viewport.Height) return;
+ _startMousePosition = location;
+ IsPanning = true;
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.Handled) return;
+
+ if (IsPanning) IsPanning = false;
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ base.OnPointerMoved(e);
+ if (e.Handled) return;
+
+ if (!IsPanning) return;
+ var pointer = e.GetCurrentPoint(this);
+ var location = pointer.Position;
+
+ double x;
+ double y;
+
+ if (!InvertMouse)
+ {
+ x = _startScrollPosition.X + (_startMousePosition.X - location.X);
+ y = _startScrollPosition.Y + (_startMousePosition.Y - location.Y);
+ }
+ else
+ {
+ x = (_startScrollPosition.X - (_startMousePosition.X - location.X));
+ y = (_startScrollPosition.Y - (_startMousePosition.Y - location.Y));
+ }
+
+ Offset = new Point(x, y);
+ e.Handled = true;
+ }
+ #endregion
+ }
+}
diff --git a/UVtools.WPF/Extensions/MatrixHelper.cs b/UVtools.WPF/Extensions/MatrixHelper.cs
new file mode 100644
index 0000000..f8b1e78
--- /dev/null
+++ b/UVtools.WPF/Extensions/MatrixHelper.cs
@@ -0,0 +1,131 @@
+using System;
+using Avalonia;
+
+namespace UVtools.WPF.Extensions
+{
+ ///
+ /// Avalonia Matrix helper methods.
+ ///
+ public static class MatrixHelper
+ {
+ ///
+ /// Creates a translation matrix using the specified offsets.
+ ///
+ /// X-coordinate offset.
+ /// Y-coordinate offset.
+ /// The created translation matrix.
+ public static Matrix Translate(double offsetX, double offsetY)
+ {
+ return new Matrix(1.0, 0.0, 0.0, 1.0, offsetX, offsetY);
+ }
+
+ ///
+ /// Prepends a translation around the center of provided matrix.
+ ///
+ /// The matrix to prepend translation.
+ /// X-coordinate offset.
+ /// Y-coordinate offset.
+ /// The created translation matrix.
+ public static Matrix TranslatePrepend(Matrix matrix, double offsetX, double offsetY)
+ {
+ return Translate(offsetX, offsetY) * matrix;
+ }
+
+ ///
+ /// Creates a matrix that scales along the x-axis and y-axis.
+ ///
+ /// Scaling factor that is applied along the x-axis.
+ /// Scaling factor that is applied along the y-axis.
+ /// The created scaling matrix.
+ public static Matrix Scale(double scaleX, double scaleY)
+ {
+ return new Matrix(scaleX, 0, 0, scaleY, 0.0, 0.0);
+ }
+
+ ///
+ /// Creates a matrix that is scaling from a specified center.
+ ///
+ /// Scaling factor that is applied along the x-axis.
+ /// Scaling factor that is applied along the y-axis.
+ /// The center X-coordinate of the scaling.
+ /// The center Y-coordinate of the scaling.
+ /// The created scaling matrix.
+ public static Matrix ScaleAt(double scaleX, double scaleY, double centerX, double centerY)
+ {
+ return new Matrix(scaleX, 0, 0, scaleY, centerX - (scaleX * centerX), centerY - (scaleY * centerY));
+ }
+
+ ///
+ /// Prepends a scale around the center of provided matrix.
+ ///
+ /// The matrix to prepend scale.
+ /// Scaling factor that is applied along the x-axis.
+ /// Scaling factor that is applied along the y-axis.
+ /// The center X-coordinate of the scaling.
+ /// The center Y-coordinate of the scaling.
+ /// The created scaling matrix.
+ public static Matrix ScaleAtPrepend(Matrix matrix, double scaleX, double scaleY, double centerX, double centerY)
+ {
+ return ScaleAt(scaleX, scaleY, centerX, centerY) * matrix;
+ }
+
+ ///
+ /// Creates a skew matrix.
+ ///
+ /// Angle of skew along the X-axis in radians.
+ /// Angle of skew along the Y-axis in radians.
+ /// When the method completes, contains the created skew matrix.
+ public static Matrix Skew(float angleX, float angleY)
+ {
+ return new Matrix(1.0, Math.Tan(angleX), Math.Tan(angleY), 1.0, 0.0, 0.0);
+ }
+
+ ///
+ /// Creates a matrix that rotates.
+ ///
+ /// Angle of rotation in radians. Angles are measured clockwise when looking along the rotation axis.
+ /// The created rotation matrix.
+ public static Matrix Rotation(double radians)
+ {
+ double cos = Math.Cos(radians);
+ double sin = Math.Sin(radians);
+ return new Matrix(cos, sin, -sin, cos, 0, 0);
+ }
+
+ ///
+ /// Creates a matrix that rotates about a specified center.
+ ///
+ /// Angle of rotation in radians.
+ /// The center X-coordinate of the rotation.
+ /// The center Y-coordinate of the rotation.
+ /// The created rotation matrix.
+ public static Matrix Rotation(double angle, double centerX, double centerY)
+ {
+ return Translate(-centerX, -centerY) * Rotation(angle) * Translate(centerX, centerY);
+ }
+
+ ///
+ /// Creates a matrix that rotates about a specified center.
+ ///
+ /// Angle of rotation in radians.
+ /// The center of the rotation.
+ /// The created rotation matrix.
+ public static Matrix Rotation(double angle, Vector center)
+ {
+ return Translate(-center.X, -center.Y) * Rotation(angle) * Translate(center.X, center.Y);
+ }
+
+ ///
+ /// Transforms a point by this matrix.
+ ///
+ /// The matrix to use as a transformation matrix.
+ /// >The original point to apply the transformation.
+ /// The result of the transformation for the input point.
+ public static Point TransformPoint(Matrix matrix, Point point)
+ {
+ return new Point(
+ (point.X * matrix.M11) + (point.Y * matrix.M21) + matrix.M31,
+ (point.X * matrix.M12) + (point.Y * matrix.M22) + matrix.M32);
+ }
+ }
+}
diff --git a/UVtools.WPF/Extensions/PrimitivesExtensions.cs b/UVtools.WPF/Extensions/PrimitivesExtensions.cs
new file mode 100644
index 0000000..d4ff66c
--- /dev/null
+++ b/UVtools.WPF/Extensions/PrimitivesExtensions.cs
@@ -0,0 +1,12 @@
+using Avalonia;
+
+namespace UVtools.WPF.Extensions
+{
+ public static class PrimitivesExtensions
+ {
+ public static bool IsEmpty(this Point point)
+ {
+ return point.X == 0 && point.Y == 0;
+ }
+ }
+}
diff --git a/UVtools.WPF/MainWindow.axaml b/UVtools.WPF/MainWindow.axaml
new file mode 100644
index 0000000..0e97bee
--- /dev/null
+++ b/UVtools.WPF/MainWindow.axaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/UVtools.WPF/MainWindow.axaml.cs b/UVtools.WPF/MainWindow.axaml.cs
new file mode 100644
index 0000000..f4b19ff
--- /dev/null
+++ b/UVtools.WPF/MainWindow.axaml.cs
@@ -0,0 +1,42 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using UVtools.WPF.Controls;
+
+namespace UVtools.WPF
+{
+ public class MainWindow : Window
+ {
+ public AdvancedImageBox LayerImage;
+ public Button ZoomToFitButton;
+ public Button CenterButton;
+ public MainWindow()
+ {
+ InitializeComponent();
+#if DEBUG
+ this.AttachDevTools();
+#endif
+ LayerImage = this.FindControl("Layer.ImageOld");
+ ZoomToFitButton = this.FindControl