From c7442ce100f49c2481ae4dd03e2bbcaf574e496a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Jul 2026 21:07:22 -0500 Subject: [PATCH 01/11] Update NuGet packages including Emgu.CV 4.13 and WPF-UI 4.3 Co-Authored-By: Claude Fable 5 --- MagickCrop-Package/MagickCrop-Package.wapproj | 2 +- MagickCrop/MagickCrop.csproj | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/MagickCrop-Package/MagickCrop-Package.wapproj b/MagickCrop-Package/MagickCrop-Package.wapproj index 86d4529..60866bb 100644 --- a/MagickCrop-Package/MagickCrop-Package.wapproj +++ b/MagickCrop-Package/MagickCrop-Package.wapproj @@ -165,7 +165,7 @@ - + diff --git a/MagickCrop/MagickCrop.csproj b/MagickCrop/MagickCrop.csproj index 9dbdcb6..ac08c9c 100644 --- a/MagickCrop/MagickCrop.csproj +++ b/MagickCrop/MagickCrop.csproj @@ -40,16 +40,16 @@ - - - - - - - - + + + + + + + + - + From 51b26c5077c070c1f70ce74088fef578f75cfa9a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Jul 2026 21:07:40 -0500 Subject: [PATCH 02/11] Add RGB histogram control and threshold adjustment command Async per-channel histogram rendering with a draggable threshold indicator, plus an ApplyThreshold relay command on the ViewModel. Co-Authored-By: Claude Fable 5 --- MagickCrop/Controls/RgbHistogramControl.xaml | 39 ++++ .../Controls/RgbHistogramControl.xaml.cs | 188 ++++++++++++++++++ MagickCrop/ViewModels/MainWindowViewModel.cs | 7 + 3 files changed, 234 insertions(+) create mode 100644 MagickCrop/Controls/RgbHistogramControl.xaml create mode 100644 MagickCrop/Controls/RgbHistogramControl.xaml.cs diff --git a/MagickCrop/Controls/RgbHistogramControl.xaml b/MagickCrop/Controls/RgbHistogramControl.xaml new file mode 100644 index 0000000..30e05f0 --- /dev/null +++ b/MagickCrop/Controls/RgbHistogramControl.xaml @@ -0,0 +1,39 @@ + + + + + + + + + + + diff --git a/MagickCrop/Controls/RgbHistogramControl.xaml.cs b/MagickCrop/Controls/RgbHistogramControl.xaml.cs new file mode 100644 index 0000000..e22402a --- /dev/null +++ b/MagickCrop/Controls/RgbHistogramControl.xaml.cs @@ -0,0 +1,188 @@ +using ImageMagick; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Shapes; + +namespace MagickCrop.Controls; + +public partial class RgbHistogramControl : UserControl +{ + private long[]? _rBins, _gBins, _bBins; + private CancellationTokenSource? _cts; + private bool _isThresholdActive; + + public bool IsThresholdActive + { + get => _isThresholdActive; + set { _isThresholdActive = value; UpdateThresholdLine(); } + } + + public static readonly DependencyProperty ImagePathProperty = + DependencyProperty.Register( + nameof(ImagePath), + typeof(string), + typeof(RgbHistogramControl), + new PropertyMetadata(null, OnImagePathChanged)); + + public string? ImagePath + { + get => (string?)GetValue(ImagePathProperty); + set => SetValue(ImagePathProperty, value); + } + + public static readonly DependencyProperty ThresholdValueProperty = + DependencyProperty.Register( + nameof(ThresholdValue), + typeof(double), + typeof(RgbHistogramControl), + new PropertyMetadata(128.0, OnThresholdValueChanged)); + + public double ThresholdValue + { + get => (double)GetValue(ThresholdValueProperty); + set => SetValue(ThresholdValueProperty, value); + } + + public RgbHistogramControl() + { + InitializeComponent(); + } + + private static void OnImagePathChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((RgbHistogramControl)d).LoadHistogramAsync((string?)e.NewValue); + } + + private static void OnThresholdValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((RgbHistogramControl)d).UpdateThresholdLine(); + } + + private async void LoadHistogramAsync(string? path) + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + CancellationToken token = _cts.Token; + + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + _rBins = _gBins = _bBins = null; + Render(); + return; + } + + long[] r = new long[256], g = new long[256], b = new long[256]; + + try + { + await Task.Run(() => + { + using MagickImage image = new(path); + // Resize to limit memory and speed up computation + if (image.Width > 512 || image.Height > 512) + image.Resize(new MagickGeometry(512, 512) { Greater = true }); + + // ToByteArray scales Q16 values to 0-255 per channel + byte[]? data = image.GetPixelsUnsafe().ToByteArray(PixelMapping.RGB); + if (data is null) return; + + for (int i = 0; i + 2 < data.Length; i += 3) + { + token.ThrowIfCancellationRequested(); + r[data[i]]++; + g[data[i + 1]]++; + b[data[i + 2]]++; + } + }, token); + } + catch (OperationCanceledException) { return; } + + // A newer load may have started while this one was finishing + if (token.IsCancellationRequested) + return; + + _rBins = r; + _gBins = g; + _bBins = b; + Render(); + } + + private void Render() + { + HistogramCanvas.Children.Clear(); + + if (_rBins is null) + { + NoImageText.Visibility = Visibility.Visible; + return; + } + + NoImageText.Visibility = Visibility.Collapsed; + + double w = HistogramCanvas.ActualWidth; + double h = HistogramCanvas.ActualHeight; + if (w <= 0 || h <= 0) return; + + long max = 0; + for (int i = 0; i < 256; i++) + { + max = Math.Max(max, _rBins[i]); + max = Math.Max(max, _gBins![i]); + max = Math.Max(max, _bBins![i]); + } + if (max == 0) return; + + DrawChannel(_rBins, Color.FromArgb(80, 220, 50, 50), Color.FromArgb(180, 220, 50, 50), w, h, max); + DrawChannel(_gBins!, Color.FromArgb(80, 50, 200, 80), Color.FromArgb(180, 50, 200, 80), w, h, max); + DrawChannel(_bBins!, Color.FromArgb(80, 50, 120, 220), Color.FromArgb(180, 50, 120, 220), w, h, max); + + UpdateThresholdLine(); + } + + private void UpdateThresholdLine() + { + double w = HistogramCanvas.ActualWidth; + if (_rBins is null || !_isThresholdActive || w <= 0) + { + ThresholdIndicator.Visibility = Visibility.Collapsed; + return; + } + double x = ThresholdValue / 255.0 * w; + ThresholdIndicator.Margin = new Thickness(x, 0, 0, 0); + ThresholdIndicator.Visibility = Visibility.Visible; + } + + private void DrawChannel(long[] bins, Color fillColor, Color strokeColor, double w, double h, long max) + { + var points = new PointCollection(258) + { + new Point(0, h) + }; + for (int i = 0; i < 256; i++) + { + double x = i / 255.0 * w; + double y = h - (bins[i] / (double)max * h); + points.Add(new Point(x, y)); + } + points.Add(new Point(w, h)); + + var polygon = new Polygon + { + Points = points, + Fill = new SolidColorBrush(fillColor), + Stroke = new SolidColorBrush(strokeColor), + StrokeThickness = 1, + IsHitTestVisible = false, + }; + HistogramCanvas.Children.Add(polygon); + } + + private void HistogramCanvas_SizeChanged(object sender, SizeChangedEventArgs e) + { + Render(); + UpdateThresholdLine(); + } +} diff --git a/MagickCrop/ViewModels/MainWindowViewModel.cs b/MagickCrop/ViewModels/MainWindowViewModel.cs index 3f88e50..668556e 100644 --- a/MagickCrop/ViewModels/MainWindowViewModel.cs +++ b/MagickCrop/ViewModels/MainWindowViewModel.cs @@ -53,6 +53,7 @@ public void SetView(IMainWindowView view) [NotifyCanExecuteChangedFor(nameof(Rotate90CcwCommand))] [NotifyCanExecuteChangedFor(nameof(FlipVerticalCommand))] [NotifyCanExecuteChangedFor(nameof(FlipHorizontalCommand))] + [NotifyCanExecuteChangedFor(nameof(ApplyThresholdCommand))] private string? imagePath; [ObservableProperty] @@ -273,6 +274,12 @@ private async Task Share() [RelayCommand(CanExecute = nameof(CanApplyAdjustment))] private Task ApplyFindEdges() => ApplyAdjustmentAsync(img => img.CannyEdge()); + [ObservableProperty] + private double thresholdValue = 128.0; + + [RelayCommand(CanExecute = nameof(CanApplyAdjustment))] + private Task ApplyThreshold() => ApplyAdjustmentAsync(img => img.Threshold(new Percentage(ThresholdValue / 255.0 * 100.0))); + private async Task ApplyAdjustmentAsync(Action adjustment) { if (_view is null || string.IsNullOrWhiteSpace(ImagePath)) From ab203b2755b3846bf4b1e3b4904a08a59f267c65 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Jul 2026 21:08:01 -0500 Subject: [PATCH 03/11] Add markup annotation controls, models, and undo/redo items - MarkupShapeControl: two-point line/arrow/rectangle/ellipse overlay - MarkupTextControl: draggable text label with inline commit/cancel edit mode (Enter or click-away accepts, Escape cancels) - WhiteboardInkConverter: Emgu.CV-based whiteboard cleanup and photographed-stroke-to-ink conversion - Serializable DTOs for shapes, texts, and ink strokes wired into MeasurementCollection for project persistence - Undo/redo items covering add, remove, move, edit, resize, stroke property changes, and clear-all of markup Co-Authored-By: Claude Fable 5 --- MagickCrop/Controls/MarkupShapeControl.xaml | 75 +++ .../Controls/MarkupShapeControl.xaml.cs | 215 +++++++ MagickCrop/Controls/MarkupTextControl.xaml | 52 ++ MagickCrop/Controls/MarkupTextControl.xaml.cs | 220 +++++++ MagickCrop/Helpers/WhiteboardInkConverter.cs | 550 ++++++++++++++++++ MagickCrop/Models/DraggingMode.cs | 4 +- MagickCrop/Models/MarkupShapeType.cs | 9 + .../MeasurementControls/MarkupShapeDto.cs | 18 + .../MeasurementControls/MarkupStrokeDto.cs | 38 ++ .../MeasurementControls/MarkupTextDto.cs | 15 + .../MeasurementCollection.cs | 15 + MagickCrop/Models/UndoRedo.cs | 445 ++++++++++++++ 12 files changed, 1655 insertions(+), 1 deletion(-) create mode 100644 MagickCrop/Controls/MarkupShapeControl.xaml create mode 100644 MagickCrop/Controls/MarkupShapeControl.xaml.cs create mode 100644 MagickCrop/Controls/MarkupTextControl.xaml create mode 100644 MagickCrop/Controls/MarkupTextControl.xaml.cs create mode 100644 MagickCrop/Helpers/WhiteboardInkConverter.cs create mode 100644 MagickCrop/Models/MarkupShapeType.cs create mode 100644 MagickCrop/Models/MeasurementControls/MarkupShapeDto.cs create mode 100644 MagickCrop/Models/MeasurementControls/MarkupStrokeDto.cs create mode 100644 MagickCrop/Models/MeasurementControls/MarkupTextDto.cs diff --git a/MagickCrop/Controls/MarkupShapeControl.xaml b/MagickCrop/Controls/MarkupShapeControl.xaml new file mode 100644 index 0000000..28bd83a --- /dev/null +++ b/MagickCrop/Controls/MarkupShapeControl.xaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MagickCrop/Controls/MarkupShapeControl.xaml.cs b/MagickCrop/Controls/MarkupShapeControl.xaml.cs new file mode 100644 index 0000000..ca71d83 --- /dev/null +++ b/MagickCrop/Controls/MarkupShapeControl.xaml.cs @@ -0,0 +1,215 @@ +using MagickCrop.Models; +using MagickCrop.Models.MeasurementControls; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; + +namespace MagickCrop.Controls; + +public partial class MarkupShapeControl : UserControl +{ + private Point point1 = new(100, 100); + private Point point2 = new(300, 300); + private int pointDraggingIndex = -1; + + public event MouseButtonEventHandler? MeasurementPointMouseDown; + + public delegate void RemoveControlRequestedEventHandler(object sender, EventArgs e); + public event RemoveControlRequestedEventHandler? RemoveControlRequested; + + private MarkupShapeType shapeType = MarkupShapeType.Rectangle; + public MarkupShapeType ShapeType + { + get => shapeType; + set + { + shapeType = value; + UpdateShapeVisibility(); + UpdatePositions(); + } + } + + private Color strokeColor = Colors.Red; + public Color StrokeColor + { + get => strokeColor; + set + { + strokeColor = value; + UpdateColors(); + } + } + + private double strokeThickness = 3.0; + public double StrokeThickness + { + get => strokeThickness; + set + { + strokeThickness = value; + UpdateThickness(); + UpdatePositions(); + } + } + + public MarkupShapeControl() + { + InitializeComponent(); + UpdateShapeVisibility(); + UpdatePositions(); + UpdateColors(); + } + + private void UpdateShapeVisibility() + { + ShapeLine.Visibility = shapeType is MarkupShapeType.Line or MarkupShapeType.Arrow + ? Visibility.Visible : Visibility.Collapsed; + ArrowHead.Visibility = shapeType == MarkupShapeType.Arrow + ? Visibility.Visible : Visibility.Collapsed; + ShapeRectangle.Visibility = shapeType == MarkupShapeType.Rectangle + ? Visibility.Visible : Visibility.Collapsed; + ShapeEllipse.Visibility = shapeType == MarkupShapeType.Ellipse + ? Visibility.Visible : Visibility.Collapsed; + } + + private void UpdateColors() + { + SolidColorBrush brush = new(strokeColor); + ShapeLine.Stroke = brush; + ArrowHead.Fill = new SolidColorBrush(strokeColor); + ShapeRectangle.Stroke = new SolidColorBrush(strokeColor); + ShapeEllipse.Stroke = new SolidColorBrush(strokeColor); + Point1Handle.Fill = new SolidColorBrush(strokeColor); + Point2Handle.Fill = new SolidColorBrush(strokeColor); + } + + private void UpdateThickness() + { + ShapeLine.StrokeThickness = strokeThickness; + ShapeRectangle.StrokeThickness = strokeThickness; + ShapeEllipse.StrokeThickness = strokeThickness; + } + + private void UpdatePositions() + { + Canvas.SetLeft(Point1Handle, point1.X - Point1Handle.Width / 2); + Canvas.SetTop(Point1Handle, point1.Y - Point1Handle.Height / 2); + Canvas.SetLeft(Point2Handle, point2.X - Point2Handle.Width / 2); + Canvas.SetTop(Point2Handle, point2.Y - Point2Handle.Height / 2); + + switch (shapeType) + { + case MarkupShapeType.Line: + case MarkupShapeType.Arrow: + ShapeLine.X1 = point1.X; + ShapeLine.Y1 = point1.Y; + ShapeLine.X2 = point2.X; + ShapeLine.Y2 = point2.Y; + if (shapeType == MarkupShapeType.Arrow) + UpdateArrowHead(); + break; + + case MarkupShapeType.Rectangle: + double rx = Math.Min(point1.X, point2.X); + double ry = Math.Min(point1.Y, point2.Y); + Canvas.SetLeft(ShapeRectangle, rx); + Canvas.SetTop(ShapeRectangle, ry); + ShapeRectangle.Width = Math.Max(1, Math.Abs(point2.X - point1.X)); + ShapeRectangle.Height = Math.Max(1, Math.Abs(point2.Y - point1.Y)); + break; + + case MarkupShapeType.Ellipse: + double ex = Math.Min(point1.X, point2.X); + double ey = Math.Min(point1.Y, point2.Y); + Canvas.SetLeft(ShapeEllipse, ex); + Canvas.SetTop(ShapeEllipse, ey); + ShapeEllipse.Width = Math.Max(1, Math.Abs(point2.X - point1.X)); + ShapeEllipse.Height = Math.Max(1, Math.Abs(point2.Y - point1.Y)); + break; + } + } + + private void UpdateArrowHead() + { + double dx = point2.X - point1.X; + double dy = point2.Y - point1.Y; + double len = Math.Sqrt(dx * dx + dy * dy); + if (len < 1) return; + + double arrowSize = Math.Max(14, strokeThickness * 4); + double angle = Math.Atan2(dy, dx); + + Point tip = point2; + Point b1 = new( + tip.X - arrowSize * Math.Cos(angle - Math.PI / 6), + tip.Y - arrowSize * Math.Sin(angle - Math.PI / 6)); + Point b2 = new( + tip.X - arrowSize * Math.Cos(angle + Math.PI / 6), + tip.Y - arrowSize * Math.Sin(angle + Math.PI / 6)); + + ArrowHead.Points = [tip, b1, b2]; + } + + private void HandlePoint_MouseDown(object sender, MouseButtonEventArgs e) + { + if (sender is not Ellipse ellipse || ellipse.Tag is not string s) + return; + pointDraggingIndex = int.Parse(s); + MeasurementPointMouseDown?.Invoke(sender, e); + } + + public void MovePoint(int pointIndex, Point newPosition) + { + if (pointIndex == 0) point1 = newPosition; + else if (pointIndex == 1) point2 = newPosition; + UpdatePositions(); + } + + public void StartDraggingPoint(int pointIndex) + { + pointDraggingIndex = pointIndex; + MeasurementPointMouseDown?.Invoke( + pointIndex == 0 ? Point1Handle : Point2Handle, null!); + } + + public int GetActivePointIndex() => pointDraggingIndex; + + public void ResetActivePoint() => pointDraggingIndex = -1; + + public (Point Point1, Point Point2) GetPoints() => (point1, point2); + + private void RemoveMenuItem_Click(object sender, RoutedEventArgs e) + { + RemoveControlRequested?.Invoke(this, EventArgs.Empty); + } + + public MarkupShapeDto ToDto() + { + return new MarkupShapeDto + { + ShapeType = shapeType, + Point1 = point1, + Point2 = point2, + StrokeColor = strokeColor.ToString(), + StrokeThickness = strokeThickness + }; + } + + public void FromDto(MarkupShapeDto dto) + { + shapeType = dto.ShapeType; + point1 = dto.Point1; + point2 = dto.Point2; + + try { strokeColor = (Color)ColorConverter.ConvertFromString(dto.StrokeColor); } + catch { strokeColor = Colors.Red; } + + strokeThickness = dto.StrokeThickness; + UpdateShapeVisibility(); + UpdateColors(); + UpdateThickness(); + UpdatePositions(); + } +} diff --git a/MagickCrop/Controls/MarkupTextControl.xaml b/MagickCrop/Controls/MarkupTextControl.xaml new file mode 100644 index 0000000..c6d063d --- /dev/null +++ b/MagickCrop/Controls/MarkupTextControl.xaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + diff --git a/MagickCrop/Controls/MarkupTextControl.xaml.cs b/MagickCrop/Controls/MarkupTextControl.xaml.cs new file mode 100644 index 0000000..99e0fdf --- /dev/null +++ b/MagickCrop/Controls/MarkupTextControl.xaml.cs @@ -0,0 +1,220 @@ +using MagickCrop.Models.MeasurementControls; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; + +namespace MagickCrop.Controls; + +public partial class MarkupTextControl : UserControl +{ + private Point dragOffset; + private Point positionBeforeDrag; + private bool isDragging; + private string textBeforeEdit = "Text"; + + public delegate void RemoveControlRequestedEventHandler(object sender, EventArgs e); + public event RemoveControlRequestedEventHandler? RemoveControlRequested; + + public delegate void TextMovedEventHandler(object sender, Point before, Point after); + public event TextMovedEventHandler? TextMoved; + + public event EventHandler? EditCommitted; + public event EventHandler? EditCancelled; + + public bool IsEditing => EditBox.Visibility == Visibility.Visible; + + /// + /// The text as it was when the current or most recent edit began. + /// + public string TextBeforeEdit => textBeforeEdit; + + private Color textColor = Colors.Red; + public Color TextColor + { + get => textColor; + set + { + textColor = value; + SolidColorBrush brush = new(textColor); + DisplayText.Foreground = brush; + EditBox.Foreground = brush; + EditBox.CaretBrush = brush; + } + } + + private double markupFontSize = 16.0; + public double MarkupFontSize + { + get => markupFontSize; + set + { + markupFontSize = value; + DisplayText.FontSize = markupFontSize; + EditBox.FontSize = markupFontSize; + } + } + + public string MarkupText + { + get => DisplayText.Text; + set + { + DisplayText.Text = value; + EditBox.Text = value; + } + } + + public MarkupTextControl() + { + InitializeComponent(); + } + + public void EnterEditMode() + { + textBeforeEdit = DisplayText.Text; + EditBox.Text = DisplayText.Text; + DisplayText.Visibility = Visibility.Collapsed; + EditBox.Visibility = Visibility.Visible; + + // The control may have just been added to the canvas and not be loaded + // yet, in which case Focus() fails — defer until layout has run + Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () => + { + EditBox.Focus(); + EditBox.SelectAll(); + }); + } + + public void CommitEdit() => FinishEdit(accepted: true); + + public void CancelEdit() => FinishEdit(accepted: false); + + private void FinishEdit(bool accepted) + { + if (!IsEditing) + return; + + string text = EditBox.Text.Trim(); + if (text.Length == 0) + accepted = false; // committing empty text is a cancel + + // Collapse first: the focus shift it triggers re-enters via LostFocus, + // which the IsEditing guard above turns into a no-op + EditBox.Visibility = Visibility.Collapsed; + DisplayText.Visibility = Visibility.Visible; + + if (accepted) + { + DisplayText.Text = text; + EditBox.Text = text; + EditCommitted?.Invoke(this, EventArgs.Empty); + } + else + { + DisplayText.Text = textBeforeEdit; + EditBox.Text = textBeforeEdit; + EditCancelled?.Invoke(this, EventArgs.Empty); + } + } + + private void EditBox_LostFocus(object sender, RoutedEventArgs e) + { + CommitEdit(); + } + + private void EditBox_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + CommitEdit(); + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + CancelEdit(); + e.Handled = true; + } + } + + private void Border_MouseDown(object sender, MouseButtonEventArgs e) + { + if (IsEditing || e.ChangedButton != MouseButton.Left) + return; + + if (e.ClickCount == 2) + { + EnterEditMode(); + e.Handled = true; + return; + } + + isDragging = true; + dragOffset = e.GetPosition(this); + positionBeforeDrag = new Point(Canvas.GetLeft(this), Canvas.GetTop(this)); + // Capture on the Border (the sender) so its MouseMove/MouseUp handlers + // keep receiving events; capturing the UserControl routes events away + // from the Border and the drag never updates or releases + ((UIElement)sender).CaptureMouse(); + e.Handled = true; + } + + private void Border_MouseMove(object sender, MouseEventArgs e) + { + if (!isDragging || Parent is not Canvas canvas) + return; + + Point parentPos = e.GetPosition(canvas); + Canvas.SetLeft(this, parentPos.X - dragOffset.X); + Canvas.SetTop(this, parentPos.Y - dragOffset.Y); + } + + private void Border_MouseUp(object sender, MouseButtonEventArgs e) + { + if (!isDragging) + return; + + isDragging = false; + ((UIElement)sender).ReleaseMouseCapture(); + e.Handled = true; + + Point positionAfterDrag = new(Canvas.GetLeft(this), Canvas.GetTop(this)); + if (Math.Abs(positionAfterDrag.X - positionBeforeDrag.X) > 0.01 + || Math.Abs(positionAfterDrag.Y - positionBeforeDrag.Y) > 0.01) + { + TextMoved?.Invoke(this, positionBeforeDrag, positionAfterDrag); + } + } + + private void Border_LostMouseCapture(object sender, MouseEventArgs e) + { + isDragging = false; + } + + private void RemoveMenuItem_Click(object sender, RoutedEventArgs e) + { + RemoveControlRequested?.Invoke(this, EventArgs.Empty); + } + + public MarkupTextDto ToDto() + { + return new MarkupTextDto + { + Text = DisplayText.Text, + PositionX = Canvas.GetLeft(this), + PositionY = Canvas.GetTop(this), + TextColor = textColor.ToString(), + FontSize = markupFontSize + }; + } + + public void FromDto(MarkupTextDto dto) + { + MarkupText = dto.Text; + MarkupFontSize = dto.FontSize; + + try { TextColor = (Color)ColorConverter.ConvertFromString(dto.TextColor); } + catch { TextColor = Colors.Red; } + } +} diff --git a/MagickCrop/Helpers/WhiteboardInkConverter.cs b/MagickCrop/Helpers/WhiteboardInkConverter.cs new file mode 100644 index 0000000..3b6e580 --- /dev/null +++ b/MagickCrop/Helpers/WhiteboardInkConverter.cs @@ -0,0 +1,550 @@ +using Emgu.CV; +using Emgu.CV.CvEnum; +using Emgu.CV.Structure; +using Emgu.CV.Util; +using System.Drawing; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows.Ink; +using System.Windows.Input; +using WpfColor = System.Windows.Media.Color; + +namespace MagickCrop.Helpers; + +public static class WhiteboardInkConverter +{ + private const int MinComponentPixels = 50; + private const double SimplifyEpsilon = 2.0; + private const int DefaultSpeckleMinArea = 250; + + // HSV saturation threshold: pixels with S > this are considered colored ink + // (blue/red/green markers on white; white background has S ≈ 0) + private const int SaturationThreshold = 30; + + public static async Task> ConvertToStrokesAsync( + string imagePath, double displayWidth, double displayHeight) + { + return await Task.Run(() => Convert(imagePath, displayWidth, displayHeight)); + } + + /// + /// Removes small isolated marks (speckles/dirt) from a whiteboard image. + /// Detects marks via HSV saturation (colored ink) and grayscale darkness. + /// Returns the path to a cleaned temp PNG, or null if no speckles were found. + /// + public static async Task RemoveSpecklesAsync(string imagePath, int minArea = DefaultSpeckleMinArea) + { + return await Task.Run(() => RemoveSpeckles(imagePath, minArea)); + } + + /// + /// Estimates the dominant stroke width in image pixels using the distance transform + /// at skeleton pixels (75th-percentile diameter). Useful for diagnosing why conversion + /// produces strokes that are too thick or too thin. + /// + public static async Task DetectStrokeWidthAsync(string imagePath) + { + return await Task.Run(() => DetectStrokeWidth(imagePath)); + } + + /// + /// Fills hollow interiors of thick whiteboard marker strokes so each stroke is a solid + /// blob before skeletonization. Thick markers often produce "ring" shaped regions in the + /// binary image; this preprocessing step corrects that so the centerline is found cleanly. + /// Returns the path to a modified temp PNG, or null if no hollow regions were found. + /// + public static async Task FillHollowStrokesAsync(string imagePath) + { + return await Task.Run(() => FillHollowStrokes(imagePath)); + } + + // Builds a binary mask where white = ink stroke, black = background. + // Detects BOTH colored markers (via HSV saturation) and dark markers (via adaptive + // grayscale threshold), then OR-combines them. This is critical for colored markers + // (blue, red, etc.) which are not "dark" in grayscale and are missed by the gray-only path. + private static Mat CreateStrokeMask(Mat bgr) + { + using Mat hsv = new(); + CvInvoke.CvtColor(bgr, hsv, ColorConversion.Bgr2Hsv); + + Mat[] hsvChannels = hsv.Split(); + using Mat satChannel = hsvChannels[1]; // S: 0=gray/white, 255=fully saturated color + using Mat valChannel = hsvChannels[2]; // V: brightness + hsvChannels[0].Dispose(); + + // Colored strokes: pixels with meaningful saturation + using Mat satMask = new(); + CvInvoke.Threshold(satChannel, satMask, SaturationThreshold, 255, ThresholdType.Binary); + + // Exclude pixels that are near-pure-white (background bleeds through at S boundary) + using Mat whiteMask = new(); + CvInvoke.Threshold(valChannel, whiteMask, 245, 255, ThresholdType.Binary); + using Mat notWhite = new(); + CvInvoke.BitwiseNot(whiteMask, notWhite); + + using Mat coloredStrokes = new(); + CvInvoke.BitwiseAnd(satMask, notWhite, coloredStrokes); + + // Dark strokes: adaptive threshold on grayscale (catches black markers and edges) + using Mat gray = new(); + CvInvoke.CvtColor(bgr, gray, ColorConversion.Bgr2Gray); + using Mat darkMask = new(); + CvInvoke.AdaptiveThreshold(gray, darkMask, 255, + AdaptiveThresholdType.GaussianC, ThresholdType.BinaryInv, 11, 5); + + // Combined: colored OR dark + Mat combined = new(); + CvInvoke.BitwiseOr(coloredStrokes, darkMask, combined); + return combined; + } + + private static string? RemoveSpeckles(string imagePath, int minArea) + { + using Mat bgr = CvInvoke.Imread(imagePath, ImreadModes.AnyColor); + if (bgr.IsEmpty) return null; + + using Mat binary = CreateStrokeMask(bgr); + + using Mat labels = new(); + int numLabels = CvInvoke.ConnectedComponents(binary, labels, LineType.EightConnected, DepthType.Cv32S); + + int rows = labels.Rows; + int cols = labels.Cols; + int labelStep = labels.Step; + byte[] labelsData = new byte[rows * labelStep]; + Marshal.Copy(labels.DataPointer, labelsData, 0, labelsData.Length); + + int[] areaCounts = new int[numLabels]; + for (int y = 0; y < rows; y++) + { + int ry = y * labelStep; + for (int x = 0; x < cols; x++) + { + int lbl = BitConverter.ToInt32(labelsData, ry + x * 4); + if (lbl > 0) areaCounts[lbl]++; + } + } + + bool[] isSpeckle = new bool[numLabels]; + bool anySpeckle = false; + for (int i = 1; i < numLabels; i++) + { + if (areaCounts[i] < minArea) + { + isSpeckle[i] = true; + anySpeckle = true; + } + } + + if (!anySpeckle) return null; + + // Estimate background color from near-white pixels + using Mat gray = new(); + CvInvoke.CvtColor(bgr, gray, ColorConversion.Bgr2Gray); + using Mat lightMask = new(); + CvInvoke.Threshold(gray, lightMask, 200, 255, ThresholdType.Binary); + MCvScalar bgColor = CvInvoke.Mean(bgr, lightMask); + + using Mat speckleMask = new(bgr.Size, DepthType.Cv8U, 1); + speckleMask.SetTo(new MCvScalar(0)); + int mStep = speckleMask.Step; + byte[] maskData = new byte[rows * mStep]; + + for (int y = 0; y < rows; y++) + { + int ry = y * labelStep; + int my = y * mStep; + for (int x = 0; x < cols; x++) + { + int lbl = BitConverter.ToInt32(labelsData, ry + x * 4); + if (lbl > 0 && isSpeckle[lbl]) + maskData[my + x] = 255; + } + } + Marshal.Copy(maskData, 0, speckleMask.DataPointer, maskData.Length); + + using Mat result = bgr.Clone(); + result.SetTo(bgColor, speckleMask); + + string tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".png"); + CvInvoke.Imwrite(tempPath, result); + return tempPath; + } + + private static double DetectStrokeWidth(string imagePath) + { + using Mat bgr = CvInvoke.Imread(imagePath, ImreadModes.AnyColor); + if (bgr.IsEmpty) return 0; + + using Mat binary = CreateStrokeMask(bgr); + + using Mat morphKernel = CvInvoke.GetStructuringElement( + MorphShapes.Ellipse, new Size(3, 3), new Point(-1, -1)); + using Mat closed = new(); + CvInvoke.MorphologyEx(binary, closed, MorphOp.Close, morphKernel, + new Point(-1, -1), 1, BorderType.Default, new MCvScalar()); + + using Mat skeleton = Skeletonize(closed); + using Mat dist = new(); + CvInvoke.DistanceTransform(closed, dist, null, DistType.L2, 5); + + int rows = skeleton.Rows, cols = skeleton.Cols; + int skelStep = skeleton.Step, distStep = dist.Step; + + byte[] skelData = new byte[rows * skelStep]; + Marshal.Copy(skeleton.DataPointer, skelData, 0, skelData.Length); + byte[] distData = new byte[rows * distStep]; + Marshal.Copy(dist.DataPointer, distData, 0, distData.Length); + + List radii = []; + for (int y = 0; y < rows; y++) + { + int sy = y * skelStep, dy = y * distStep; + for (int x = 0; x < cols; x++) + { + if (skelData[sy + x] == 0) continue; + float r = BitConverter.ToSingle(distData, dy + x * 4); + if (r > 0.5f) radii.Add(r); + } + } + + if (radii.Count == 0) return 3.0; + radii.Sort(); + return Math.Round(radii[(int)(radii.Count * 0.75)] * 2.0, 1); + } + + private static string? FillHollowStrokes(string imagePath) + { + using Mat bgr = CvInvoke.Imread(imagePath, ImreadModes.AnyColor); + if (bgr.IsEmpty) return null; + + using Mat binary = CreateStrokeMask(bgr); + + using Mat morphKernel = CvInvoke.GetStructuringElement( + MorphShapes.Ellipse, new Size(3, 3), new Point(-1, -1)); + using Mat closed = new(); + CvInvoke.MorphologyEx(binary, closed, MorphOp.Close, morphKernel, + new Point(-1, -1), 1, BorderType.Default, new MCvScalar()); + + // RETR_EXTERNAL finds only outermost contours; filling them covers hollow interiors + using VectorOfVectorOfPoint contours = new(); + using Mat hierarchy = new(); + CvInvoke.FindContours(closed, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple); + + using Mat filledMask = new(bgr.Size, DepthType.Cv8U, 1); + filledMask.SetTo(new MCvScalar(0)); + CvInvoke.DrawContours(filledMask, contours, -1, new MCvScalar(255), -1); + + // Hollow pixels: inside a filled contour but not in the original mask + using Mat notClosed = new(); + CvInvoke.BitwiseNot(closed, notClosed); + using Mat hollowMask = new(); + CvInvoke.BitwiseAnd(filledMask, notClosed, hollowMask); + + if (CvInvoke.CountNonZero(hollowMask) == 0) return null; + + MCvScalar inkColor = CvInvoke.Mean(bgr, closed); + + using Mat result = bgr.Clone(); + result.SetTo(inkColor, hollowMask); + + string tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".png"); + CvInvoke.Imwrite(tempPath, result); + return tempPath; + } + + private static List Convert(string imagePath, double displayWidth, double displayHeight) + { + using Mat bgr = CvInvoke.Imread(imagePath, ImreadModes.AnyColor); + if (bgr.IsEmpty) return []; + + WpfColor penColor = EstimatePenColor(bgr); + + using Mat binary = CreateStrokeMask(bgr); + + using Mat morphKernel = CvInvoke.GetStructuringElement( + MorphShapes.Ellipse, new Size(3, 3), new Point(-1, -1)); + using Mat closed = new(); + CvInvoke.MorphologyEx(binary, closed, MorphOp.Close, morphKernel, + new Point(-1, -1), 1, BorderType.Default, new MCvScalar()); + + using Mat skeleton = Skeletonize(closed); + + using Mat dist = new(); + CvInvoke.DistanceTransform(closed, dist, null, DistType.L2, 5); + + double strokeThickness = EstimateStrokeThickness(dist, skeleton, displayWidth / bgr.Width); + + using Mat labels = new(); + int numLabels = CvInvoke.ConnectedComponents( + skeleton, labels, LineType.EightConnected, DepthType.Cv32S); + + double scaleX = displayWidth / bgr.Width; + double scaleY = displayHeight / bgr.Height; + + Dictionary> components = CollectComponents(labels, numLabels); + List result = []; + + foreach ((int _, List? pixels) in components) + { + if (pixels.Count < MinComponentPixels) continue; + + foreach (List path in ExtractPathsFromSkeleton(pixels)) + { + List simplified = SimplifyPath(path, SimplifyEpsilon); + if (simplified.Count < 2) continue; + + StylusPointCollection spc = new( + simplified.Select(p => new StylusPoint(p.X * scaleX, p.Y * scaleY, 0.5f))); + + DrawingAttributes attrs = new() + { + Color = penColor, + Width = strokeThickness, + Height = strokeThickness, + StylusTip = StylusTip.Ellipse, + }; + + result.Add(new Stroke(spc, attrs)); + } + } + + return result; + } + + // Samples colored (high-saturation) pixels to estimate ink color. + // Falls back to dark-pixel detection for black/gray markers. + private static WpfColor EstimatePenColor(Mat bgr) + { + using Mat hsv = new(); + CvInvoke.CvtColor(bgr, hsv, ColorConversion.Bgr2Hsv); + + Mat[] hsvChannels = hsv.Split(); + using Mat satChannel = hsvChannels[1]; + using Mat valChannel = hsvChannels[2]; + hsvChannels[0].Dispose(); + + using Mat satMask = new(); + CvInvoke.Threshold(satChannel, satMask, SaturationThreshold + 10, 255, ThresholdType.Binary); + + // Exclude very bright pixels (background bleed-through near edges) + using Mat whiteMask = new(); + CvInvoke.Threshold(valChannel, whiteMask, 240, 255, ThresholdType.Binary); + using Mat notWhite = new(); + CvInvoke.BitwiseNot(whiteMask, notWhite); + using Mat strokeMask = new(); + CvInvoke.BitwiseAnd(satMask, notWhite, strokeMask); + + if (CvInvoke.CountNonZero(strokeMask) >= 500) + { + MCvScalar mean = CvInvoke.Mean(bgr, strokeMask); + return WpfColor.FromRgb( + (byte)Math.Clamp(mean.V2, 0, 255), + (byte)Math.Clamp(mean.V1, 0, 255), + (byte)Math.Clamp(mean.V0, 0, 255)); + } + + // Fallback for black/gray markers: sample dark pixels + using Mat gray = new(); + CvInvoke.CvtColor(bgr, gray, ColorConversion.Bgr2Gray); + using Mat darkMask = new(); + CvInvoke.Threshold(gray, darkMask, 76, 255, ThresholdType.BinaryInv); + MCvScalar darkMean = CvInvoke.Mean(bgr, darkMask); + return WpfColor.FromRgb( + (byte)Math.Clamp(darkMean.V2, 0, 255), + (byte)Math.Clamp(darkMean.V1, 0, 255), + (byte)Math.Clamp(darkMean.V0, 0, 255)); + } + + // Collects the distance-transform value at every skeleton pixel, sorts them, and returns + // the 75th-percentile value × 2 as the stroke diameter. + private static double EstimateStrokeThickness(Mat dist, Mat skeleton, double imageToDisplayScale) + { + int rows = skeleton.Rows; + int cols = skeleton.Cols; + int skelStep = skeleton.Step; + int distStep = dist.Step; + + byte[] skelData = new byte[rows * skelStep]; + Marshal.Copy(skeleton.DataPointer, skelData, 0, skelData.Length); + + byte[] distData = new byte[rows * distStep]; + Marshal.Copy(dist.DataPointer, distData, 0, distData.Length); + + List radii = []; + for (int y = 0; y < rows; y++) + { + int sy = y * skelStep; + int dy = y * distStep; + for (int x = 0; x < cols; x++) + { + if (skelData[sy + x] == 0) continue; + float r = BitConverter.ToSingle(distData, dy + x * 4); + if (r > 0.5f) radii.Add(r); + } + } + + if (radii.Count == 0) return 3.0 * imageToDisplayScale; + + radii.Sort(); + float p75 = radii[(int)(radii.Count * 0.75)]; + + return Math.Clamp(p75 * 2.0 * imageToDisplayScale, 1.5, 50.0); + } + + private static Mat Skeletonize(Mat binary) + { + using Mat crossKernel = CvInvoke.GetStructuringElement( + MorphShapes.Cross, new Size(3, 3), new Point(1, 1)); + + Mat skeleton = new(binary.Size, DepthType.Cv8U, 1); + skeleton.SetTo(new MCvScalar(0)); + + Mat remaining = binary.Clone(); + using Mat eroded = new(); + using Mat temp = new(); + + try + { + while (CvInvoke.CountNonZero(remaining) > 0) + { + CvInvoke.Erode(remaining, eroded, crossKernel, + new Point(-1, -1), 1, BorderType.Default, new MCvScalar()); + CvInvoke.Dilate(eroded, temp, crossKernel, + new Point(-1, -1), 1, BorderType.Default, new MCvScalar()); + CvInvoke.Subtract(remaining, temp, temp); + CvInvoke.BitwiseOr(skeleton, temp, skeleton); + eroded.CopyTo(remaining); + } + } + finally + { + remaining.Dispose(); + } + + return skeleton; + } + + private static Dictionary> CollectComponents(Mat labels, int numLabels) + { + int rows = labels.Rows; + int cols = labels.Cols; + int step = labels.Step; + + byte[] rawData = new byte[rows * step]; + Marshal.Copy(labels.DataPointer, rawData, 0, rawData.Length); + + Dictionary> components = new(numLabels); + for (int y = 0; y < rows; y++) + { + int rowOffset = y * step; + for (int x = 0; x < cols; x++) + { + int lbl = BitConverter.ToInt32(rawData, rowOffset + x * 4); + if (lbl <= 0) continue; + if (!components.TryGetValue(lbl, out List? pts)) + { + pts = []; + components[lbl] = pts; + } + pts.Add(new Point(x, y)); + } + } + + return components; + } + + // Extracts ordered path segments from a skeleton connected component by traversing + // the pixel adjacency graph. Splits at junction pixels (3+ neighbors) so each + // returned list is a smooth, non-branching sequence of pixels suitable for a stroke. + private static List> ExtractPathsFromSkeleton(List pixels) + { + if (pixels.Count < 2) return []; + + HashSet pixelSet = [.. pixels]; + + List GetNeighbors(Point p) + { + List ns = []; + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) + { + if (dx == 0 && dy == 0) continue; + Point n = new(p.X + dx, p.Y + dy); + if (pixelSet.Contains(n)) ns.Add(n); + } + return ns; + } + + Dictionary> adj = new(pixels.Count); + foreach (Point p in pixels) + adj[p] = GetNeighbors(p); + + List endpoints = [.. pixels.Where(p => adj[p].Count == 1)]; + HashSet junctions = [.. pixels.Where(p => adj[p].Count >= 3)]; + + HashSet<(Point, Point)> used = []; + List> paths = []; + + void Follow(Point start, Point first) + { + if (used.Contains((start, first))) return; + + List path = [start, first]; + used.Add((start, first)); + used.Add((first, start)); + + Point prev = start; + Point cur = first; + + while (adj[cur].Count == 2) + { + Point next = default; + bool found = false; + foreach (Point n in adj[cur]) + { + if (n == prev || used.Contains((cur, n))) continue; + next = n; + found = true; + break; + } + if (!found) break; + + used.Add((cur, next)); + used.Add((next, cur)); + path.Add(next); + prev = cur; + cur = next; + } + + if (path.Count >= 3) + paths.Add(path); + } + + foreach (Point ep in endpoints) + foreach (Point n in adj[ep]) + Follow(ep, n); + + foreach (Point junc in junctions) + foreach (Point n in adj[junc]) + Follow(junc, n); + + // Catch isolated loops (all pixels degree 2, no endpoints or junctions) + foreach (Point p in pixels) + foreach (Point n in adj[p]) + if (!used.Contains((p, n))) + Follow(p, n); + + return paths; + } + + private static List SimplifyPath(List points, double epsilon) + { + if (points.Count <= 2) return points; + + using VectorOfPoint input = new([.. points]); + using VectorOfPoint approx = new(); + CvInvoke.ApproxPolyDP(input, approx, epsilon, false); + return [.. approx.ToArray()]; + } +} diff --git a/MagickCrop/Models/DraggingMode.cs b/MagickCrop/Models/DraggingMode.cs index d5561f5..47a4091 100644 --- a/MagickCrop/Models/DraggingMode.cs +++ b/MagickCrop/Models/DraggingMode.cs @@ -15,5 +15,7 @@ public enum DraggingMode WhitePointPicker, BlackPointPicker, EdgeCorrectionDragging, - GridStraightenDragging + GridStraightenDragging, + MarkupShape, + MarkupText } diff --git a/MagickCrop/Models/MarkupShapeType.cs b/MagickCrop/Models/MarkupShapeType.cs new file mode 100644 index 0000000..1e6333e --- /dev/null +++ b/MagickCrop/Models/MarkupShapeType.cs @@ -0,0 +1,9 @@ +namespace MagickCrop.Models; + +public enum MarkupShapeType +{ + Line, + Arrow, + Rectangle, + Ellipse +} diff --git a/MagickCrop/Models/MeasurementControls/MarkupShapeDto.cs b/MagickCrop/Models/MeasurementControls/MarkupShapeDto.cs new file mode 100644 index 0000000..97be5a9 --- /dev/null +++ b/MagickCrop/Models/MeasurementControls/MarkupShapeDto.cs @@ -0,0 +1,18 @@ +using MagickCrop.Models; +using System.Windows; + +namespace MagickCrop.Models.MeasurementControls; + +public class MarkupShapeDto : MeasurementControlDto +{ + public MarkupShapeDto() + { + Type = "MarkupShape"; + } + + public MarkupShapeType ShapeType { get; set; } + public Point Point1 { get; set; } + public Point Point2 { get; set; } + public string StrokeColor { get; set; } = "#FFFF0000"; + public double StrokeThickness { get; set; } = 3.0; +} diff --git a/MagickCrop/Models/MeasurementControls/MarkupStrokeDto.cs b/MagickCrop/Models/MeasurementControls/MarkupStrokeDto.cs new file mode 100644 index 0000000..12c903d --- /dev/null +++ b/MagickCrop/Models/MeasurementControls/MarkupStrokeDto.cs @@ -0,0 +1,38 @@ +using System.Windows; +using System.Windows.Ink; +using System.Windows.Input; +using System.Windows.Media; + +namespace MagickCrop.Models.MeasurementControls; + +public class MarkupStrokeDto +{ + public List Points { get; set; } = []; + public Color Color { get; set; } + public double Thickness { get; set; } + public bool IsHighlighter { get; set; } + + public static MarkupStrokeDto FromStroke(Stroke stroke) + { + return new MarkupStrokeDto + { + Points = [.. stroke.StylusPoints.Select(sp => new Point(sp.X, sp.Y))], + Color = stroke.DrawingAttributes.Color, + Thickness = stroke.DrawingAttributes.Width, + IsHighlighter = stroke.DrawingAttributes.IsHighlighter + }; + } + + public Stroke ToStroke() + { + StylusPointCollection stylusPoints = [.. Points.Select(p => new StylusPoint(p.X, p.Y))]; + DrawingAttributes attrs = new() + { + Color = Color, + Width = Thickness, + Height = Thickness, + IsHighlighter = IsHighlighter + }; + return new Stroke(stylusPoints, attrs); + } +} diff --git a/MagickCrop/Models/MeasurementControls/MarkupTextDto.cs b/MagickCrop/Models/MeasurementControls/MarkupTextDto.cs new file mode 100644 index 0000000..152b936 --- /dev/null +++ b/MagickCrop/Models/MeasurementControls/MarkupTextDto.cs @@ -0,0 +1,15 @@ +namespace MagickCrop.Models.MeasurementControls; + +public class MarkupTextDto : MeasurementControlDto +{ + public MarkupTextDto() + { + Type = "MarkupText"; + } + + public string Text { get; set; } = string.Empty; + public double PositionX { get; set; } + public double PositionY { get; set; } + public string TextColor { get; set; } = "#FFFF0000"; + public double FontSize { get; set; } = 16.0; +} diff --git a/MagickCrop/Models/MeasurementControls/MeasurementCollection.cs b/MagickCrop/Models/MeasurementControls/MeasurementCollection.cs index 101e40d..1ba3d9e 100644 --- a/MagickCrop/Models/MeasurementControls/MeasurementCollection.cs +++ b/MagickCrop/Models/MeasurementControls/MeasurementCollection.cs @@ -56,6 +56,21 @@ public class MeasurementCollection /// public List StrokeInfos { get; set; } = []; + /// + /// Collection of markup shape overlay controls + /// + public List MarkupShapes { get; set; } = []; + + /// + /// Collection of markup text annotation controls + /// + public List MarkupTexts { get; set; } = []; + + /// + /// Collection of markup canvas ink strokes (pen and highlighter) + /// + public List MarkupStrokes { get; set; } = []; + /// /// Global scale factor applied to all distance measurements /// diff --git a/MagickCrop/Models/UndoRedo.cs b/MagickCrop/Models/UndoRedo.cs index 1064286..84ade88 100644 --- a/MagickCrop/Models/UndoRedo.cs +++ b/MagickCrop/Models/UndoRedo.cs @@ -1,8 +1,11 @@ using ImageMagick; +using MagickCrop.Controls; +using System.Collections.ObjectModel; using System.ComponentModel; using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; +using System.Windows.Ink; using System.Windows.Media; namespace MagickCrop; @@ -102,6 +105,448 @@ public override string Redo() } } +public class MarkupShapeAddedItem : UndoRedoItem +{ + private readonly MarkupShapeControl _control; + private readonly ObservableCollection _collection; + private readonly Canvas _canvas; + private readonly Action _wireEvents; + private readonly Action _unwireEvents; + + public MarkupShapeAddedItem( + MarkupShapeControl control, + ObservableCollection collection, + Canvas canvas, + Action wireEvents, + Action unwireEvents) + { + _control = control; + _collection = collection; + _canvas = canvas; + _wireEvents = wireEvents; + _unwireEvents = unwireEvents; + } + + public override string Undo() + { + _unwireEvents(); + _collection.Remove(_control); + _canvas.Children.Remove(_control); + return string.Empty; + } + + public override string Redo() + { + _wireEvents(); + _collection.Add(_control); + _canvas.Children.Add(_control); + return string.Empty; + } +} + +public class MarkupShapePointMovedItem : UndoRedoItem +{ + private readonly MarkupShapeControl _control; + private readonly int _pointIndex; + private readonly Point _before; + private readonly Point _after; + + public MarkupShapePointMovedItem(MarkupShapeControl control, int pointIndex, Point before, Point after) + { + _control = control; + _pointIndex = pointIndex; + _before = before; + _after = after; + } + + public override string Undo() + { + _control.MovePoint(_pointIndex, _before); + return string.Empty; + } + + public override string Redo() + { + _control.MovePoint(_pointIndex, _after); + return string.Empty; + } +} + +public class MarkupTextAddedItem : UndoRedoItem +{ + private readonly MarkupTextControl _control; + private readonly ObservableCollection _collection; + private readonly Canvas _canvas; + private readonly Action _wireEvents; + private readonly Action _unwireEvents; + + public MarkupTextAddedItem( + MarkupTextControl control, + ObservableCollection collection, + Canvas canvas, + Action wireEvents, + Action unwireEvents) + { + _control = control; + _collection = collection; + _canvas = canvas; + _wireEvents = wireEvents; + _unwireEvents = unwireEvents; + } + + public override string Undo() + { + _unwireEvents(); + _collection.Remove(_control); + _canvas.Children.Remove(_control); + return string.Empty; + } + + public override string Redo() + { + _wireEvents(); + _collection.Add(_control); + _canvas.Children.Add(_control); + return string.Empty; + } +} + +public class MarkupStrokeAddedItem : UndoRedoItem +{ + private readonly InkCanvas _canvas; + private readonly Stroke _stroke; + + public MarkupStrokeAddedItem(InkCanvas canvas, Stroke stroke) + { + _canvas = canvas; + _stroke = stroke; + } + + public override string Undo() + { + _canvas.Strokes.Remove(_stroke); + return string.Empty; + } + + public override string Redo() + { + if (!_canvas.Strokes.Contains(_stroke)) + _canvas.Strokes.Add(_stroke); + return string.Empty; + } +} + +public class MarkupStrokeBatchAddedItem : UndoRedoItem +{ + private readonly InkCanvas _canvas; + private readonly List _strokes; + + public MarkupStrokeBatchAddedItem(InkCanvas canvas, List strokes) + { + _canvas = canvas; + _strokes = strokes; + } + + public override string Undo() + { + foreach (Stroke stroke in _strokes) + _canvas.Strokes.Remove(stroke); + return string.Empty; + } + + public override string Redo() + { + foreach (Stroke stroke in _strokes) + if (!_canvas.Strokes.Contains(stroke)) + _canvas.Strokes.Add(stroke); + return string.Empty; + } +} + +public class MarkupStrokeMovedItem : UndoRedoItem +{ + private readonly StrokeCollection _strokes; + private readonly double _deltaX; + private readonly double _deltaY; + + public MarkupStrokeMovedItem(StrokeCollection strokes, double deltaX, double deltaY) + { + _strokes = new StrokeCollection(strokes); + _deltaX = deltaX; + _deltaY = deltaY; + } + + public override string Undo() + { + Matrix m = new(); + m.Translate(-_deltaX, -_deltaY); + foreach (Stroke s in _strokes) + s.Transform(m, false); + return string.Empty; + } + + public override string Redo() + { + Matrix m = new(); + m.Translate(_deltaX, _deltaY); + foreach (Stroke s in _strokes) + s.Transform(m, false); + return string.Empty; + } +} + +public class MarkupStrokeDeletedItem : UndoRedoItem +{ + private readonly InkCanvas _canvas; + private readonly List _strokes; + + public MarkupStrokeDeletedItem(InkCanvas canvas, StrokeCollection strokes) + { + _canvas = canvas; + _strokes = [.. strokes]; + } + + public override string Undo() + { + foreach (Stroke s in _strokes) + if (!_canvas.Strokes.Contains(s)) + _canvas.Strokes.Add(s); + return string.Empty; + } + + public override string Redo() + { + foreach (Stroke s in _strokes) + _canvas.Strokes.Remove(s); + return string.Empty; + } +} + +public class MarkupStrokePropertiesChangedItem : UndoRedoItem +{ + private readonly List<(Stroke stroke, DrawingAttributes before, DrawingAttributes after)> _changes; + + public MarkupStrokePropertiesChangedItem(List<(Stroke, DrawingAttributes, DrawingAttributes)> changes) + { + _changes = changes; + } + + public override string Undo() + { + foreach (var (stroke, before, _) in _changes) + stroke.DrawingAttributes = before; + return string.Empty; + } + + public override string Redo() + { + foreach (var (stroke, _, after) in _changes) + stroke.DrawingAttributes = after; + return string.Empty; + } +} + +public class MarkupControlRemovedItem : UndoRedoItem where T : UIElement +{ + private readonly T _control; + private readonly ObservableCollection _collection; + private readonly Canvas _canvas; + private readonly Action _wireEvents; + private readonly Action _unwireEvents; + + public MarkupControlRemovedItem( + T control, + ObservableCollection collection, + Canvas canvas, + Action wireEvents, + Action unwireEvents) + { + _control = control; + _collection = collection; + _canvas = canvas; + _wireEvents = wireEvents; + _unwireEvents = unwireEvents; + } + + public override string Undo() + { + _wireEvents(); + _collection.Add(_control); + _canvas.Children.Add(_control); + return string.Empty; + } + + public override string Redo() + { + _unwireEvents(); + _collection.Remove(_control); + _canvas.Children.Remove(_control); + return string.Empty; + } +} + +public class MarkupTextMovedItem : UndoRedoItem +{ + private readonly MarkupTextControl _control; + private readonly Point _before; + private readonly Point _after; + + public MarkupTextMovedItem(MarkupTextControl control, Point before, Point after) + { + _control = control; + _before = before; + _after = after; + } + + public override string Undo() + { + Canvas.SetLeft(_control, _before.X); + Canvas.SetTop(_control, _before.Y); + return string.Empty; + } + + public override string Redo() + { + Canvas.SetLeft(_control, _after.X); + Canvas.SetTop(_control, _after.Y); + return string.Empty; + } +} + +public class MarkupTextChangedItem : UndoRedoItem +{ + private readonly MarkupTextControl _control; + private readonly string _before; + private readonly string _after; + + public MarkupTextChangedItem(MarkupTextControl control, string before, string after) + { + _control = control; + _before = before; + _after = after; + } + + public override string Undo() + { + _control.MarkupText = _before; + return string.Empty; + } + + public override string Redo() + { + _control.MarkupText = _after; + return string.Empty; + } +} + +public class MarkupStrokeResizedItem : UndoRedoItem +{ + private readonly StrokeCollection _strokes; + private readonly Rect _before; + private readonly Rect _after; + + public MarkupStrokeResizedItem(StrokeCollection strokes, Rect before, Rect after) + { + _strokes = new StrokeCollection(strokes); + _before = before; + _after = after; + } + + public override string Undo() + { + Apply(_after, _before); + return string.Empty; + } + + public override string Redo() + { + Apply(_before, _after); + return string.Empty; + } + + private void Apply(Rect from, Rect to) + { + Matrix m = new(); + m.Translate(-from.X, -from.Y); + m.Scale(to.Width / from.Width, to.Height / from.Height); + m.Translate(to.X, to.Y); + foreach (Stroke s in _strokes) + s.Transform(m, false); + } +} + +public class MarkupClearedItem : UndoRedoItem +{ + private readonly List _shapes; + private readonly List _texts; + private readonly List _strokes; + private readonly ObservableCollection _shapeCollection; + private readonly ObservableCollection _textCollection; + private readonly Canvas _canvas; + private readonly InkCanvas _inkCanvas; + private readonly Action _wireEvents; + private readonly Action _unwireEvents; + + public MarkupClearedItem( + List shapes, + List texts, + List strokes, + ObservableCollection shapeCollection, + ObservableCollection textCollection, + Canvas canvas, + InkCanvas inkCanvas, + Action wireEvents, + Action unwireEvents) + { + _shapes = shapes; + _texts = texts; + _strokes = strokes; + _shapeCollection = shapeCollection; + _textCollection = textCollection; + _canvas = canvas; + _inkCanvas = inkCanvas; + _wireEvents = wireEvents; + _unwireEvents = unwireEvents; + } + + public override string Undo() + { + _wireEvents(); + foreach (MarkupShapeControl shape in _shapes) + { + _shapeCollection.Add(shape); + _canvas.Children.Add(shape); + } + foreach (MarkupTextControl text in _texts) + { + _textCollection.Add(text); + _canvas.Children.Add(text); + } + foreach (Stroke stroke in _strokes) + if (!_inkCanvas.Strokes.Contains(stroke)) + _inkCanvas.Strokes.Add(stroke); + return string.Empty; + } + + public override string Redo() + { + _unwireEvents(); + foreach (MarkupShapeControl shape in _shapes) + { + _shapeCollection.Remove(shape); + _canvas.Children.Remove(shape); + } + foreach (MarkupTextControl text in _texts) + { + _textCollection.Remove(text); + _canvas.Children.Remove(text); + } + foreach (Stroke stroke in _strokes) + _inkCanvas.Strokes.Remove(stroke); + return string.Empty; + } +} + public class ResizeUndoRedoItem : UndoRedoItem { private readonly Image _image; From a00e5df64d305c739c53b6829bffa5c250b4455d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Jul 2026 21:08:15 -0500 Subject: [PATCH 04/11] Add Markup tab and threshold panel to MainWindow Markup tab: pen/highlighter/eraser/select ink tools on an InkCanvas, shape and text placement, 10-color palette and size slider, whiteboard cleanup / fill hollow strokes / convert-to-ink actions, hide-all and clear-all. All markup operations are undoable and persist with the project package. Threshold panel: RGB histogram with live threshold indicator bound to the ViewModel ApplyThreshold command. Co-Authored-By: Claude Fable 5 --- MagickCrop/MainWindow.xaml | 435 ++++++++++++++++- MagickCrop/MainWindow.xaml.cs | 888 +++++++++++++++++++++++++++++++++- 2 files changed, 1307 insertions(+), 16 deletions(-) diff --git a/MagickCrop/MainWindow.xaml b/MagickCrop/MainWindow.xaml index 1bdc377..c7255e2 100644 --- a/MagickCrop/MainWindow.xaml +++ b/MagickCrop/MainWindow.xaml @@ -121,6 +121,17 @@ IsEnabled="False" IsHitTestVisible="False" Visibility="Collapsed" /> + + Visibility="{Binding HasOpenedFileName, + Converter={StaticResource BoolToVis}}" /> - + @@ -595,6 +607,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1032,7 +1456,9 @@ Visibility="Collapsed"> @@ -1725,7 +2151,8 @@ Foreground="#0066FF" IsIndeterminate="True" ToolTip="Processing..." - Visibility="{Binding IsBusy, Converter={StaticResource BoolToVis}}" /> + Visibility="{Binding IsBusy, + Converter={StaticResource BoolToVis}}" /> verticalLineControls = []; private readonly ObservableCollection horizontalLineControls = []; + // --- Markup state --- + private readonly ObservableCollection markupShapeControls = []; + private MarkupShapeControl? activeMarkupShapeControl; + private readonly ObservableCollection markupTextControls = []; + private System.Windows.Media.Color markupColor = System.Windows.Media.Colors.Red; + private double markupSize = 3.0; + private bool isMarkupPenMode = false; + private bool isMarkupHighlighterMode = false; + private bool isMarkupSelectMode = false; + private bool isMarkupShapeMode = false; + private bool isMarkupTextMode = false; + private Rect? _selectionBoundsBeforeMove = null; + private StrokeCollection? _strokesBeforeMove = null; + private Rect? _selectionBoundsBeforeResize = null; + private StrokeCollection? _strokesBeforeResize = null; + private MagickCrop.Models.MarkupShapeType activeMarkupShapeType = MagickCrop.Models.MarkupShapeType.Rectangle; + private bool isMarkupShapeDragCreation = false; + private Point markupShapeBeforePoint1; + private Point markupShapeBeforePoint2; + private int markupShapeBeforeDragIndex = -1; + private Services.RecentProjectsManager? recentProjectsManager; private System.Timers.Timer? autoSaveTimer; private readonly int AutoSaveIntervalMs = (int)TimeSpan.FromSeconds(5).TotalMilliseconds; @@ -139,7 +161,7 @@ void IMainWindowView.SetBusy(bool busy) private bool isFreeRotatingDrag = false; // runtime reference to angle overlay - private WpfTextBlock? rotationOverlayLabel; + private readonly WpfTextBlock? rotationOverlayLabel; private long lastRotateUpdateTicks = 0; private double lastAppliedAdornerAngle = 0.0; private const int RotateUpdateMinIntervalMs = 12; // throttle to reduce UI thrash @@ -197,6 +219,9 @@ public MainWindow() PreviewMouseWheel += ShapeCanvas_PreviewMouseWheel; DrawPolyLine(); + + lines ??= new(); + _polygonElements = [lines, TopLeft, TopRight, BottomRight, BottomLeft]; foreach (UIElement element in _polygonElements) @@ -458,6 +483,43 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e) gridStraightenDragIndex = -1; } + if (draggingMode == DraggingMode.MarkupShape && activeMarkupShapeControl is not null) + { + if (isMarkupShapeDragCreation) + { + // New shape added — record undo for the whole addition + MarkupShapeControl ctrl = activeMarkupShapeControl; + UndoRedo.AddUndo(new MarkupShapeAddedItem( + ctrl, markupShapeControls, ShapeCanvas, + wireEvents: () => + { + ctrl.MeasurementPointMouseDown += MarkupShapePoint_MouseDown; + ctrl.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested; + }, + unwireEvents: () => + { + ctrl.MeasurementPointMouseDown -= MarkupShapePoint_MouseDown; + ctrl.RemoveControlRequested -= MarkupShapeControl_RemoveControlRequested; + })); + } + else if (markupShapeBeforeDragIndex >= 0) + { + // Existing handle dragged — record undo for the point move + var (afterP1, afterP2) = activeMarkupShapeControl.GetPoints(); + Point before = markupShapeBeforeDragIndex == 0 ? markupShapeBeforePoint1 : markupShapeBeforePoint2; + Point after = markupShapeBeforeDragIndex == 0 ? afterP1 : afterP2; + if (before != after) + { + MarkupShapeControl ctrl = activeMarkupShapeControl; + UndoRedo.AddUndo(new MarkupShapePointMovedItem(ctrl, markupShapeBeforeDragIndex, before, after)); + } + } + + activeMarkupShapeControl.ResetActivePoint(); + activeMarkupShapeControl = null; + isMarkupShapeDragCreation = false; + } + clickedElement = null; ReleaseMouseCapture(); draggingMode = DraggingMode.None; @@ -549,6 +611,15 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e) return; } + if (draggingMode == DraggingMode.MarkupShape && activeMarkupShapeControl is not null) + { + int idx = activeMarkupShapeControl.GetActivePointIndex(); + if (idx >= 0) + activeMarkupShapeControl.MovePoint(idx, movingPoint); + e.Handled = true; + return; + } + if (draggingMode != DraggingMode.MoveElement || clickedElement is null) return; @@ -1167,13 +1238,13 @@ private async void CameraButton_Click(object sender, RoutedEventArgs e) SetUiForLongTask(); WelcomeMessageModal.Visibility = Visibility.Collapsed; - var hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle; - var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(hwnd); + nint hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle; + Microsoft.UI.WindowId windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(hwnd); - var cameraCaptureUI = new Microsoft.Windows.Media.Capture.CameraCaptureUI(windowId); - cameraCaptureUI.PhotoSettings.Format = Microsoft.Windows.Media.Capture.CameraCaptureUIPhotoFormat.Png; + CameraCaptureUI cameraCaptureUI = new(windowId); + cameraCaptureUI.PhotoSettings.Format = CameraCaptureUIPhotoFormat.Png; - var file = await cameraCaptureUI.CaptureFileAsync(Microsoft.Windows.Media.Capture.CameraCaptureUIMode.Photo); + StorageFile file = await cameraCaptureUI.CaptureFileAsync(CameraCaptureUIMode.Photo); if (file != null) { @@ -1448,6 +1519,15 @@ private void ShapeCanvas_MouseDown(object sender, MouseButtonEventArgs e) clickedPoint = e.GetPosition(ShapeCanvas); + // A markup text edit in progress absorbs this click: commit it instead of + // starting a new tool action (otherwise clicking away to accept the text + // would immediately place another text box) + if (CommitPendingMarkupTextEdit()) + { + e.Handled = true; + return; + } + // --- ANGLE MEASUREMENT PLACEMENT LOGIC --- if (isPlacingAngleMeasurement && anglePlacementStep == AnglePlacementStep.PlacingThirdPoint && activeAnglePlacementControl != null) { @@ -1468,6 +1548,73 @@ private void ShapeCanvas_MouseDown(object sender, MouseButtonEventArgs e) return; } + // --- MARKUP SHAPE PLACEMENT --- + if (isMarkupShapeMode && e.LeftButton == MouseButtonState.Pressed) + { + MarkupShapeControl shapeControl = new() + { + ShapeType = activeMarkupShapeType, + StrokeColor = markupColor, + StrokeThickness = markupSize + }; + shapeControl.MeasurementPointMouseDown += MarkupShapePoint_MouseDown; + shapeControl.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested; + markupShapeControls.Add(shapeControl); + ShapeCanvas.Children.Add(shapeControl); + shapeControl.MovePoint(0, clickedPoint); + shapeControl.StartDraggingPoint(1); // fires MarkupShapePoint_MouseDown → CaptureMouse + isMarkupShapeDragCreation = true; // distinguishes creation from handle move for undo + draggingMode = DraggingMode.MarkupShape; + e.Handled = true; + return; + } + + // --- MARKUP TEXT PLACEMENT --- + if (isMarkupTextMode && e.LeftButton == MouseButtonState.Pressed) + { + MarkupTextControl textControl = new() + { + TextColor = markupColor, + MarkupFontSize = markupSize * 4 + }; + textControl.RemoveControlRequested += MarkupTextControl_RemoveControlRequested; + Canvas.SetLeft(textControl, clickedPoint.X); + Canvas.SetTop(textControl, clickedPoint.Y); + markupTextControls.Add(textControl); + ShapeCanvas.Children.Add(textControl); + + // Push the undo item only once the initial edit is committed; cancelling + // (Escape, or committing empty text) discards the control entirely + MarkupTextControl ctrl = textControl; + void OnFirstCommit(object? s, EventArgs args) + { + ctrl.EditCommitted -= OnFirstCommit; + ctrl.EditCancelled -= OnFirstCancel; + UndoRedo.AddUndo(new MarkupTextAddedItem( + ctrl, markupTextControls, ShapeCanvas, + wireEvents: () => ctrl.RemoveControlRequested += MarkupTextControl_RemoveControlRequested, + unwireEvents: () => ctrl.RemoveControlRequested -= MarkupTextControl_RemoveControlRequested)); + + // From now on, edits and drags of this label get their own undo items + ctrl.EditCommitted += MarkupTextControl_EditCommitted; + ctrl.TextMoved += MarkupTextControl_TextMoved; + } + void OnFirstCancel(object? s, EventArgs args) + { + ctrl.EditCommitted -= OnFirstCommit; + ctrl.EditCancelled -= OnFirstCancel; + ctrl.RemoveControlRequested -= MarkupTextControl_RemoveControlRequested; + markupTextControls.Remove(ctrl); + ShapeCanvas.Children.Remove(ctrl); + } + ctrl.EditCommitted += OnFirstCommit; + ctrl.EditCancelled += OnFirstCancel; + + textControl.EnterEditMode(); + e.Handled = true; + return; + } + if (MeasureDistanceToggle.IsChecked is true) { double scale = ScaleInput.Value ?? 1.0; @@ -3903,6 +4050,7 @@ private void ShowObjectEraseControls() HideEdgeCorrectionControls(); HideGridStraightenControls(); + HideThresholdControls(); isObjectEraseMode = true; ObjectEraseButtonPanel.Visibility = Visibility.Visible; @@ -3933,6 +4081,38 @@ private void HideObjectEraseControls() EraseMaskCanvas.IsHitTestVisible = false; } + private void ThresholdMenuItem_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrWhiteSpace(ViewModel.ImagePath)) + return; + + ShowThresholdControls(); + } + + private void ShowThresholdControls() + { + HideObjectEraseControls(); + ThresholdPanel.Visibility = Visibility.Visible; + EditHistogram.IsThresholdActive = true; + } + + private void HideThresholdControls() + { + ThresholdPanel.Visibility = Visibility.Collapsed; + EditHistogram.IsThresholdActive = false; + } + + private async void ApplyThresholdButton_Click(object sender, RoutedEventArgs e) + { + await ViewModel.ApplyThresholdCommand.ExecuteAsync(null); + HideThresholdControls(); + } + + private void CancelThresholdButton_Click(object sender, RoutedEventArgs e) + { + HideThresholdControls(); + } + private void EraseBrushSizeSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) { if (EraseMaskCanvas is null) @@ -4117,7 +4297,6 @@ private async void MeasurementControl_SetRealWorldLengthRequested(object sender, }; // Show the dialog and handle the result - ContentDialogService dialogService = new(); dialog.DialogHost = Presenter; dialog.Closing += (s, args) => { @@ -4205,6 +4384,7 @@ private void RemoveMeasurementControls() horizontalLineControls.Clear(); ClearAllStrokesAndLengths(); + ClearAllMarkup(); draggingMode = DraggingMode.None; } @@ -4213,7 +4393,7 @@ private bool HandleMeasurementMouseDown(object sender, MouseButtonEventArgs? { if (isAdornerRotatingDrag) { - if (e is not null) e.Handled = true; + e?.Handled = true; return false; } if (sender is Ellipse senderEllipse @@ -4248,6 +4428,20 @@ private void PolygonMeasurementPoint_MouseDown(object sender, MouseButtonEventAr private void CircleMeasurementPoint_MouseDown(object sender, MouseButtonEventArgs e) => HandleMeasurementMouseDown(sender, e, DraggingMode.MeasureCircle, c => activeCircleMeasureControl = c); + private void MarkupShapePoint_MouseDown(object sender, MouseButtonEventArgs? e) + { + if (HandleMeasurementMouseDown(sender, e, DraggingMode.MarkupShape, c => activeMarkupShapeControl = c)) + { + // Capture before-state for point-move undo (overridden to true by creation caller if needed) + isMarkupShapeDragCreation = false; + if (activeMarkupShapeControl is not null) + { + (markupShapeBeforePoint1, markupShapeBeforePoint2) = activeMarkupShapeControl.GetPoints(); + markupShapeBeforeDragIndex = activeMarkupShapeControl.GetActivePointIndex(); + } + } + } + private async void SetImageScaleButton_Click(object sender, RoutedEventArgs e) { if (MainImage.Source is not BitmapSource bitmap) @@ -4551,6 +4745,15 @@ private MagickCropMeasurementPackage BuildCurrentPackage(PackageMetadata? metada package.Measurements.StrokeInfos.Add(StrokeInfoDto.FromStrokeInfo(info, displayX, displayY)); } + foreach (MarkupShapeControl control in markupShapeControls) + package.Measurements.MarkupShapes.Add(control.ToDto()); + + foreach (MarkupTextControl control in markupTextControls) + package.Measurements.MarkupTexts.Add(control.ToDto()); + + foreach (Stroke stroke in MarkupCanvas.Strokes) + package.Measurements.MarkupStrokes.Add(MarkupStrokeDto.FromStroke(stroke)); + return package; } @@ -4818,6 +5021,36 @@ private async Task LoadMeasurementPackageAsync(string fileName) strokeMeasurements.Add(stroke, infoDto.ToStrokeInfo()); } + // Restore markup shapes + foreach (MarkupShapeDto dto in package.Measurements.MarkupShapes) + { + MarkupShapeControl control = new(); + control.FromDto(dto); + control.MeasurementPointMouseDown += MarkupShapePoint_MouseDown; + control.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested; + markupShapeControls.Add(control); + ShapeCanvas.Children.Add(control); + } + + // Restore markup text annotations + foreach (MarkupTextDto dto in package.Measurements.MarkupTexts) + { + MarkupTextControl control = new(); + control.FromDto(dto); + control.RemoveControlRequested += MarkupTextControl_RemoveControlRequested; + control.EditCommitted += MarkupTextControl_EditCommitted; + control.TextMoved += MarkupTextControl_TextMoved; + Canvas.SetLeft(control, dto.PositionX); + Canvas.SetTop(control, dto.PositionY); + markupTextControls.Add(control); + ShapeCanvas.Children.Add(control); + } + + // Restore markup canvas strokes + MarkupCanvas.Strokes.Clear(); + foreach (MarkupStrokeDto dto in package.Measurements.MarkupStrokes) + MarkupCanvas.Strokes.Add(dto.ToStroke()); + if (package?.Metadata?.ProjectId is not null) ViewModel.CurrentProjectId = package.Metadata.ProjectId; else @@ -5082,6 +5315,7 @@ private void ResetApplicationState() HideCroppingControls(); HideResizeControls(); HideObjectEraseControls(); + HideThresholdControls(); BottomBorder.Visibility = Visibility.Collapsed; WelcomeMessageModal.Visibility = Visibility.Visible; OpenFolderButton.IsEnabled = false; @@ -5464,8 +5698,12 @@ private void FluentWindow_PreviewKeyDown(object sender, KeyEventArgs e) } } + // While a text box has keyboard focus (e.g. editing a markup text), + // leave Ctrl+Z/Ctrl+Y to the text box's own undo + bool typingInTextBox = Keyboard.FocusedElement is System.Windows.Controls.Primitives.TextBoxBase; + // Handle Ctrl+Z for undo - if ((Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control && e.Key == Key.Z) + if (!typingInTextBox && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control && e.Key == Key.Z) { if (UndoRedo.CanUndo) { @@ -5476,7 +5714,7 @@ private void FluentWindow_PreviewKeyDown(object sender, KeyEventArgs e) } // Handle Ctrl+Y for redo - if ((Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control && e.Key == Key.Y) + if (!typingInTextBox && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control && e.Key == Key.Y) { if (UndoRedo.CanRedo) { @@ -5486,8 +5724,31 @@ private void FluentWindow_PreviewKeyDown(object sender, KeyEventArgs e) } } + // Handle Delete key for selected markup ink strokes + if (e.Key == Key.Delete && isMarkupSelectMode) + { + StrokeCollection selected = MarkupCanvas.GetSelectedStrokes(); + if (selected.Count > 0) + { + DeleteSelectedMarkupStrokes(); + e.Handled = true; + return; + } + } + if (e.Key == Key.Escape) { + // Escape while editing a markup text cancels just that edit + foreach (MarkupTextControl control in markupTextControls.ToList()) + { + if (control.IsEditing) + { + control.CancelEdit(); + e.Handled = true; + return; + } + } + UncheckAllBut(); // Cancel white point picker mode @@ -6112,6 +6373,609 @@ DraggingMode.MeasurePolygon or } #endregion Pixel Precision Zoom + + #region Markup Tab + + private void ToolsTabControl_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (e.Source is not TabControl) + return; + + bool markupTabActive = MarkupTabItem?.IsSelected == true; + if (!markupTabActive) + DeactivateAllMarkupTools(); + } + + private void DeactivateAllMarkupTools() + { + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupShapeMode = false; + isMarkupTextMode = false; + if (MarkupCanvas is not null) + { + MarkupCanvas.IsEnabled = false; + MarkupCanvas.IsHitTestVisible = false; + } + UncheckMarkupAllBut(); + } + + private void UncheckMarkupAllBut(ToggleButton? keep = null) + { + if (MarkupToolsPanel is null || MarkupShapeToolsPanel is null) return; + foreach (ToggleButton btn in MarkupToolsPanel.Children.OfType()) + if (btn != keep) btn.IsChecked = false; + foreach (ToggleButton btn in MarkupShapeToolsPanel.Children.OfType()) + if (btn != keep) btn.IsChecked = false; + } + + private void UpdateMarkupCanvasForPen() + { + MarkupCanvas.IsEnabled = true; + MarkupCanvas.IsHitTestVisible = true; + MarkupCanvas.EditingMode = InkCanvasEditingMode.Ink; + DrawingAttributes attrs = new() + { + Color = markupColor, + Width = markupSize, + Height = markupSize, + IsHighlighter = false, + StylusTip = StylusTip.Ellipse + }; + MarkupCanvas.DefaultDrawingAttributes = attrs; + } + + private void UpdateMarkupCanvasForHighlighter() + { + MarkupCanvas.IsEnabled = true; + MarkupCanvas.IsHitTestVisible = true; + MarkupCanvas.EditingMode = InkCanvasEditingMode.Ink; + System.Windows.Media.Color highlightColor = markupColor; + highlightColor.A = 100; + DrawingAttributes attrs = new() + { + Color = highlightColor, + Width = markupSize * 6, + Height = markupSize * 6, + IsHighlighter = true, + StylusTip = StylusTip.Rectangle + }; + MarkupCanvas.DefaultDrawingAttributes = attrs; + } + + private void DisableMarkupCanvas() + { + MarkupCanvas.IsEnabled = false; + MarkupCanvas.IsHitTestVisible = false; + } + + private void MarkupPenToggle_Checked(object sender, RoutedEventArgs e) + { + isMarkupPenMode = true; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupShapeMode = false; + isMarkupTextMode = false; + UpdateMarkupCanvasForPen(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupHighlighterToggle_Checked(object sender, RoutedEventArgs e) + { + isMarkupHighlighterMode = true; + isMarkupPenMode = false; + isMarkupSelectMode = false; + isMarkupShapeMode = false; + isMarkupTextMode = false; + UpdateMarkupCanvasForHighlighter(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupEraserToggle_Checked(object sender, RoutedEventArgs e) + { + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupShapeMode = false; + isMarkupTextMode = false; + MarkupCanvas.IsEnabled = true; + MarkupCanvas.IsHitTestVisible = true; + MarkupCanvas.EditingMode = InkCanvasEditingMode.EraseByStroke; + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupSelectToggle_Checked(object sender, RoutedEventArgs e) + { + isMarkupSelectMode = true; + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupShapeMode = false; + isMarkupTextMode = false; + MarkupCanvas.IsEnabled = true; + MarkupCanvas.IsHitTestVisible = true; + MarkupCanvas.EditingMode = InkCanvasEditingMode.Select; + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupLineToggle_Checked(object sender, RoutedEventArgs e) + { + activeMarkupShapeType = MagickCrop.Models.MarkupShapeType.Line; + isMarkupShapeMode = true; + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupTextMode = false; + DisableMarkupCanvas(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupArrowToggle_Checked(object sender, RoutedEventArgs e) + { + activeMarkupShapeType = MagickCrop.Models.MarkupShapeType.Arrow; + isMarkupShapeMode = true; + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupTextMode = false; + DisableMarkupCanvas(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupRectangleToggle_Checked(object sender, RoutedEventArgs e) + { + activeMarkupShapeType = MagickCrop.Models.MarkupShapeType.Rectangle; + isMarkupShapeMode = true; + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupTextMode = false; + DisableMarkupCanvas(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupEllipseToggle_Checked(object sender, RoutedEventArgs e) + { + activeMarkupShapeType = MagickCrop.Models.MarkupShapeType.Ellipse; + isMarkupShapeMode = true; + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupTextMode = false; + DisableMarkupCanvas(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupTextToggle_Checked(object sender, RoutedEventArgs e) + { + isMarkupTextMode = true; + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupShapeMode = false; + DisableMarkupCanvas(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupToolToggle_Clicked(object sender, RoutedEventArgs e) + { + if (sender is not ToggleButton toggle || toggle.IsChecked is true) + return; + + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupShapeMode = false; + isMarkupTextMode = false; + DisableMarkupCanvas(); + draggingMode = DraggingMode.None; + } + + private void MarkupToolToggle_Unchecked(object sender, RoutedEventArgs e) + { + // Handled by MarkupToolToggle_Clicked + } + + private void MarkupColorButton_Checked(object sender, RoutedEventArgs e) + { + if (sender is not ToggleButton btn || btn.Tag is not string colorName) + return; + + // Uncheck other color buttons + foreach (ToggleButton other in MarkupColorPalette.Children.OfType()) + if (other != btn) other.IsChecked = false; + + try + { + markupColor = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(colorName); + } + catch + { + markupColor = System.Windows.Media.Colors.Red; + } + + // Apply color to active ink tool immediately + if (isMarkupPenMode) UpdateMarkupCanvasForPen(); + else if (isMarkupHighlighterMode) UpdateMarkupCanvasForHighlighter(); + else if (isMarkupSelectMode) ApplyColorToSelectedStrokes(); + } + + private void MarkupSizeSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + markupSize = e.NewValue; + + if (isMarkupPenMode) UpdateMarkupCanvasForPen(); + else if (isMarkupHighlighterMode) UpdateMarkupCanvasForHighlighter(); + else if (isMarkupSelectMode) ApplySizeToSelectedStrokes(); + } + + private void ApplyColorToSelectedStrokes() + { + StrokeCollection selected = MarkupCanvas.GetSelectedStrokes(); + if (selected.Count == 0) return; + + List<(Stroke, DrawingAttributes, DrawingAttributes)> changes = []; + foreach (Stroke stroke in selected) + { + DrawingAttributes before = stroke.DrawingAttributes.Clone(); + System.Windows.Media.Color color = markupColor; + if (stroke.DrawingAttributes.IsHighlighter) + color.A = 100; + DrawingAttributes after = stroke.DrawingAttributes.Clone(); + after.Color = color; + stroke.DrawingAttributes = after; + changes.Add((stroke, before, after)); + } + UndoRedo.AddUndo(new MarkupStrokePropertiesChangedItem(changes)); + } + + private void ApplySizeToSelectedStrokes() + { + StrokeCollection selected = MarkupCanvas.GetSelectedStrokes(); + if (selected.Count == 0) return; + + List<(Stroke, DrawingAttributes, DrawingAttributes)> changes = []; + foreach (Stroke stroke in selected) + { + DrawingAttributes before = stroke.DrawingAttributes.Clone(); + double size = stroke.DrawingAttributes.IsHighlighter ? markupSize * 6 : markupSize; + DrawingAttributes after = stroke.DrawingAttributes.Clone(); + after.Width = size; + after.Height = size; + stroke.DrawingAttributes = after; + changes.Add((stroke, before, after)); + } + UndoRedo.AddUndo(new MarkupStrokePropertiesChangedItem(changes)); + } + + private void MarkupCanvas_StrokeCollected(object sender, InkCanvasStrokeCollectedEventArgs e) + { + UndoRedo.AddUndo(new MarkupStrokeAddedItem(MarkupCanvas, e.Stroke)); + } + + private void MarkupCanvas_StrokeErasing(object sender, InkCanvasStrokeErasingEventArgs e) + { + UndoRedo.AddUndo(new MarkupStrokeDeletedItem(MarkupCanvas, [e.Stroke])); + } + + private void MarkupCanvas_SelectionMoving(object sender, InkCanvasSelectionEditingEventArgs e) + { + _selectionBoundsBeforeMove = e.OldRectangle; + _strokesBeforeMove = new StrokeCollection(MarkupCanvas.GetSelectedStrokes()); + } + + private void MarkupCanvas_SelectionMoved(object sender, EventArgs e) + { + if (_strokesBeforeMove is null || _selectionBoundsBeforeMove is null) return; + + Rect newBounds = MarkupCanvas.GetSelectionBounds(); + double deltaX = newBounds.X - _selectionBoundsBeforeMove.Value.X; + double deltaY = newBounds.Y - _selectionBoundsBeforeMove.Value.Y; + + if (Math.Abs(deltaX) > 0.01 || Math.Abs(deltaY) > 0.01) + UndoRedo.AddUndo(new MarkupStrokeMovedItem(_strokesBeforeMove, deltaX, deltaY)); + + _strokesBeforeMove = null; + _selectionBoundsBeforeMove = null; + } + + private void MarkupCanvas_SelectionResizing(object sender, InkCanvasSelectionEditingEventArgs e) + { + _selectionBoundsBeforeResize = e.OldRectangle; + _strokesBeforeResize = new StrokeCollection(MarkupCanvas.GetSelectedStrokes()); + } + + private void MarkupCanvas_SelectionResized(object sender, EventArgs e) + { + if (_strokesBeforeResize is null || _selectionBoundsBeforeResize is null) return; + + Rect oldBounds = _selectionBoundsBeforeResize.Value; + Rect newBounds = MarkupCanvas.GetSelectionBounds(); + + if (oldBounds.Width > 0 && oldBounds.Height > 0 + && (Math.Abs(newBounds.X - oldBounds.X) > 0.01 + || Math.Abs(newBounds.Y - oldBounds.Y) > 0.01 + || Math.Abs(newBounds.Width - oldBounds.Width) > 0.01 + || Math.Abs(newBounds.Height - oldBounds.Height) > 0.01)) + { + UndoRedo.AddUndo(new MarkupStrokeResizedItem(_strokesBeforeResize, oldBounds, newBounds)); + } + + _strokesBeforeResize = null; + _selectionBoundsBeforeResize = null; + } + + private void DeleteSelectedMarkupStrokes() + { + StrokeCollection selected = MarkupCanvas.GetSelectedStrokes(); + if (selected.Count == 0) return; + UndoRedo.AddUndo(new MarkupStrokeDeletedItem(MarkupCanvas, selected)); + foreach (Stroke stroke in selected.ToList()) + MarkupCanvas.Strokes.Remove(stroke); + } + + private async void FillHollowStrokesButton_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrWhiteSpace(ViewModel.ImagePath)) return; + + FillHollowStrokesButton.IsEnabled = false; + FillHollowStrokesProgressRing.Visibility = Visibility.Visible; + + try + { + string? resultPath = await WhiteboardInkConverter.FillHollowStrokesAsync(ViewModel.ImagePath); + + if (resultPath is null) + { + System.Windows.MessageBox.Show( + "No hollow stroke interiors were found in the image.", + "Fill Hollow Strokes", + System.Windows.MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + MagickImageUndoRedoItem undoRedoItem = new(MainImage, ViewModel.ImagePath, resultPath); + UndoRedo.AddUndo(undoRedoItem); + + ViewModel.ImagePath = resultPath; + using MagickImage resultImage = new(resultPath); + MainImage.Source = resultImage.ToBitmapSource(); + ViewModel.ActualImageSize = new Size(resultImage.Width, resultImage.Height); + } + catch (Exception ex) + { + System.Windows.MessageBox.Show( + $"Fill Hollow Strokes failed: {ex.Message}", + "Error", + System.Windows.MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + FillHollowStrokesButton.IsEnabled = true; + FillHollowStrokesProgressRing.Visibility = Visibility.Collapsed; + } + } + + private async void ConvertToInkButton_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrEmpty(ViewModel.ImagePath)) return; + + ConvertToInkButton.IsEnabled = false; + ConvertToInkProgressRing.Visibility = Visibility.Visible; + + try + { + List strokes = await WhiteboardInkConverter.ConvertToStrokesAsync( + ViewModel.ImagePath, + MarkupCanvas.ActualWidth, + MarkupCanvas.ActualHeight); + + if (strokes.Count == 0) return; + + foreach (Stroke stroke in strokes) + MarkupCanvas.Strokes.Add(stroke); + + UndoRedo.AddUndo(new MarkupStrokeBatchAddedItem(MarkupCanvas, strokes)); + } + finally + { + ConvertToInkButton.IsEnabled = true; + ConvertToInkProgressRing.Visibility = Visibility.Collapsed; + } + } + + private async void CleanWhiteboardButton_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrWhiteSpace(ViewModel.ImagePath)) return; + + CleanWhiteboardButton.IsEnabled = false; + CleanWhiteboardProgressRing.Visibility = Visibility.Visible; + + try + { + string? resultPath = await WhiteboardInkConverter.RemoveSpecklesAsync(ViewModel.ImagePath); + + if (resultPath is null) + { + System.Windows.MessageBox.Show( + "No small speckles were found in the image.", + "Clean Whiteboard", + System.Windows.MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + MagickImageUndoRedoItem undoRedoItem = new(MainImage, ViewModel.ImagePath, resultPath); + UndoRedo.AddUndo(undoRedoItem); + + ViewModel.ImagePath = resultPath; + using MagickImage resultImage = new(resultPath); + MainImage.Source = resultImage.ToBitmapSource(); + ViewModel.ActualImageSize = new Size(resultImage.Width, resultImage.Height); + } + catch (Exception ex) + { + System.Windows.MessageBox.Show( + $"Clean Whiteboard failed: {ex.Message}", + "Error", + System.Windows.MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + CleanWhiteboardButton.IsEnabled = true; + CleanWhiteboardProgressRing.Visibility = Visibility.Collapsed; + } + } + + private void MarkupShapeControl_RemoveControlRequested(object sender, EventArgs e) + { + if (sender is not MarkupShapeControl control) return; + control.MeasurementPointMouseDown -= MarkupShapePoint_MouseDown; + control.RemoveControlRequested -= MarkupShapeControl_RemoveControlRequested; + markupShapeControls.Remove(control); + ShapeCanvas.Children.Remove(control); + + UndoRedo.AddUndo(new MarkupControlRemovedItem( + control, markupShapeControls, ShapeCanvas, + wireEvents: () => + { + control.MeasurementPointMouseDown += MarkupShapePoint_MouseDown; + control.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested; + }, + unwireEvents: () => + { + control.MeasurementPointMouseDown -= MarkupShapePoint_MouseDown; + control.RemoveControlRequested -= MarkupShapeControl_RemoveControlRequested; + })); + } + + private void MarkupTextControl_EditCommitted(object? sender, EventArgs e) + { + if (sender is not MarkupTextControl control) return; + if (control.TextBeforeEdit != control.MarkupText) + UndoRedo.AddUndo(new MarkupTextChangedItem(control, control.TextBeforeEdit, control.MarkupText)); + } + + private void MarkupTextControl_TextMoved(object sender, Point before, Point after) + { + if (sender is not MarkupTextControl control) return; + UndoRedo.AddUndo(new MarkupTextMovedItem(control, before, after)); + } + + /// + /// Commits any markup text control still in edit mode. Returns true if one was open. + /// + private bool CommitPendingMarkupTextEdit() + { + bool committed = false; + // Committing empty text cancels the edit, which can remove the control + // from the collection — iterate over a copy + foreach (MarkupTextControl control in markupTextControls.ToList()) + { + if (control.IsEditing) + { + control.CommitEdit(); + committed = true; + } + } + + return committed; + } + + private void MarkupTextControl_RemoveControlRequested(object sender, EventArgs e) + { + if (sender is not MarkupTextControl control) return; + control.RemoveControlRequested -= MarkupTextControl_RemoveControlRequested; + markupTextControls.Remove(control); + ShapeCanvas.Children.Remove(control); + + UndoRedo.AddUndo(new MarkupControlRemovedItem( + control, markupTextControls, ShapeCanvas, + wireEvents: () => control.RemoveControlRequested += MarkupTextControl_RemoveControlRequested, + unwireEvents: () => control.RemoveControlRequested -= MarkupTextControl_RemoveControlRequested)); + } + + private void ClearAllMarkup() + { + foreach (MarkupShapeControl control in markupShapeControls.ToList()) + { + control.MeasurementPointMouseDown -= MarkupShapePoint_MouseDown; + control.RemoveControlRequested -= MarkupShapeControl_RemoveControlRequested; + ShapeCanvas.Children.Remove(control); + } + markupShapeControls.Clear(); + + foreach (MarkupTextControl control in markupTextControls.ToList()) + { + control.RemoveControlRequested -= MarkupTextControl_RemoveControlRequested; + ShapeCanvas.Children.Remove(control); + } + markupTextControls.Clear(); + + MarkupCanvas.Strokes.Clear(); + } + + private void ClearMarkupButton_Click(object sender, RoutedEventArgs e) + { + if (markupShapeControls.Count == 0 && markupTextControls.Count == 0 && MarkupCanvas.Strokes.Count == 0) + return; + + List shapes = [.. markupShapeControls]; + List texts = [.. markupTextControls]; + List strokes = [.. MarkupCanvas.Strokes]; + + UndoRedo.AddUndo(new MarkupClearedItem( + shapes, texts, strokes, + markupShapeControls, markupTextControls, + ShapeCanvas, MarkupCanvas, + wireEvents: () => + { + foreach (MarkupShapeControl shape in shapes) + { + shape.MeasurementPointMouseDown += MarkupShapePoint_MouseDown; + shape.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested; + } + foreach (MarkupTextControl text in texts) + text.RemoveControlRequested += MarkupTextControl_RemoveControlRequested; + }, + unwireEvents: () => + { + foreach (MarkupShapeControl shape in shapes) + { + shape.MeasurementPointMouseDown -= MarkupShapePoint_MouseDown; + shape.RemoveControlRequested -= MarkupShapeControl_RemoveControlRequested; + } + foreach (MarkupTextControl text in texts) + text.RemoveControlRequested -= MarkupTextControl_RemoveControlRequested; + })); + + ClearAllMarkup(); + } + + private void HideMarkupToggle_Checked(object sender, RoutedEventArgs e) + { + SetMarkupVisibility(false); + } + + private void HideMarkupToggle_Unchecked(object sender, RoutedEventArgs e) + { + SetMarkupVisibility(true); + } + + private void SetMarkupVisibility(bool visible) + { + Visibility v = visible ? Visibility.Visible : Visibility.Collapsed; + foreach (MarkupShapeControl control in markupShapeControls) + control.Visibility = v; + foreach (MarkupTextControl control in markupTextControls) + control.Visibility = v; + MarkupCanvas.Visibility = v; + } + + #endregion Markup Tab } internal enum AnglePlacementStep { From e923733356fc17bec7021604abb91c1c7a94d68e Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Jul 2026 21:09:44 -0500 Subject: [PATCH 05/11] Add CLAUDE.md with build commands and architecture overview Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ccbdbe4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Magick Crop & Measure is a WPF desktop app (Windows 10+) that corrects perspective distortion in photos and provides measurement tools. It uses ImageMagick for image processing, Emgu.CV (OpenCV wrapper) for shape detection, and WPF-UI (Fluent Design) for the UI. + +## Build Commands + +```bash +# Build the solution (from repo root) +dotnet build MagickCrop.sln + +# Build a specific platform (arm64, x64, x86) +dotnet build MagickCrop/MagickCrop.csproj -p:Platform=x64 + +# Run (requires Windows with .NET 10) +dotnet run --project MagickCrop/MagickCrop.csproj + +# Run tests +dotnet test MagickCrop.Tests/ + +# Publish self-contained for x64 +dotnet publish MagickCrop/MagickCrop.csproj -r win-x64 --self-contained -o bld/x64/MagickCrop-Self-Contained +``` + +The MSIX packaging project (`MagickCrop-Package/MagickCrop-Package.wapproj`) is used for Store submissions and requires Visual Studio. + +## Architecture + +### Key Namespaces + +- `MagickCrop` — root namespace; `MainWindow.xaml.cs` and `MainWindow.QuadrilateralHover.cs` are partial classes for the main window +- `MagickCrop.ViewModels` — `MainWindowViewModel` (CommunityToolkit.Mvvm `ObservableObject`) + `IMainWindowView` interface +- `MagickCrop.Helpers` — stateless static helpers for image processing operations +- `MagickCrop.Controls` — WPF `UserControl` subclasses for measurement overlays and interactive controls +- `MagickCrop.Models` — DTOs, `UndoRedo` stack, `AspectRatio`, `DraggingMode` enum +- `MagickCrop.Models.MeasurementControls` — serializable DTOs for each measurement type +- `MagickCrop.Services` — `RecentProjectsManager` (JSON-backed project persistence in `%LocalAppData%\MagickCrop`) +- `MagickCrop.Behaviors` — `PinchZoomBehavior` attached property + +### View / ViewModel Split + +`MainWindow` implements `IMainWindowView`, which exposes just the properties the ViewModel needs (image source, busy state, local-adjustment region). `MainWindowViewModel` holds all commands (CommunityToolkit source-generated `[RelayCommand]`) and observable state. UI-only logic (dragging handles, polygon rendering) stays in the code-behind. + +### Image Processing Pipeline + +All image mutations write to a **temp file** on disk and reload from it. Undo/redo (`UndoRedo` + `MagickImageUndoRedoItem`) stores before/after file paths and reloads from disk on undo/redo — there is no in-memory image stack. + +Key helpers: +- `QuadrilateralDetector` — Emgu.CV-based contour detection, returns `DetectedQuadrilateral` with confidence score +- `GridStraightenHelper` — polynomial distortion via ImageMagick for grid-based warp correction +- `UnWarpCorrector` — barrel/pincushion correction using transfinite interpolation + polynomial distortion +- `EdgeCorrectionHelper` — wavy-edge straightening using the same transfinite interpolation approach +- `ImageExtensions` / `MagickExtensions` — extension methods bridging WPF types and Magick.NET types + +### Measurement Controls + +Each measurement type (Distance, Angle, Rectangle, Polygon, Circle, VerticalLine, HorizontalLine) is a `UserControl` with a corresponding `*Dto` model for JSON serialization. Controls live on a WPF `Canvas` overlaid on the image. + +### Project Persistence + +`RecentProjectsManager` saves projects as JSON files (including measurement state as `MagickCropMeasurementPackage`) with thumbnails under `%LocalAppData%\MagickCrop\Projects`. An auto-save timer fires every 5 seconds when a project is open. + +### UI Framework + +Uses **WPF-UI** (`Wpf.Ui`) for `FluentWindow`, theming, and Fluent controls. `MainWindow` extends `FluentWindow`. Theme follows system (light/dark) via `ApplicationThemeManager`. From 2e32cfb425530abe16c6ceb83f4bf4d20c429636 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Jul 2026 21:11:10 -0500 Subject: [PATCH 06/11] Allow PowerShell git diff and build commands in config Added "PowerShell(git diff *)" and "PowerShell(dotnet build *)" to the allowed command patterns in settings.local.json. Also fixed a missing comma to correct JSON formatting. This enables running git diff and dotnet build via PowerShell. --- .claude/settings.local.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bd1cd19..448de3a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,8 +14,10 @@ "Bash(find:*)", "Bash(ls:*)", "Bash(/mnt/c/Program\\ Files/dotnet/dotnet build)", - "Bash(\"/mnt/c/Program Files/dotnet/dotnet\" build)" + "Bash(\"/mnt/c/Program Files/dotnet/dotnet\" build)", + "PowerShell(git diff *)", + "PowerShell(dotnet build *)" ], "deny": [] } -} \ No newline at end of file +} From a1c667d620f70ecc04b038646c08287486df6ef4 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Jul 2026 21:30:38 -0500 Subject: [PATCH 07/11] Add Despeckle image adjustment Fixes #12 Co-Authored-By: Claude Fable 5 --- MagickCrop/MainWindow.xaml | 9 +++++++++ MagickCrop/ViewModels/MainWindowViewModel.cs | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/MagickCrop/MainWindow.xaml b/MagickCrop/MainWindow.xaml index c7255e2..77a6e83 100644 --- a/MagickCrop/MainWindow.xaml +++ b/MagickCrop/MainWindow.xaml @@ -778,6 +778,15 @@ + + + + + ApplyAdjustmentAsync(img => img.CannyEdge()); + [RelayCommand(CanExecute = nameof(CanApplyAdjustment))] + private Task ApplyDespeckle() => ApplyAdjustmentAsync(img => img.Despeckle()); + [ObservableProperty] private double thresholdValue = 128.0; From 4e3c92c5a403807bcecaa76fd3b7d867b432f34c Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Jul 2026 21:30:39 -0500 Subject: [PATCH 08/11] Add reset button to reload the original image Fixes #13 Co-Authored-By: Claude Fable 5 --- MagickCrop/MainWindow.xaml | 10 ++++++++++ MagickCrop/MainWindow.xaml.cs | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/MagickCrop/MainWindow.xaml b/MagickCrop/MainWindow.xaml index 77a6e83..2dbb5c3 100644 --- a/MagickCrop/MainWindow.xaml +++ b/MagickCrop/MainWindow.xaml @@ -389,6 +389,16 @@ + + + + + CenterAndZoomToFit(); } + private async void ResetToOriginalMenuItem_Click(object sender, RoutedEventArgs e) + { + string? originalPath = ViewModel.OriginalFilePath; + if (string.IsNullOrWhiteSpace(originalPath) || !File.Exists(originalPath)) + { + Wpf.Ui.Controls.MessageBox uiMessageBox = new() + { + Title = "Reset to Original", + Content = "The original image file could no longer be found, so the image cannot be reset.", + }; + await uiMessageBox.ShowDialogAsync(); + return; + } + + Wpf.Ui.Controls.MessageBox confirmBox = new() + { + Title = "Reset to Original", + Content = "This will discard all edits and measurements and reload the original image. Continue?", + PrimaryButtonText = "Reset", + CloseButtonText = "Cancel", + }; + + if (await confirmBox.ShowDialogAsync() != Wpf.Ui.Controls.MessageBoxResult.Primary) + return; + + await OpenImagePath(originalPath); + } + private const double ZoomFactor = 0.1; private const double MinZoom = 0.1; private const double MaxZoom = 10.0; From 0dc215f6dc4de320742e9c6caa3d5dec34eb4c76 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Jul 2026 21:30:39 -0500 Subject: [PATCH 09/11] Add HEIF, GIF, TIFF, and WebP to the open image dialog filter HEIC decoding already works via Magick.NET; this completes the file dialog so all formats accepted by drag-and-drop are also selectable. Fixes #18 Co-Authored-By: Claude Fable 5 --- MagickCrop/MainWindow.xaml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MagickCrop/MainWindow.xaml.cs b/MagickCrop/MainWindow.xaml.cs index a2192e6..be8a5bb 100644 --- a/MagickCrop/MainWindow.xaml.cs +++ b/MagickCrop/MainWindow.xaml.cs @@ -1147,7 +1147,7 @@ private async void OpenFileButton_Click(object sender, RoutedEventArgs e) OpenFileDialog openFileDialog = new() { - Filter = "Image Files|*.png;*.jpg;*.jpeg;*.heic;*.bmp|All files (*.*)|*.*", + Filter = "Image Files|*.png;*.jpg;*.jpeg;*.heic;*.heif;*.bmp;*.gif;*.tif;*.tiff;*.webp|All files (*.*)|*.*", RestoreDirectory = true, }; From b023148b364f2fc2866dfb4c6103bf3c86f0971a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Jul 2026 21:31:27 -0500 Subject: [PATCH 10/11] Allow Bash gh issue list command in settings.local.json Added "Bash(gh issue list *)" to the allowed commands list, enabling use of the GitHub CLI to list issues via Bash. No other changes were made. --- .claude/settings.local.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 448de3a..0936a2d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,8 @@ "Bash(/mnt/c/Program\\ Files/dotnet/dotnet build)", "Bash(\"/mnt/c/Program Files/dotnet/dotnet\" build)", "PowerShell(git diff *)", - "PowerShell(dotnet build *)" + "PowerShell(dotnet build *)", + "Bash(gh issue list *)" ], "deny": [] } From 3a89849d206429ea0948570d03791802db49d407 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 8 Jul 2026 21:36:04 -0500 Subject: [PATCH 11/11] Update version to 1.12.0 in manifest and project files Synchronized application version to 1.12.0 in both Package.appxmanifest and MagickCrop.csproj to reflect the latest release. This maintains consistency across deployment and build metadata. --- MagickCrop-Package/Package.appxmanifest | 4 ++-- MagickCrop/MagickCrop.csproj | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/MagickCrop-Package/Package.appxmanifest b/MagickCrop-Package/Package.appxmanifest index 6201c8a..2083f04 100644 --- a/MagickCrop-Package/Package.appxmanifest +++ b/MagickCrop-Package/Package.appxmanifest @@ -1,4 +1,4 @@ - + + Version="1.12.0.0" /> MagickCrop diff --git a/MagickCrop/MagickCrop.csproj b/MagickCrop/MagickCrop.csproj index ac08c9c..111fdd9 100644 --- a/MagickCrop/MagickCrop.csproj +++ b/MagickCrop/MagickCrop.csproj @@ -15,7 +15,7 @@ win-arm64;win-x86;win-x64 false true - 1.11.0 + 1.12.0 true false false