diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0936a2d..af2e968 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,8 @@ "Bash(\"/mnt/c/Program Files/dotnet/dotnet\" build)", "PowerShell(git diff *)", "PowerShell(dotnet build *)", - "Bash(gh issue list *)" + "Bash(gh issue list *)", + "Bash(dotnet test *)" ], "deny": [] } diff --git a/MagickCrop.Tests/Helpers/GeometryMathHelperTests.cs b/MagickCrop.Tests/Helpers/GeometryMathHelperTests.cs new file mode 100644 index 0000000..cd2447e --- /dev/null +++ b/MagickCrop.Tests/Helpers/GeometryMathHelperTests.cs @@ -0,0 +1,114 @@ +using System.Windows; +using MagickCrop.Helpers; + +namespace MagickCrop.Tests.Helpers; + +[TestClass] +public class GeometryMathHelperTests +{ + [TestMethod] + public void MidPoint_ReturnsAverageOfCoordinates() + { + Point result = GeometryMathHelper.MidPoint(new Point(0, 0), new Point(10, 20)); + + Assert.AreEqual(5, result.X); + Assert.AreEqual(10, result.Y); + } + + [TestMethod] + public void Distance_ForHorizontalSegment_ReturnsDeltaX() + { + double result = GeometryMathHelper.Distance(new Point(0, 0), new Point(3, 0)); + + Assert.AreEqual(3, result, 1e-9); + } + + [TestMethod] + public void Distance_For3_4_5Triangle_ReturnsFive() + { + double result = GeometryMathHelper.Distance(new Point(0, 0), new Point(3, 4)); + + Assert.AreEqual(5, result, 1e-9); + } + + [TestMethod] + public void PolygonPerimeter_ClosedTriangle_SumsAllThreeEdges() + { + Point[] triangle = [new(0, 0), new(4, 0), new(0, 3)]; + + double result = GeometryMathHelper.PolygonPerimeter(triangle, isClosed: true); + + // 4 + 5 + 3 = 12 + Assert.AreEqual(12, result, 1e-9); + } + + [TestMethod] + public void PolygonPerimeter_OpenPolyline_ExcludesClosingEdge() + { + Point[] triangle = [new(0, 0), new(4, 0), new(0, 3)]; + + double result = GeometryMathHelper.PolygonPerimeter(triangle, isClosed: false); + + // 4 + 5 = 9 (no closing edge back to the start) + Assert.AreEqual(9, result, 1e-9); + } + + [TestMethod] + public void PolygonPerimeter_FewerThanTwoVertices_ReturnsZero() + { + Assert.AreEqual(0, GeometryMathHelper.PolygonPerimeter([], isClosed: true)); + Assert.AreEqual(0, GeometryMathHelper.PolygonPerimeter([new Point(1, 1)], isClosed: true)); + } + + [TestMethod] + public void PolygonArea_UnitSquare_ReturnsOne() + { + Point[] square = [new(0, 0), new(1, 0), new(1, 1), new(0, 1)]; + + double result = GeometryMathHelper.PolygonArea(square); + + Assert.AreEqual(1, result, 1e-9); + } + + [TestMethod] + public void PolygonArea_FewerThanThreeVertices_ReturnsZero() + { + Point[] segment = [new(0, 0), new(1, 1)]; + + Assert.AreEqual(0, GeometryMathHelper.PolygonArea(segment)); + } + + [TestMethod] + public void TryGetCircumcircle_ForPointsOnUnitCircle_FindsOriginAndRadiusOne() + { + bool found = GeometryMathHelper.TryGetCircumcircle( + new Point(1, 0), new Point(0, 1), new Point(-1, 0), out Point center, out double radius); + + Assert.IsTrue(found); + Assert.AreEqual(0, center.X, 1e-9); + Assert.AreEqual(0, center.Y, 1e-9); + Assert.AreEqual(1, radius, 1e-9); + } + + [TestMethod] + public void TryGetCircumcircle_ForCollinearPoints_ReturnsFalse() + { + bool found = GeometryMathHelper.TryGetCircumcircle( + new Point(0, 0), new Point(1, 1), new Point(2, 2), out _, out _); + + Assert.IsFalse(found); + } + + [TestMethod] + public void BezierControlFromPassThrough_ForMidpointOnStraightLine_ReturnsThatSamePoint() + { + Point start = new(0, 0); + Point end = new(10, 0); + Point mid = new(5, 0); + + Point control = GeometryMathHelper.BezierControlFromPassThrough(start, mid, end); + + Assert.AreEqual(5, control.X, 1e-9); + Assert.AreEqual(0, control.Y, 1e-9); + } +} diff --git a/MagickCrop.Tests/Helpers/MeasurementFormattingHelperTests.cs b/MagickCrop.Tests/Helpers/MeasurementFormattingHelperTests.cs new file mode 100644 index 0000000..cdeff9c --- /dev/null +++ b/MagickCrop.Tests/Helpers/MeasurementFormattingHelperTests.cs @@ -0,0 +1,39 @@ +using MagickCrop.Helpers; + +namespace MagickCrop.Tests.Helpers; + +[TestClass] +public class MeasurementFormattingHelperTests +{ + [TestMethod] + public void FormatPerimeter_IncludesUnitsAndTwoDecimalPlaces() + { + string result = MeasurementFormattingHelper.FormatPerimeter(12.3456, "cm"); + + Assert.AreEqual("P: 12.35 cm", result); + } + + [TestMethod] + public void FormatPerimeterArea_SquaresTheLinearUnits() + { + string result = MeasurementFormattingHelper.FormatPerimeterArea(10, 6.25, "cm"); + + Assert.AreEqual("P: 10.00 cm, A: 6.25 cm²", result); + } + + [TestMethod] + public void FormatNeedMorePoints_IncludesRemainingCount() + { + string result = MeasurementFormattingHelper.FormatNeedMorePoints(4.5, "px", 2); + + Assert.AreEqual("P: 4.50 px (Need 2 more points)", result); + } + + [TestMethod] + public void FormatClickToClose_IncludesInstructionalText() + { + string result = MeasurementFormattingHelper.FormatClickToClose(4.5, "px"); + + Assert.AreEqual("P: 4.50 px (Click orange point to close)", result); + } +} diff --git a/MagickCrop.Tests/MSTestSettings.cs b/MagickCrop.Tests/MSTestSettings.cs new file mode 100644 index 0000000..aaf278c --- /dev/null +++ b/MagickCrop.Tests/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/MagickCrop.Tests/MagickCrop.Tests.csproj b/MagickCrop.Tests/MagickCrop.Tests.csproj new file mode 100644 index 0000000..2896d84 --- /dev/null +++ b/MagickCrop.Tests/MagickCrop.Tests.csproj @@ -0,0 +1,40 @@ + + + + net10.0-windows10.0.20348.0 + latest + enable + enable + true + false + true + x64;ARM64 + + ARM64 + x64 + $(Platform) + + + + + + + + + + + + + + + Platform=$(Platform) + + + + diff --git a/MagickCrop.Tests/UndoRedoTests.cs b/MagickCrop.Tests/UndoRedoTests.cs new file mode 100644 index 0000000..9469f69 --- /dev/null +++ b/MagickCrop.Tests/UndoRedoTests.cs @@ -0,0 +1,111 @@ +using MagickCrop; + +namespace MagickCrop.Tests; + +[TestClass] +public class UndoRedoTests +{ + private sealed class FakeUndoRedoItem : UndoRedoItem + { + public int UndoCalls { get; private set; } + public int RedoCalls { get; private set; } + public string UndoResult { get; set; } = string.Empty; + public string RedoResult { get; set; } = string.Empty; + + public override string Undo() + { + UndoCalls++; + return UndoResult; + } + + public override string Redo() + { + RedoCalls++; + return RedoResult; + } + } + + [TestMethod] + public void NewUndoRedo_HasNothingToUndoOrRedo() + { + UndoRedo undoRedo = new(); + + Assert.IsFalse(undoRedo.CanUndo); + Assert.IsFalse(undoRedo.CanRedo); + } + + [TestMethod] + public void AddUndo_MakesCanUndoTrueAndCanRedoFalse() + { + UndoRedo undoRedo = new(); + + undoRedo.AddUndo(new FakeUndoRedoItem()); + + Assert.IsTrue(undoRedo.CanUndo); + Assert.IsFalse(undoRedo.CanRedo); + } + + [TestMethod] + public void Undo_InvokesItemUndoAndMovesItToRedoStack() + { + UndoRedo undoRedo = new(); + FakeUndoRedoItem item = new() { UndoResult = "previous.png" }; + undoRedo.AddUndo(item); + + string result = undoRedo.Undo(); + + Assert.AreEqual("previous.png", result); + Assert.AreEqual(1, item.UndoCalls); + Assert.IsFalse(undoRedo.CanUndo); + Assert.IsTrue(undoRedo.CanRedo); + } + + [TestMethod] + public void Redo_AfterUndo_InvokesItemRedoAndMovesItBackToUndoStack() + { + UndoRedo undoRedo = new(); + FakeUndoRedoItem item = new() { RedoResult = "next.png" }; + undoRedo.AddUndo(item); + undoRedo.Undo(); + + string result = undoRedo.Redo(); + + Assert.AreEqual("next.png", result); + Assert.AreEqual(1, item.RedoCalls); + Assert.IsTrue(undoRedo.CanUndo); + Assert.IsFalse(undoRedo.CanRedo); + } + + [TestMethod] + public void Undo_WithEmptyStack_ReturnsEmptyStringAndDoesNotThrow() + { + UndoRedo undoRedo = new(); + + string result = undoRedo.Undo(); + + Assert.AreEqual(string.Empty, result); + } + + [TestMethod] + public void Redo_WithEmptyStack_ReturnsEmptyStringAndDoesNotThrow() + { + UndoRedo undoRedo = new(); + + string result = undoRedo.Redo(); + + Assert.AreEqual(string.Empty, result); + } + + [TestMethod] + public void AddUndo_AfterAnUndo_ClearsTheRedoStack() + { + UndoRedo undoRedo = new(); + undoRedo.AddUndo(new FakeUndoRedoItem()); + undoRedo.Undo(); + Assert.IsTrue(undoRedo.CanRedo); + + undoRedo.AddUndo(new FakeUndoRedoItem()); + + Assert.IsFalse(undoRedo.CanRedo); + } +} diff --git a/MagickCrop.sln b/MagickCrop.sln index ee4250d..040a892 100644 --- a/MagickCrop.sln +++ b/MagickCrop.sln @@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MagickCrop", "MagickCrop\Ma EndProject Project("{C7167F0D-BC9F-4E6E-AFE1-012C56B48DB5}") = "MagickCrop-Package", "MagickCrop-Package\MagickCrop-Package.wapproj", "{F6C2CFAD-9A9B-48F6-A5F3-DB3BCC25CACB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagickCrop.Tests", "MagickCrop.Tests\MagickCrop.Tests.csproj", "{113A3DCE-5333-4003-9B94-61C46F5BB543}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,6 +73,26 @@ Global {F6C2CFAD-9A9B-48F6-A5F3-DB3BCC25CACB}.Release|x86.ActiveCfg = Release|x86 {F6C2CFAD-9A9B-48F6-A5F3-DB3BCC25CACB}.Release|x86.Build.0 = Release|x86 {F6C2CFAD-9A9B-48F6-A5F3-DB3BCC25CACB}.Release|x86.Deploy.0 = Release|x86 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|Any CPU.ActiveCfg = Debug|ARM64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|Any CPU.Build.0 = Debug|ARM64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|ARM.ActiveCfg = Debug|ARM64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|ARM.Build.0 = Debug|ARM64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|ARM64.Build.0 = Debug|ARM64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|x64.ActiveCfg = Debug|x64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|x64.Build.0 = Debug|x64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|x86.ActiveCfg = Debug|x64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|x86.Build.0 = Debug|x64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|Any CPU.ActiveCfg = Release|ARM64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|Any CPU.Build.0 = Release|ARM64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|ARM.ActiveCfg = Release|ARM64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|ARM.Build.0 = Release|ARM64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|ARM64.ActiveCfg = Release|ARM64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|ARM64.Build.0 = Release|ARM64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|x64.ActiveCfg = Release|x64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|x64.Build.0 = Release|x64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|x86.ActiveCfg = Release|x64 + {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|x86.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MagickCrop/Controls/AngleMeasurementControl.xaml b/MagickCrop/Controls/AngleMeasurementControl.xaml index 83a3e77..aad3552 100644 --- a/MagickCrop/Controls/AngleMeasurementControl.xaml +++ b/MagickCrop/Controls/AngleMeasurementControl.xaml @@ -85,6 +85,10 @@ Click="CopyMeasurementMenuItem_Click" Header="Copy Measurement" ToolTip="Copy the angle value" /> + strokeColor; + set + { + strokeColor = value; + UpdateColors(); + } + } + public AngleMeasurementControl() { InitializeComponent(); UpdatePositions(); } + private void UpdateColors() + { + SolidColorBrush brush = new(strokeColor); + Line1.Stroke = brush; + Line2.Stroke = brush; + AngleArc.Stroke = brush; + AngleArc.Fill = new SolidColorBrush(Color.FromArgb(0x20, strokeColor.R, strokeColor.G, strokeColor.B)); + Point1.Fill = brush; + VertexPoint.Fill = brush; + Point3.Fill = brush; + } + public bool IsDragGizmoVisible { get => Point1.Visibility == Visibility.Visible; @@ -252,6 +276,16 @@ private void RemoveMeasurementMenuItem_Click(object sender, RoutedEventArgs e) RemoveControlRequested?.Invoke(this, EventArgs.Empty); } + private async void ChangeColorMenuItem_Click(object sender, RoutedEventArgs e) + { + if (Application.Current.MainWindow is not MainWindow mainWindow) + return; + + Color? picked = await ColorPickerDialog.PickColorAsync(mainWindow, strokeColor, "Change Measurement Color"); + if (picked is Color color) + StrokeColor = color; + } + /// /// Convert this control to a data transfer object /// @@ -261,7 +295,8 @@ public AngleMeasurementControlDto ToDto() { Point1Position = point1Position, VertexPosition = vertexPosition, - Point3Position = point3Position + Point3Position = point3Position, + StrokeColor = strokeColor.ToString() }; } @@ -273,6 +308,11 @@ public void FromDto(AngleMeasurementControlDto dto) point1Position = dto.Point1Position; vertexPosition = dto.VertexPosition; point3Position = dto.Point3Position; + + try { strokeColor = (Color)ColorConverter.ConvertFromString(dto.StrokeColor); } + catch { strokeColor = (Color)ColorConverter.ConvertFromString("#0066FF"); } + UpdateColors(); + UpdatePositions(); } } diff --git a/MagickCrop/Controls/CircleMeasurementControl.xaml b/MagickCrop/Controls/CircleMeasurementControl.xaml index b5d1d37..460ec5c 100644 --- a/MagickCrop/Controls/CircleMeasurementControl.xaml +++ b/MagickCrop/Controls/CircleMeasurementControl.xaml @@ -50,6 +50,10 @@ Click="CopyMeasurementMenuItem_Click" Header="Copy Measurement" ToolTip="Copy the circle properties" /> + strokeColor; + set + { + strokeColor = value; + UpdateColors(); + } + } + public CircleMeasurementControl() { InitializeComponent(); UpdatePositions(); } + private void UpdateColors() + { + MeasurementCircle.Stroke = new SolidColorBrush(strokeColor); + MeasurementCircle.Fill = new SolidColorBrush(Color.FromArgb(0x20, strokeColor.R, strokeColor.G, strokeColor.B)); + SolidColorBrush pointBrush = new(strokeColor); + CenterPoint.Fill = pointBrush; + EdgePoint.Fill = pointBrush; + } + public bool IsDragGizmoVisible { get => CenterPoint.Visibility == Visibility.Visible; @@ -182,6 +204,16 @@ private void RemoveMeasurementMenuItem_Click(object sender, RoutedEventArgs e) RemoveControlRequested?.Invoke(this, EventArgs.Empty); } + private async void ChangeColorMenuItem_Click(object sender, RoutedEventArgs e) + { + if (Application.Current.MainWindow is not MainWindow mainWindow) + return; + + Color? picked = await ColorPickerDialog.PickColorAsync(mainWindow, strokeColor, "Change Measurement Color"); + if (picked is Color color) + StrokeColor = color; + } + public CircleMeasurementControlDto ToDto() { return new CircleMeasurementControlDto @@ -189,7 +221,8 @@ public CircleMeasurementControlDto ToDto() Center = center, EdgePoint = edgePoint, ScaleFactor = ScaleFactor, - Units = Units + Units = Units, + StrokeColor = strokeColor.ToString() }; } @@ -202,6 +235,11 @@ public void FromDto(CircleMeasurementControlDto dto) edgePoint = dto.EdgePoint; ScaleFactor = dto.ScaleFactor; // This will use the property setter Units = dto.Units; // This will use the property setter + + try { strokeColor = (Color)ColorConverter.ConvertFromString(dto.StrokeColor); } + catch { strokeColor = (Color)ColorConverter.ConvertFromString("#0066FF"); } + UpdateColors(); + UpdatePositions(); } } diff --git a/MagickCrop/Controls/ColorSwatchPicker.xaml b/MagickCrop/Controls/ColorSwatchPicker.xaml new file mode 100644 index 0000000..15a0405 --- /dev/null +++ b/MagickCrop/Controls/ColorSwatchPicker.xaml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/MagickCrop/Controls/ColorSwatchPicker.xaml.cs b/MagickCrop/Controls/ColorSwatchPicker.xaml.cs new file mode 100644 index 0000000..d850d7f --- /dev/null +++ b/MagickCrop/Controls/ColorSwatchPicker.xaml.cs @@ -0,0 +1,104 @@ +using MagickCrop.Helpers; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; + +namespace MagickCrop.Controls; + +/// +/// A swatch grid plus a custom hex entry, used wherever the user picks a color for a +/// shape. Kept deliberately simple (no color wheel) since WPF-UI's ColorPicker control +/// has no usable public API in the package version this app references. +/// +public partial class ColorSwatchPicker : UserControl +{ + private readonly List swatchButtons = []; + private bool isUpdatingFromCode; + + private Color selectedColor = Colors.Red; + public Color SelectedColor + { + get => selectedColor; + set + { + selectedColor = value; + UpdateFromColor(); + } + } + + public ColorSwatchPicker() + { + InitializeComponent(); + BuildSwatches(); + UpdateFromColor(); + } + + private void BuildSwatches() + { + foreach ((string name, Color color) in ColorPalette.Swatches) + { + ToggleButton button = new() + { + Width = 28, + Height = 28, + Margin = new Thickness(2), + Background = new SolidColorBrush(color), + Tag = color, + ToolTip = name + }; + button.Checked += SwatchButton_Checked; + + swatchButtons.Add(button); + SwatchGrid.Children.Add(button); + } + } + + private void SwatchButton_Checked(object sender, RoutedEventArgs e) + { + if (sender is not ToggleButton { Tag: Color color }) + return; + + SelectedColor = color; + } + + private void HexTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + if (isUpdatingFromCode) return; + + try + { + string text = HexTextBox.Text.Trim(); + if (text.Length == 0) return; + if (!text.StartsWith('#')) text = "#" + text; + + Color color = (Color)ColorConverter.ConvertFromString(text); + selectedColor = color; + PreviewSwatch.Background = new SolidColorBrush(color); + UncheckAllSwatches(); + } + catch + { + // Left as typed — an incomplete hex value while the user is still typing. + } + } + + private void UpdateFromColor() + { + isUpdatingFromCode = true; + + PreviewSwatch.Background = new SolidColorBrush(selectedColor); + HexTextBox.Text = selectedColor.ToString(); + + foreach (ToggleButton button in swatchButtons) + button.IsChecked = button.Tag is Color color && color == selectedColor; + + isUpdatingFromCode = false; + } + + private void UncheckAllSwatches() + { + foreach (ToggleButton button in swatchButtons) + button.IsChecked = false; + } +} diff --git a/MagickCrop/Controls/ConstructionOverlayControl.xaml b/MagickCrop/Controls/ConstructionOverlayControl.xaml new file mode 100644 index 0000000..68aa9bd --- /dev/null +++ b/MagickCrop/Controls/ConstructionOverlayControl.xaml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs b/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs new file mode 100644 index 0000000..fa73b30 --- /dev/null +++ b/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs @@ -0,0 +1,2196 @@ +using MagickCrop.Helpers; +using MagickCrop.Models.Construction; +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; + +/// +/// Hosts a whole parametric construction — points, the lines through them, and the shape +/// derived from where those lines cross. +/// +/// Unlike the other measurement controls this is one control for the entire graph rather +/// than one per entity, because a corner is a function of all the lines and so the +/// solve needs a single owner. +/// +public partial class ConstructionOverlayControl : UserControl +{ + private const double BasePointSize = 12; + private const double BaseSmallPointSize = 6; + private const double BaseStrokeThickness = 2; + private const double HitStrokeThickness = 12; + + /// How far past the construction extended lines are allowed to run. + private const double BoundsInflation = 0.2; + + /// How much bigger a selected point handle draws than an idle one. + private const double SelectedPointScale = 1.5; + + // Mutable per-instance so each construction overlay can have its own color. Selection + // and face-state brushes below stay fixed/static — they're state indicators, not the + // shape's identity color. + private Brush PointBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0066FF")); + private Brush LineBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0066FF")); + private static readonly Brush SelectionBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FF6600")); + + // Nearly transparent rather than fully so WPF still hit-tests the fill — an idle face + // needs to be clickable before it has ever been hovered. + private static readonly Brush FaceIdleBrush = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0)); + private static readonly Brush FaceHoverBrush = new SolidColorBrush(Color.FromArgb(70, 0, 150, 255)); + private static readonly Brush FaceSelectedBrush = new SolidColorBrush(Color.FromArgb(110, 0, 200, 90)); + + // Layering inside the shared canvas. Explicit z-indices avoid having to remove and + // re-add elements to keep points clickable above the lines. + private const int FaceZIndex = -1; + private const int ShapeZIndex = 0; + private const int LineZIndex = 1; + private const int HitZIndex = 2; + + /// Above the line hit paths so it is clickable, below real points. + private const int CandidateZIndex = 3; + + private const int PointZIndex = 4; + private const int TextZIndex = 5; + + private readonly ConstructionGeometry geometry = new(); + private readonly List pointHandles = []; + private readonly List linePaths = []; + private readonly List hitPaths = []; + + /// + /// Points the user has picked, oldest first. Capped at three: two define a line, + /// three define a circle, and nothing needs more, so the cap is what keeps "select + /// two, then a third" a self-explaining gesture. + /// + private readonly List selectedPointIds = []; + + private const int MaxSelectedPoints = 3; + + private Guid? selectedLineId; + private Guid? selectedCircleId; + + private readonly List circlePaths = []; + private readonly List circleHitPaths = []; + + /// + /// Labels for individual lines and circles the user has asked to see measured. + /// Rebuilt on every refresh, because what they read depends on positions that move. + /// + private readonly List measurementLabels = []; + + // Crossings and centres the construction implies. Recomputed every refresh and never + // stored — clicking one is what turns it into a point the construction owns. + private readonly List candidateHandles = []; + private List derivedCandidates = []; + + // The "you could build this" shape offered by the current selection: a faint dashed + // visual plus a fat transparent twin that is comfortable to click. Two points offer + // a line, three offer a circle. + private Path? ghostLinePath; + private Path? ghostHitPath; + + // Live preview of where a boundary probe is reading the edge. Created on first use and + // then reused, because it is repositioned on every mouse move of the gesture. + private Ellipse? boundaryCandidate; + private Point? boundaryCandidatePosition; + private bool boundaryCandidateIsWeak; + + private string? transientHint; + + private bool showShapeMeasurement = true; + + private int pointDraggingIndex = -1; + private bool areDragGizmosVisible = true; + private bool areEndpointCapsVisible; + private double visualScale = 1.0; + + private IReadOnlyList solvedRing = []; + private ConstructionSolver.SolveStatus solveStatus = ConstructionSolver.SolveStatus.NotEnoughLines; + + // Every bounded cell the current lines carve out, not just the single outer shape + // above. Rebuilt alongside it on every refresh; the user clicks these to build an + // arbitrary polygon out of adjacent cells. + private List faces = []; + private readonly List facePaths = []; + private readonly HashSet selectedFaceIndices = []; + private int? hoveredFaceIndex; + private bool isFaceSelectionModeActive; + + public ConstructionOverlayControl() + { + InitializeComponent(); + + Panel.SetZIndex(ShapePath, ShapeZIndex); + Panel.SetZIndex(PreviewLine, HitZIndex); + Panel.SetZIndex(MeasurementText, TextZIndex); + + Refresh(); + } + + #region Measurement contract + + private double scaleFactor = 1.0; + public double ScaleFactor + { + get => scaleFactor; + set + { + scaleFactor = value; + UpdateDisplay(); + } + } + + private string units = "pixels"; + public string Units + { + get => units; + set + { + units = value; + UpdateDisplay(); + } + } + + private Color strokeColor = (Color)ColorConverter.ConvertFromString("#0066FF"); + + /// + /// The construction's identity color — applied to its points, lines, circles, and the + /// derived shape's outline/fill. One color for the whole construction, since it is one + /// context menu and one "thing" to the user, even though it is many visual elements. + /// + public Color StrokeColor + { + get => strokeColor; + set + { + strokeColor = value; + PointBrush = new SolidColorBrush(strokeColor); + LineBrush = new SolidColorBrush(strokeColor); + ShapePath.Stroke = new SolidColorBrush(strokeColor); + ShapePath.Fill = new SolidColorBrush(Color.FromArgb(0x26, strokeColor.R, strokeColor.G, strokeColor.B)); + Refresh(); + } + } + + public bool IsDragGizmoVisible + { + get => areDragGizmosVisible; + set + { + areDragGizmosVisible = value; + Visibility visibility = value ? Visibility.Visible : Visibility.Collapsed; + foreach (Ellipse handle in pointHandles) + handle.Visibility = visibility; + + // The build affordances are gizmos too — none may survive into an export. + if (ghostLinePath is not null) ghostLinePath.Visibility = visibility; + if (ghostHitPath is not null) ghostHitPath.Visibility = visibility; + ApplyBoundaryCandidateAppearance(); + + // Candidates are dropped entirely rather than hidden, so they cannot be + // clicked while invisible. + RenderDerivedCandidates(); + } + } + + public bool IsEndpointCapVisible + { + set + { + areEndpointCapsVisible = value; + ApplyPointSizes(); + } + } + + public event MouseButtonEventHandler? MeasurementPointMouseDown; + public delegate void RemoveControlRequestedEventHandler(object sender, EventArgs e); + public event RemoveControlRequestedEventHandler? RemoveControlRequested; + + /// Raised when the construction changes so the host can refresh dependent UI. + public event EventHandler? ConstructionChanged; + + /// + /// Raised once per completed edit, carrying before/after snapshots for the undo + /// stack. A drag raises this on release, not on every mouse move. + /// + public event EventHandler? GeometryEdited; + + public void MovePoint(int pointIndex, Point newPosition) + { + if (pointIndex < 0 || pointIndex >= geometry.Points.Count) return; + + // A derived point is wherever its parents put it; a drag cannot override that. + if (geometry.Points[pointIndex].IsDerived) return; + + Edit(() => geometry.MovePoint(geometry.Points[pointIndex].Id, newPosition)); + } + + public int GetActivePointIndex() => pointDraggingIndex; + + public void ResetActivePoint() => pointDraggingIndex = -1; + + #endregion + + #region Construction API + + /// + /// Bounds of the image in canvas coordinates. Extended lines are clipped to this + /// unioned with the construction's own extent, so a corner outside the image still + /// gets drawn. + /// + public Rect ImageBounds { get; set; } = new(0, 0, 1000, 1000); + + public int PointCount => geometry.Points.Count; + public int LineCount => geometry.Lines.Count; + public int CircleCount => geometry.Circles.Count; + + public bool IsEmpty => + geometry.Points.Count == 0 && geometry.Lines.Count == 0 && geometry.Circles.Count == 0; + + public Guid AddPoint(Point position) + { + Guid id = Guid.Empty; + Edit(() => id = geometry.AddPoint(position)); + return id; + } + + public Guid AddLine(Guid startPointId, Guid endPointId) + { + Guid id = Guid.Empty; + Edit(() => id = geometry.AddLine(startPointId, endPointId)); + return id; + } + + public void MoveConstructionPoint(Guid pointId, Point position) + { + if (geometry.FindPoint(pointId)?.IsDerived != false) return; + + Edit(() => geometry.MovePoint(pointId, position)); + } + + public void RemoveConstructionPoint(Guid pointId) => + Edit(() => + { + geometry.RemovePoint(pointId); + PruneSelection(); + }); + + public void RemoveConstructionLine(Guid lineId) => + Edit(() => + { + geometry.RemoveLine(lineId); + PruneSelection(); + }); + + public void RemoveConstructionCircle(Guid circleId) => + Edit(() => + { + geometry.RemoveCircle(circleId); + PruneSelection(); + }); + + /// + /// Repoints a line's end, used when a drag-created edge is released on an existing + /// point — that reuse is what connects edges into a shape. + /// + public void SetLineEnd(Guid lineId, Guid endPointId) => + Edit(() => + { + if (geometry.FindLine(lineId) is ConstructionLine line) + line.EndPointId = endPointId; + }); + + public void SetLineExtended(Guid lineId, bool isExtended) => + Edit(() => + { + if (geometry.FindLine(lineId) is ConstructionLine line) + line.IsExtended = isExtended; + }); + + public bool IsLineExtended(Guid lineId) => geometry.FindLine(lineId)?.IsExtended ?? true; + + /// + /// Shows or hides the length label beside one line. Routed through + /// so the toggle joins the undo stack like any other change to the construction. + /// + public void SetLineMeasurementVisible(Guid lineId, bool isVisible) => + Edit(() => + { + if (geometry.FindLine(lineId) is ConstructionLine line) + line.ShowMeasurement = isVisible; + }); + + public bool IsLineMeasurementVisible(Guid lineId) => + geometry.FindLine(lineId)?.ShowMeasurement ?? false; + + /// Shows or hides the radius/circumference/area label at one circle's centre. + public void SetCircleMeasurementVisible(Guid circleId, bool isVisible) => + Edit(() => + { + if (geometry.FindCircle(circleId) is ConstructionCircle circle) + circle.ShowMeasurement = isVisible; + }); + + public bool IsCircleMeasurementVisible(Guid circleId) => + geometry.FindCircle(circleId)?.ShowMeasurement ?? false; + + /// + /// Whether the derived shape's perimeter and area readout is shown. Unlike the + /// per-line and per-circle flags this is not part of the geometry, so it is not + /// undoable — it is a view setting on the construction as a whole. + /// + public bool ShowShapeMeasurement + { + get => showShapeMeasurement; + set + { + if (showShapeMeasurement == value) return; + + showShapeMeasurement = value; + UpdateDisplay(); + } + } + + /// + /// Finds a point within of . + /// The caller must divide the tolerance by the canvas zoom so the grab radius is + /// constant on screen. + /// + public Guid? FindPointNear(Point position, double tolerance, Guid? exclude = null) => + geometry.FindPointNear(position, tolerance, exclude)?.Id; + + public Point? GetPointPosition(Guid pointId) => geometry.FindPoint(pointId)?.Position; + + public void ShowPreviewLine(Point from, Point to) + { + PreviewLine.X1 = from.X; + PreviewLine.Y1 = from.Y; + PreviewLine.X2 = to.X; + PreviewLine.Y2 = to.Y; + PreviewLine.Visibility = Visibility.Visible; + } + + public void HidePreviewLine() => PreviewLine.Visibility = Visibility.Collapsed; + + /// + /// Shows where a boundary probe is currently reading the edge, so the user can see the + /// point track the transition while they are still shaping the probe. Purely a + /// preview — nothing is added to the geometry until the gesture is released. + /// + /// + /// Draws hollow and dashed instead of solid, so a guess never looks as certain as a + /// reading. + /// + public void ShowBoundaryCandidate(Point position, bool isWeak) + { + boundaryCandidatePosition = position; + boundaryCandidateIsWeak = isWeak; + + boundaryCandidate ??= CreateBoundaryCandidate(); + ApplyBoundaryCandidateAppearance(); + } + + public void HideBoundaryCandidate() + { + boundaryCandidatePosition = null; + + if (boundaryCandidate is not null) + boundaryCandidate.Visibility = Visibility.Collapsed; + } + + /// + /// A note about the last gesture, shown under the measurement until the next edit + /// replaces it. Used to say a probe found only a weak boundary without interrupting + /// the user with a dialog for something they can simply nudge. + /// + public string? TransientHint + { + get => transientHint; + set + { + transientHint = value; + UpdateDisplay(); + } + } + + /// Ring of derived corners, empty when the construction cannot be solved. + public IReadOnlyList SolvedRing => solvedRing; + + public bool TryGetRing(out IReadOnlyList ring) + { + ring = solvedRing; + return solvedRing.Count >= 3; + } + + /// + /// Produces a quadrilateral for the transform / crop / un-warp consumers. Returns + /// false whenever the construction is not exactly four solved corners. + /// + public bool TryGetQuadrilateral(out QuadrilateralDetector.DetectedQuadrilateral quadrilateral) + { + quadrilateral = null!; + + if (solvedRing.Count != 4) return false; + + // DetectedQuadrilateral labels corners by x+y / x-y extremes, which mislabels a + // strongly rotated quad. Detection output is near axis-aligned so it never trips + // on this, but a hand construction can sit at 40 degrees — normalizing the winding + // first keeps the TL/TR/BR/BL labels honest. + List ordered = ConstructionSolver.NormalizeWinding(solvedRing); + double area = GeometryMathHelper.PolygonArea(ordered); + + if (area <= 0 || double.IsNaN(area) || double.IsInfinity(area)) return false; + + foreach (Point corner in ordered) + { + if (double.IsNaN(corner.X) || double.IsNaN(corner.Y) || + double.IsInfinity(corner.X) || double.IsInfinity(corner.Y)) + return false; + } + + quadrilateral = new QuadrilateralDetector.DetectedQuadrilateral([.. ordered], area, 1.0); + return true; + } + + #endregion + + #region Face selection + + /// + /// Gates whether the bounded cells the arrangement carves out can be hovered and + /// clicked. Off by default so face paths never steal clicks meant for the point, line, + /// and boundary tools sharing this canvas. + /// + public bool IsFaceSelectionModeActive + { + get => isFaceSelectionModeActive; + set + { + isFaceSelectionModeActive = value; + + foreach (Path path in facePaths) + path.IsHitTestVisible = value; + + if (!value) + { + hoveredFaceIndex = null; + UpdateFaceVisuals(); + } + } + } + + /// Raised whenever a face is clicked, so the host can enable/disable its "Make Polygon" button. + public event EventHandler? FaceSelectionChanged; + + public bool HasSelectedFaces => selectedFaceIndices.Count > 0; + + public void ClearFaceSelection() + { + if (selectedFaceIndices.Count == 0) return; + + selectedFaceIndices.Clear(); + UpdateFaceVisuals(); + FaceSelectionChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// The selected faces merged into one or more outer boundaries — more than one only + /// when the selection is split into separate clumps. Empty rather than a single empty + /// ring when nothing is selected. + /// + public bool TryGetSelectedFacesUnion(out List> rings) + { + rings = selectedFaceIndices.Count > 0 + ? ConstructionFaceSolver.UnionFaces(faces, selectedFaceIndices) + : []; + + return rings.Count > 0; + } + + #endregion + + #region Undo transactions + + /// Geometry as it stood when the current drag began. + private ConstructionGeometryDto? dragSnapshot; + + /// Geometry as it stood when the current single mutation began. + private ConstructionGeometryDto? editSnapshot; + + /// + /// Opens a drag: every mouse move writes a new position, but only the gesture as a + /// whole is worth undoing, so the individual writes stop publishing until + /// . + /// + public void BeginDrag() + { + // A drag whose release was missed is published here rather than silently lost, + // which keeps a dropped mouse-up from wedging the stack. + EndDrag(); + dragSnapshot = CaptureGeometry(); + } + + /// Closes a drag and publishes it as one undo step. Safe to call twice. + public void EndDrag() + { + ConstructionGeometryDto? before = dragSnapshot; + dragSnapshot = null; + + if (before is not null) + PublishEdit(before); + } + + /// Runs a single mutation, publishing it unless a wider edit is in flight. + private void Edit(Action change) + { + // Only the outermost scope captures and publishes: inside a drag, or inside + // another Edit, the mutation is part of a bigger step. + bool isOutermost = dragSnapshot is null && editSnapshot is null; + if (isOutermost) + editSnapshot = CaptureGeometry(); + + // Any change to the geometry makes a note about the previous gesture stale. The + // probe tool sets its note back after the point it adds lands here. + transientHint = null; + + try + { + change(); + Refresh(); + } + finally + { + if (isOutermost) + { + ConstructionGeometryDto before = editSnapshot!; + editSnapshot = null; + PublishEdit(before); + } + } + } + + /// + /// Raises the edit, but only when the geometry actually differs — a click that + /// changed nothing, or a drag that never moved, leaves the undo stack alone. + /// + private void PublishEdit(ConstructionGeometryDto before) + { + ConstructionGeometryDto after = CaptureGeometry(); + if (GeometryEquals(before, after)) return; + + GeometryEdited?.Invoke(this, new ConstructionGeometryEditedEventArgs(before, after)); + } + + /// + /// Snapshot of the point/line graph alone. Scale and units are deliberately left at + /// their defaults: they are window-level settings and must not ride along on an undo. + /// + public ConstructionGeometryDto CaptureGeometry() + { + ConstructionGeometryDto snapshot = new(); + + foreach (ConstructionPoint point in geometry.Points) + { + snapshot.Points.Add(new ConstructionPointDto + { + Id = point.Id, + Position = point.Position, + Source = point.Source, + ParentAId = point.ParentAId, + ParentBId = point.ParentBId + }); + } + + foreach (ConstructionLine line in geometry.Lines) + { + snapshot.Lines.Add(new ConstructionLineDto + { + Id = line.Id, + StartPointId = line.StartPointId, + EndPointId = line.EndPointId, + IsExtended = line.IsExtended, + ShowMeasurement = line.ShowMeasurement + }); + } + + foreach (ConstructionCircle circle in geometry.Circles) + { + snapshot.Circles.Add(new ConstructionCircleDto + { + Id = circle.Id, + PointAId = circle.PointAId, + PointBId = circle.PointBId, + PointCId = circle.PointCId, + ShowMeasurement = circle.ShowMeasurement + }); + } + + return snapshot; + } + + /// + /// Replaces the graph with a snapshot, without touching scale or units. Selection is + /// dropped because the points it referred to may not exist in the restored state. + /// Does not itself raise — undo is not a new edit. + /// + public void RestoreGeometry(ConstructionGeometryDto snapshot) + { + foreach (Ellipse handle in pointHandles) + MeasurementCanvas.Children.Remove(handle); + pointHandles.Clear(); + + selectedPointIds.Clear(); + selectedLineId = null; + selectedCircleId = null; + + // Whatever was in flight refers to a state that no longer exists. + dragSnapshot = null; + editSnapshot = null; + transientHint = null; + HideBoundaryCandidate(); + + geometry.Clear(); + + foreach (ConstructionPointDto point in snapshot.Points) + geometry.AddPoint(point.Id, point.Position, point.Source, point.ParentAId, point.ParentBId); + + foreach (ConstructionLineDto line in snapshot.Lines) + geometry.AddLine(line.Id, line.StartPointId, line.EndPointId, line.IsExtended, line.ShowMeasurement); + + foreach (ConstructionCircleDto circle in snapshot.Circles) + geometry.AddCircle(circle.Id, circle.PointAId, circle.PointBId, circle.PointCId, circle.ShowMeasurement); + + Refresh(); + } + + private static bool GeometryEquals(ConstructionGeometryDto a, ConstructionGeometryDto b) + { + if (a.Points.Count != b.Points.Count || + a.Lines.Count != b.Lines.Count || + a.Circles.Count != b.Circles.Count) + return false; + + for (int i = 0; i < a.Points.Count; i++) + { + if (a.Points[i].Id != b.Points[i].Id || + a.Points[i].Position != b.Points[i].Position || + a.Points[i].Source != b.Points[i].Source || + a.Points[i].ParentAId != b.Points[i].ParentAId || + a.Points[i].ParentBId != b.Points[i].ParentBId) + return false; + } + + for (int i = 0; i < a.Lines.Count; i++) + { + if (a.Lines[i].Id != b.Lines[i].Id || + a.Lines[i].StartPointId != b.Lines[i].StartPointId || + a.Lines[i].EndPointId != b.Lines[i].EndPointId || + a.Lines[i].IsExtended != b.Lines[i].IsExtended || + a.Lines[i].ShowMeasurement != b.Lines[i].ShowMeasurement) + return false; + } + + for (int i = 0; i < a.Circles.Count; i++) + { + if (a.Circles[i].Id != b.Circles[i].Id || + a.Circles[i].PointAId != b.Circles[i].PointAId || + a.Circles[i].PointBId != b.Circles[i].PointBId || + a.Circles[i].PointCId != b.Circles[i].PointCId || + a.Circles[i].ShowMeasurement != b.Circles[i].ShowMeasurement) + return false; + } + + return true; + } + + #endregion + + #region Selection + + /// + /// True when anything is picked, so the host can tell whether Delete belongs to this + /// control or to some other selection elsewhere in the window. + /// + public bool HasSelection => + selectedPointIds.Count > 0 || selectedLineId is not null || selectedCircleId is not null; + + public int SelectedPointCount => selectedPointIds.Count; + + /// Position of the lone selected point, or null unless exactly one is picked. + public Point? SingleSelectedPointPosition => + selectedPointIds.Count == 1 ? geometry.FindPoint(selectedPointIds[0])?.Position : null; + + /// + /// When true, picking a second point connects it straight away instead of offering + /// the faint line. The Connect Points tool sets this, so its click-click flow and a + /// plain click on a point handle can never disagree about what happens next. + /// + public bool ConnectOnSecondSelection { get; set; } + + /// + /// The line the user is acting on: one they clicked, or — when two connected points + /// are picked — the line already joining them. That second case is what lets a + /// connection be broken by selecting its two ends. + /// + private Guid? EffectiveSelectedLineId + { + get + { + if (selectedLineId is Guid explicitId && geometry.FindLine(explicitId) is not null) + return explicitId; + + if (selectedPointIds.Count != 2) + return null; + + return geometry.FindLineBetween(selectedPointIds[0], selectedPointIds[1])?.Id; + } + } + + /// + /// The circle the user is acting on: one they clicked, or — when three points that + /// already define a circle are picked — that circle. Mirrors the line rule, so a + /// circle is removed by reselecting the three points that made it. + /// + private Guid? EffectiveSelectedCircleId + { + get + { + if (selectedCircleId is Guid explicitId && geometry.FindCircle(explicitId) is not null) + return explicitId; + + if (selectedPointIds.Count != 3) + return null; + + return geometry.FindCircleThrough( + selectedPointIds[0], selectedPointIds[1], selectedPointIds[2])?.Id; + } + } + + public void ClearSelection() + { + if (!HasSelection) return; + + selectedPointIds.Clear(); + selectedLineId = null; + selectedCircleId = null; + Refresh(); + } + + /// + /// Deletes whatever is selected, preferring the line or circle: breaking one of those + /// is the common case and it must not take the points with it. Returns true when + /// something was removed. + /// + public bool DeleteSelection() + { + if (EffectiveSelectedLineId is Guid lineId) + { + Edit(() => + { + geometry.RemoveLine(lineId); + selectedLineId = null; + PruneSelection(); + }); + return true; + } + + if (EffectiveSelectedCircleId is Guid circleId) + { + Edit(() => + { + geometry.RemoveCircle(circleId); + selectedCircleId = null; + PruneSelection(); + }); + return true; + } + + if (selectedPointIds.Count == 0) + return false; + + Edit(() => + { + foreach (Guid pointId in selectedPointIds.ToList()) + geometry.RemovePoint(pointId); + + selectedPointIds.Clear(); + PruneSelection(); + }); + return true; + } + + /// Picks a point, replacing the oldest once all three slots are full. + public void SelectPoint(Guid pointId) + { + if (geometry.FindPoint(pointId) is null) return; + + selectedLineId = null; + selectedCircleId = null; + + if (!selectedPointIds.Contains(pointId)) + { + selectedPointIds.Add(pointId); + + // Three points define the largest thing on offer, so beyond that the oldest + // drops out and the selection walks forward rather than starting over. + while (selectedPointIds.Count > MaxSelectedPoints) + selectedPointIds.RemoveAt(0); + } + + if (ConnectOnSecondSelection && TryConnectSelectedPoints()) + return; + + Refresh(); + } + + /// + /// Selects an existing point near , if there is one. + /// Lets a click that lands beside a point still count as picking it. + /// + public bool TrySelectPointNear(Point position, double tolerance) + { + if (geometry.FindPointNear(position, tolerance)?.Id is not Guid pointId) + return false; + + SelectPoint(pointId); + return true; + } + + /// + /// Turns the two selected points into a real line. Returns false when there are not + /// two of them, or when they are already joined. + /// + public bool TryConnectSelectedPoints() + { + if (selectedPointIds.Count != 2) return false; + + Guid startId = selectedPointIds[0]; + Guid endId = selectedPointIds[1]; + if (geometry.FindLineBetween(startId, endId) is not null) return false; + + Edit(() => + { + geometry.AddLine(startId, endId); + + // Leave the far end selected so the next point click chains straight into + // the following edge, the way walking a polygon actually goes. + selectedPointIds.Clear(); + selectedPointIds.Add(endId); + selectedLineId = null; + }); + + return true; + } + + /// + /// Turns the three selected points into a real circle. Returns false unless there + /// are three of them, they are not already circled, and they are not collinear — + /// three points in a straight line have no finite circle through them. + /// + public bool TryCircleSelectedPoints() + { + if (selectedPointIds.Count != 3) return false; + + Guid aId = selectedPointIds[0]; + Guid bId = selectedPointIds[1]; + Guid cId = selectedPointIds[2]; + + if (geometry.FindCircleThrough(aId, bId, cId) is not null) return false; + if (!TryGetSelectionCircle(out _, out _)) return false; + + Edit(() => + { + geometry.AddCircle(aId, bId, cId); + + // Nothing chains off a circle, so the selection is spent. + selectedPointIds.Clear(); + selectedLineId = null; + selectedCircleId = null; + }); + + return true; + } + + /// + /// The circle the three selected points would make. False when there are not three, + /// or when they are collinear. + /// + private bool TryGetSelectionCircle(out Point center, out double radius) + { + center = default; + radius = 0; + + if (selectedPointIds.Count != 3) return false; + + ConstructionPoint? a = geometry.FindPoint(selectedPointIds[0]); + ConstructionPoint? b = geometry.FindPoint(selectedPointIds[1]); + ConstructionPoint? c = geometry.FindPoint(selectedPointIds[2]); + if (a is null || b is null || c is null) return false; + + return GeometryMathHelper.TryGetCircumcircle( + a.Position, b.Position, c.Position, out center, out radius); + } + + /// Drops selection entries whose point, line, or circle has since been deleted. + private void PruneSelection() + { + selectedPointIds.RemoveAll(id => geometry.FindPoint(id) is null); + + if (selectedLineId is Guid lineId && geometry.FindLine(lineId) is null) + selectedLineId = null; + + if (selectedCircleId is Guid circleId && geometry.FindCircle(circleId) is null) + selectedCircleId = null; + } + + #endregion + + #region Rendering + + /// Re-solves and redraws everything. Cheap enough to run on every mouse move. + public void Refresh() + { + // Kept derived points track their parents, so they must be re-fitted before + // anything reads a position. + geometry.RefreshDerivedPoints(); + + Solve(); + SolveFaces(); + RenderLines(); + RenderCircles(); + RenderPoints(); + RenderDerivedCandidates(); + RenderGhostLine(); + RenderShape(); + RenderFaces(); + RenderMeasurementLabels(); + UpdateDisplay(); + ConstructionChanged?.Invoke(this, EventArgs.Empty); + } + + private void Solve() + { + List<(Guid Id, Point Start, Point End)> lines = geometry.GetResolvedLines(); + List positions = [.. geometry.Points.Select(p => p.Position)]; + + ConstructionSolver.SolveResult result = ConstructionSolver.Solve(lines, positions); + solveStatus = result.Status; + solvedRing = result.Ring; + } + + /// + /// Re-derives every bounded cell of the arrangement. Selection is kept across the + /// re-solve by matching on each face's centroid rather than its list index, since nothing + /// guarantees a face keeps the same index once the geometry it comes from changes. + /// + private void SolveFaces() + { + List<(Guid Id, Point Start, Point End)> lines = geometry.GetResolvedLines(); + List newFaces = lines.Count >= 3 + ? ConstructionFaceSolver.SolveFaces(lines, GetConstructionBounds()) + : []; + + if (selectedFaceIndices.Count > 0) + { + HashSet<(long X, long Y)> selectedCentroids = []; + foreach (int index in selectedFaceIndices) + { + if (index < 0 || index >= faces.Count) continue; + selectedCentroids.Add(CentroidKey(faces[index].Ring)); + } + + selectedFaceIndices.Clear(); + for (int i = 0; i < newFaces.Count; i++) + { + if (selectedCentroids.Contains(CentroidKey(newFaces[i].Ring))) + selectedFaceIndices.Add(i); + } + } + + faces = newFaces; + } + + private static (long X, long Y) CentroidKey(IReadOnlyList ring) + { + Point centroid = ConstructionSolver.Centroid(ring); + return ((long)Math.Round(centroid.X), (long)Math.Round(centroid.Y)); + } + + /// + /// Region extended lines may run through: the image, the placed points, and any solved + /// corners, inflated a little so a corner just outside is still comfortably visible. + /// + private Rect GetConstructionBounds() + { + Rect bounds = ImageBounds; + + foreach (ConstructionPoint point in geometry.Points) + bounds.Union(point.Position); + + foreach (Point corner in solvedRing) + { + if (double.IsNaN(corner.X) || double.IsNaN(corner.Y)) continue; + bounds.Union(corner); + } + + if (bounds.Width <= 0 || bounds.Height <= 0) + bounds = new Rect(0, 0, 1000, 1000); + + bounds.Inflate(bounds.Width * BoundsInflation, bounds.Height * BoundsInflation); + return bounds; + } + + private void RenderLines() + { + foreach (Path path in linePaths) + MeasurementCanvas.Children.Remove(path); + linePaths.Clear(); + + foreach (Path path in hitPaths) + MeasurementCanvas.Children.Remove(path); + hitPaths.Clear(); + + Rect bounds = GetConstructionBounds(); + Guid? highlightedLineId = EffectiveSelectedLineId; + + foreach (ConstructionLine line in geometry.Lines) + { + ConstructionPoint? start = geometry.FindPoint(line.StartPointId); + ConstructionPoint? end = geometry.FindPoint(line.EndPointId); + if (start is null || end is null) continue; + + bool isSelected = line.Id == highlightedLineId; + + if (line.IsExtended && + TryClipToBounds(start.Position, end.Position, bounds, out Point clipStart, out Point clipEnd)) + { + // Dashed past the two owned points reads as "inferred"; the corner it + // makes with a neighbouring edge is what the user is actually aiming at. + AddLinePath(BuildLinePath(clipStart, clipEnd, dashed: true, isSelected), line.Id); + } + + AddLinePath(BuildLinePath(start.Position, end.Position, dashed: false, isSelected), line.Id); + + Path hitPath = BuildHitPath(start.Position, end.Position, bounds, line); + Panel.SetZIndex(hitPath, HitZIndex); + hitPaths.Add(hitPath); + MeasurementCanvas.Children.Add(hitPath); + } + } + + private Path BuildLinePath(Point from, Point to, bool dashed, bool isSelected) + { + Path path = new() + { + Stroke = isSelected ? SelectionBrush : LineBrush, + StrokeThickness = BaseStrokeThickness * (isSelected ? 2 : 1) * visualScale, + Opacity = dashed ? 0.55 : 0.9, + IsHitTestVisible = false, + Data = new LineGeometry(from, to) + }; + + if (dashed) + path.StrokeDashArray = [6, 4]; + + return path; + } + + /// + /// An invisible thick path so a 2px line is comfortable to right-click. + /// + private Path BuildHitPath(Point from, Point to, Rect bounds, ConstructionLine line) + { + Point hitFrom = from; + Point hitTo = to; + + if (line.IsExtended && TryClipToBounds(from, to, bounds, out Point clipStart, out Point clipEnd)) + { + hitFrom = clipStart; + hitTo = clipEnd; + } + + Path path = new() + { + Stroke = Brushes.Transparent, + StrokeThickness = HitStrokeThickness * visualScale, + Cursor = Cursors.Hand, + Tag = line.Id, + Data = new LineGeometry(hitFrom, hitTo), + ToolTip = "Click to select this line, then press Delete to remove it" + }; + + path.MouseDown += LineHitPath_MouseDown; + path.ContextMenu = BuildLineContextMenu(line); + return path; + } + + /// + /// The faint shape the current selection is offering: a line between two selected + /// points, or the circle through three. Clicking it is what turns the offer into a + /// real one, so building never competes with dragging. + /// + private void RenderGhostLine() + { + if (ghostLinePath is not null) MeasurementCanvas.Children.Remove(ghostLinePath); + if (ghostHitPath is not null) MeasurementCanvas.Children.Remove(ghostHitPath); + ghostLinePath = null; + ghostHitPath = null; + + if (TryBuildGhostGeometry() is not (Geometry shape, string tooltip)) + return; + + Visibility visibility = areDragGizmosVisible ? Visibility.Visible : Visibility.Collapsed; + + ghostLinePath = new Path + { + Stroke = SelectionBrush, + StrokeThickness = BaseStrokeThickness * visualScale, + StrokeDashArray = [4, 3], + Opacity = 0.45, + IsHitTestVisible = false, + Data = shape, + Visibility = visibility + }; + Panel.SetZIndex(ghostLinePath, LineZIndex); + MeasurementCanvas.Children.Add(ghostLinePath); + + ghostHitPath = new Path + { + Stroke = Brushes.Transparent, + StrokeThickness = HitStrokeThickness * visualScale, + Cursor = Cursors.Hand, + Data = shape, + ToolTip = tooltip, + Visibility = visibility + }; + ghostHitPath.MouseDown += GhostLine_MouseDown; + + // Below the point handles so the defining points stay grabbable. + Panel.SetZIndex(ghostHitPath, HitZIndex); + MeasurementCanvas.Children.Add(ghostHitPath); + } + + /// + /// What the selection is currently offering to build, or null when it is offering + /// nothing — too few points, a pair or triple that already exists, or three points + /// in a straight line. + /// + private (Geometry Shape, string ToolTip)? TryBuildGhostGeometry() + { + if (selectedPointIds.Count == 2) + { + ConstructionPoint? start = geometry.FindPoint(selectedPointIds[0]); + ConstructionPoint? end = geometry.FindPoint(selectedPointIds[1]); + if (start is null || end is null) return null; + + // Already joined — the existing line is highlighted instead, and a second + // line between the same two points would only duplicate it. + if (geometry.FindLineBetween(start.Id, end.Id) is not null) return null; + + return (new LineGeometry(start.Position, end.Position), + "Click to connect these two points"); + } + + if (selectedPointIds.Count == 3) + { + if (geometry.FindCircleThrough( + selectedPointIds[0], selectedPointIds[1], selectedPointIds[2]) is not null) + return null; + + if (!TryGetSelectionCircle(out Point center, out double radius)) + return null; + + return (new EllipseGeometry(center, radius, radius), + "Click to draw the circle through these three points"); + } + + return null; + } + + /// + /// Draws each circle from the centre and radius its three points imply. A circle + /// whose points have drifted into a straight line simply drops out until they are + /// moved apart again — it is re-fitted from the points on every refresh. + /// + private void RenderCircles() + { + foreach (Path path in circlePaths) + MeasurementCanvas.Children.Remove(path); + circlePaths.Clear(); + + foreach (Path path in circleHitPaths) + MeasurementCanvas.Children.Remove(path); + circleHitPaths.Clear(); + + Guid? highlightedCircleId = EffectiveSelectedCircleId; + + foreach ((Guid id, Point center, double radius) in geometry.GetResolvedCircles()) + { + bool isSelected = id == highlightedCircleId; + + Path path = new() + { + Stroke = isSelected ? SelectionBrush : LineBrush, + StrokeThickness = BaseStrokeThickness * (isSelected ? 2 : 1) * visualScale, + Opacity = 0.9, + IsHitTestVisible = false, + Data = new EllipseGeometry(center, radius, radius) + }; + Panel.SetZIndex(path, LineZIndex); + circlePaths.Add(path); + MeasurementCanvas.Children.Add(path); + + Path hitPath = new() + { + Stroke = Brushes.Transparent, + StrokeThickness = HitStrokeThickness * visualScale, + Cursor = Cursors.Hand, + Tag = id, + Data = new EllipseGeometry(center, radius, radius), + ToolTip = "Click to select this circle, then press Delete to remove it", + ContextMenu = BuildCircleContextMenu(id) + }; + hitPath.MouseDown += CircleHitPath_MouseDown; + Panel.SetZIndex(hitPath, HitZIndex); + circleHitPaths.Add(hitPath); + MeasurementCanvas.Children.Add(hitPath); + } + } + + private ContextMenu BuildCircleContextMenu(Guid circleId) + { + ContextMenu menu = new(); + + MenuItem showMeasurement = new() + { + Header = "Show Measurement", + IsCheckable = true, + IsChecked = IsCircleMeasurementVisible(circleId), + Tag = circleId, + ToolTip = "Label this circle with its radius, circumference, and area" + }; + showMeasurement.Click += CircleMeasurementMenuItem_Click; + + MenuItem delete = new() + { + Header = "Delete Circle", + Tag = circleId, + ToolTip = "Delete this circle (its three points are kept)" + }; + delete.Click += CircleDeleteMenuItem_Click; + + menu.Items.Add(showMeasurement); + menu.Items.Add(delete); + return menu; + } + + private ContextMenu BuildLineContextMenu(ConstructionLine line) + { + ContextMenu menu = new(); + + MenuItem extend = new() + { + Header = "Extend to construction edges", + IsCheckable = true, + IsChecked = line.IsExtended, + Tag = line.Id, + ToolTip = "Draw this line past its points so the corners it forms are visible" + }; + extend.Click += LineExtendMenuItem_Click; + + MenuItem showMeasurement = new() + { + Header = "Show Measurement", + IsCheckable = true, + IsChecked = line.ShowMeasurement, + Tag = line.Id, + ToolTip = "Label this line with the distance between its two points" + }; + showMeasurement.Click += LineMeasurementMenuItem_Click; + + MenuItem delete = new() + { + Header = "Delete Line", + Tag = line.Id, + ToolTip = "Delete this line (its points are kept)" + }; + delete.Click += LineDeleteMenuItem_Click; + + menu.Items.Add(extend); + menu.Items.Add(showMeasurement); + menu.Items.Add(delete); + return menu; + } + + private void AddLinePath(Path path, Guid lineId) + { + path.Tag = lineId; + Panel.SetZIndex(path, LineZIndex); + linePaths.Add(path); + MeasurementCanvas.Children.Add(path); + } + + /// + /// Liang-Barsky clip of the infinite line through two points against a rectangle. + /// + private static bool TryClipToBounds(Point a, Point b, Rect bounds, out Point start, out Point end) + { + start = a; + end = b; + + double dx = b.X - a.X; + double dy = b.Y - a.Y; + + if (Math.Abs(dx) < 1e-9 && Math.Abs(dy) < 1e-9) return false; + + double tMin = double.NegativeInfinity; + double tMax = double.PositiveInfinity; + + (double p, double q)[] edges = + [ + (-dx, a.X - bounds.Left), + (dx, bounds.Right - a.X), + (-dy, a.Y - bounds.Top), + (dy, bounds.Bottom - a.Y) + ]; + + foreach ((double p, double q) in edges) + { + if (Math.Abs(p) < 1e-9) + { + if (q < 0) return false; // Parallel to this edge and outside it. + continue; + } + + double t = q / p; + if (p < 0) tMin = Math.Max(tMin, t); + else tMax = Math.Min(tMax, t); + } + + if (tMin >= tMax) return false; + + start = new Point(a.X + (tMin * dx), a.Y + (tMin * dy)); + end = new Point(a.X + (tMax * dx), a.Y + (tMax * dy)); + return true; + } + + private void RenderPoints() + { + // Rebuild handles only when the count changed; otherwise just reposition, so a + // drag does not churn the visual tree on every mouse move. + if (pointHandles.Count != geometry.Points.Count) + { + foreach (Ellipse handle in pointHandles) + MeasurementCanvas.Children.Remove(handle); + pointHandles.Clear(); + + for (int i = 0; i < geometry.Points.Count; i++) + CreatePointHandle(i); + } + + for (int i = 0; i < pointHandles.Count; i++) + { + // Tags carry the list index for the host's drag pipeline, so they must be + // rewritten after any removal shifts the list. + pointHandles[i].Tag = i.ToString(); + ApplyPointAppearance(i); + } + } + + /// + /// Sizes and styles one handle for its current selection state, and positions it. + /// A selected point reads as a distinctly different object — bigger, orange, and + /// carrying the move cursor — because only a selected point can be dragged. + /// + private void ApplyPointAppearance(int index) + { + if (index < 0 || index >= pointHandles.Count || index >= geometry.Points.Count) return; + + ConstructionPoint point = geometry.Points[index]; + Ellipse handle = pointHandles[index]; + + bool isSelected = selectedPointIds.Contains(point.Id); + double size = CurrentPointSize() * (isSelected ? SelectedPointScale : 1.0); + + handle.Width = size; + handle.Height = size; + handle.StrokeThickness = (isSelected ? 2 : 1) * visualScale; + handle.Opacity = isSelected ? 1.0 : 0.85; + + if (point.IsDerived) + { + // Hollow, so a computed point never looks like one you can drag. + handle.Fill = Brushes.White; + handle.Stroke = isSelected ? SelectionBrush : PointBrush; + handle.StrokeThickness = (isSelected ? 3 : 2) * visualScale; + handle.Cursor = Cursors.Hand; + handle.ToolTip = point.Source == ConstructionPointSource.CircleCenter + ? "Circle centre. Follows its circle; cannot be dragged." + : "Line crossing. Follows its lines; cannot be dragged."; + } + else + { + handle.Fill = isSelected ? SelectionBrush : PointBrush; + handle.Stroke = Brushes.White; + handle.Cursor = isSelected ? Cursors.SizeAll : Cursors.Hand; + handle.ToolTip = isSelected + ? "Drag to move. Select another point to connect them, or two more for a circle." + : "Click to select. A point only moves once selected."; + } + + PositionHandle(handle, point.Position); + } + + private void CreatePointHandle(int index) + { + double size = CurrentPointSize(); + + Ellipse handle = new() + { + Width = size, + Height = size, + Fill = PointBrush, + Stroke = Brushes.White, + StrokeThickness = 1 * visualScale, + Opacity = 0.85, + Cursor = Cursors.Hand, + Tag = index.ToString(), + ToolTip = "Click to select. A point only moves once selected.", + Visibility = areDragGizmosVisible ? Visibility.Visible : Visibility.Collapsed + }; + + handle.MouseDown += PointHandle_MouseDown; + handle.ContextMenu = BuildPointContextMenu(); + Panel.SetZIndex(handle, PointZIndex); + + pointHandles.Add(handle); + MeasurementCanvas.Children.Add(handle); + } + + /// + /// Draws the crossings and centres the construction implies as faint hollow rings. + /// They are offers, not geometry: they follow the lines and circles that produce + /// them, and vanish with them unless the user clicks one to keep it. + /// + private void RenderDerivedCandidates() + { + foreach (Ellipse handle in candidateHandles) + MeasurementCanvas.Children.Remove(handle); + candidateHandles.Clear(); + + derivedCandidates = []; + + // They are gizmos, so an export must not show them. + if (!areDragGizmosVisible) return; + + Rect bounds = GetConstructionBounds(); + + foreach (DerivedPointCandidate candidate in + geometry.GetDerivedCandidates(CandidateMergeTolerance)) + { + // A pair of near-parallel lines crosses somewhere far away that the user is + // not looking at and cannot reach. + if (!bounds.Contains(candidate.Position)) continue; + + derivedCandidates.Add(candidate); + CreateCandidateHandle(derivedCandidates.Count - 1, candidate); + } + } + + /// + /// How close a candidate has to be to a real point to count as the same place. + /// Divided by the zoom so it means a constant distance on screen. + /// + private double CandidateMergeTolerance => 8 * visualScale; + + private void CreateCandidateHandle(int index, DerivedPointCandidate candidate) + { + double size = BasePointSize * 0.85 * visualScale; + + Ellipse handle = new() + { + Width = size, + Height = size, + + // Transparent rather than null: it still takes the click, so the whole disc + // is a target and not just the ring. + Fill = Brushes.Transparent, + Stroke = LineBrush, + StrokeThickness = 1.5 * visualScale, + StrokeDashArray = [2, 2], + Opacity = 0.6, + Cursor = Cursors.Hand, + Tag = index, + ToolTip = candidate.Source == ConstructionPointSource.CircleCenter + ? "Circle centre — click to keep it as a point" + : "Where two lines cross — click to keep it as a point" + }; + + handle.MouseDown += CandidateHandle_MouseDown; + Panel.SetZIndex(handle, CandidateZIndex); + + candidateHandles.Add(handle); + MeasurementCanvas.Children.Add(handle); + + Canvas.SetLeft(handle, candidate.Position.X - (size / 2)); + Canvas.SetTop(handle, candidate.Position.Y - (size / 2)); + } + + private ContextMenu BuildPointContextMenu() + { + ContextMenu menu = new(); + + MenuItem deselect = new() + { + Header = "Clear Selection", + ToolTip = "Deselect all points and lines" + }; + deselect.Click += ClearSelectionMenuItem_Click; + + MenuItem delete = new() + { + Header = "Delete Point", + ToolTip = "Delete this point and any lines that use it" + }; + delete.Click += PointDeleteMenuItem_Click; + + menu.Items.Add(deselect); + menu.Items.Add(delete); + return menu; + } + + private Ellipse CreateBoundaryCandidate() + { + Ellipse marker = new() + { + // A preview of where the click will land, so it must never eat the click. + IsHitTestVisible = false + }; + + Panel.SetZIndex(marker, PointZIndex); + MeasurementCanvas.Children.Add(marker); + return marker; + } + + private void ApplyBoundaryCandidateAppearance() + { + if (boundaryCandidate is null) return; + + if (boundaryCandidatePosition is not Point position || !areDragGizmosVisible) + { + boundaryCandidate.Visibility = Visibility.Collapsed; + return; + } + + double size = CurrentPointSize() * SelectedPointScale; + + boundaryCandidate.Width = size; + boundaryCandidate.Height = size; + boundaryCandidate.Stroke = SelectionBrush; + boundaryCandidate.Visibility = Visibility.Visible; + + if (boundaryCandidateIsWeak) + { + boundaryCandidate.Fill = null; + boundaryCandidate.StrokeThickness = 2 * visualScale; + boundaryCandidate.StrokeDashArray = [2, 2]; + } + else + { + boundaryCandidate.Fill = SelectionBrush; + boundaryCandidate.Stroke = Brushes.White; + boundaryCandidate.StrokeThickness = 2 * visualScale; + boundaryCandidate.StrokeDashArray = null; + } + + PositionHandle(boundaryCandidate, position); + } + + private double CurrentPointSize() => + (areEndpointCapsVisible ? BaseSmallPointSize : BasePointSize) * visualScale; + + private static void PositionHandle(Ellipse handle, Point position) + { + Canvas.SetLeft(handle, position.X - (handle.Width / 2)); + Canvas.SetTop(handle, position.Y - (handle.Height / 2)); + } + + private void ApplyPointSizes() + { + for (int i = 0; i < pointHandles.Count && i < geometry.Points.Count; i++) + ApplyPointAppearance(i); + } + + /// + /// Counter-scales handles and strokes against the canvas zoom so gizmos keep a + /// constant size on screen. Mirrors MainWindow.UpdateTransformVisualScale. + /// + public void UpdateVisualScale(double inverseScale) + { + visualScale = inverseScale <= 0 ? 1.0 : inverseScale; + + ApplyPointSizes(); + ShapePath.StrokeThickness = BaseStrokeThickness * visualScale; + PreviewLine.StrokeThickness = BaseStrokeThickness * visualScale; + + // Rebuilt rather than patched in place: a selected line or circle carries a + // different thickness, so there is no single multiplier to reapply here. + RenderLines(); + RenderCircles(); + RenderDerivedCandidates(); + RenderGhostLine(); + RenderFaces(); + RenderMeasurementLabels(); + ApplyBoundaryCandidateAppearance(); + + MeasurementText.RenderTransformOrigin = new Point(0.5, 0.5); + MeasurementText.RenderTransform = new ScaleTransform(visualScale, visualScale); + } + + private void RenderShape() + { + if (solvedRing.Count < 3) + { + ShapePath.Data = null; + return; + } + + PathFigure figure = new() { StartPoint = solvedRing[0], IsClosed = true, IsFilled = true }; + for (int i = 1; i < solvedRing.Count; i++) + figure.Segments.Add(new LineSegment(solvedRing[i], true)); + + PathGeometry pathGeometry = new(); + pathGeometry.Figures.Add(figure); + ShapePath.Data = pathGeometry; + } + + /// + /// Draws a hit-testable, mostly-invisible fill over every bounded cell so it can be + /// hovered and clicked. Rebuilt wholesale each refresh, same as the lines above — the + /// cells themselves can appear, disappear, split, or merge as the geometry changes, so + /// there is no single element per cell to patch in place. + /// + private void RenderFaces() + { + foreach (Path path in facePaths) + MeasurementCanvas.Children.Remove(path); + facePaths.Clear(); + + for (int i = 0; i < faces.Count; i++) + { + ConstructionFace face = faces[i]; + + PathFigure figure = new() { StartPoint = face.Ring[0], IsClosed = true, IsFilled = true }; + for (int v = 1; v < face.Ring.Count; v++) + figure.Segments.Add(new LineSegment(face.Ring[v], true)); + + PathGeometry pathGeometry = new(); + pathGeometry.Figures.Add(figure); + + Path path = new() + { + Data = pathGeometry, + StrokeThickness = 1.5 * visualScale, + Cursor = Cursors.Hand, + Tag = i, + IsHitTestVisible = isFaceSelectionModeActive, + ToolTip = "Click to select this shape. Selecting shapes that border each other merges them into one polygon." + }; + + Panel.SetZIndex(path, FaceZIndex); + path.MouseEnter += FacePath_MouseEnter; + path.MouseLeave += FacePath_MouseLeave; + path.MouseDown += FacePath_MouseDown; + + facePaths.Add(path); + MeasurementCanvas.Children.Add(path); + } + + UpdateFaceVisuals(); + } + + private void FacePath_MouseEnter(object sender, MouseEventArgs e) + { + if (!isFaceSelectionModeActive || sender is not Path { Tag: int index }) return; + + hoveredFaceIndex = index; + UpdateFaceVisuals(); + } + + private void FacePath_MouseLeave(object sender, MouseEventArgs e) + { + if (sender is not Path { Tag: int index }) return; + + if (hoveredFaceIndex == index) + hoveredFaceIndex = null; + + UpdateFaceVisuals(); + } + + private void FacePath_MouseDown(object sender, MouseButtonEventArgs e) + { + if (!isFaceSelectionModeActive || sender is not Path { Tag: int index }) return; + + if (!selectedFaceIndices.Remove(index)) + selectedFaceIndices.Add(index); + + UpdateFaceVisuals(); + FaceSelectionChanged?.Invoke(this, EventArgs.Empty); + e.Handled = true; + } + + private void UpdateFaceVisuals() + { + for (int i = 0; i < facePaths.Count; i++) + { + bool isSelected = selectedFaceIndices.Contains(i); + bool isHovered = hoveredFaceIndex == i; + + facePaths[i].Fill = isSelected ? FaceSelectedBrush : isHovered ? FaceHoverBrush : FaceIdleBrush; + facePaths[i].Stroke = isSelected ? SelectionBrush : Brushes.Transparent; + } + } + + /// + /// Draws a readout beside every line and circle the user has opted into. Rebuilt + /// wholesale each refresh: the text depends on positions that move on every drag, + /// so patching individual labels would only duplicate the work of recreating them. + /// + private void RenderMeasurementLabels() + { + foreach (Border label in measurementLabels) + MeasurementCanvas.Children.Remove(label); + measurementLabels.Clear(); + + foreach (ConstructionLine line in geometry.Lines) + { + if (!line.ShowMeasurement) continue; + + ConstructionPoint? start = geometry.FindPoint(line.StartPointId); + ConstructionPoint? end = geometry.FindPoint(line.EndPointId); + if (start is null || end is null) continue; + + double length = GeometryMathHelper.Distance(start.Position, end.Position) * ScaleFactor; + Point midpoint = new( + (start.Position.X + end.Position.X) / 2, + (start.Position.Y + end.Position.Y) / 2); + + AddMeasurementLabel($"{length:N2} {Units}", midpoint); + } + + foreach ((Guid id, Point center, double radius) in geometry.GetResolvedCircles()) + { + if (geometry.FindCircle(id)?.ShowMeasurement != true) continue; + + AddMeasurementLabel(BuildCircleText(radius), center); + } + } + + /// + /// Places a readout centred just above . The label is + /// measured up front because it is created fresh each refresh and so has no + /// ActualWidth to centre on yet. + /// + private void AddMeasurementLabel(string text, Point anchor) + { + Border label = new() + { + Padding = new Thickness(4, 1, 4, 1), + Background = new SolidColorBrush(Color.FromArgb(0x7F, 0, 0, 0)), + CornerRadius = new CornerRadius(3), + + // Purely a readout: it must never intercept a click aimed at the geometry. + IsHitTestVisible = false, + Child = new TextBlock + { + Text = text, + FontWeight = FontWeights.Bold, + Foreground = Brushes.White, + TextAlignment = TextAlignment.Center + } + }; + + label.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + Size size = label.DesiredSize; + + label.RenderTransformOrigin = new Point(0.5, 0.5); + label.RenderTransform = new ScaleTransform(visualScale, visualScale); + + Canvas.SetLeft(label, anchor.X - (size.Width / 2)); + Canvas.SetTop(label, anchor.Y - (size.Height / 2)); + + Panel.SetZIndex(label, TextZIndex); + measurementLabels.Add(label); + MeasurementCanvas.Children.Add(label); + } + + #endregion + + #region Readout + + private void UpdateDisplay() + { + ConstructionTextBlock.Text = BuildMeasurementText(); + PositionMeasurementText(); + } + + private string BuildMeasurementText() + { + string primary = BuildPrimaryText(); + string? hint = BuildSelectionHint(); + + return hint is null ? primary : $"{primary}\n{hint}"; + } + + private string BuildPrimaryText() + { + // A picked circle is what the user is looking at, so it wins the readout. + if (EffectiveSelectedCircleId is Guid selectedId && + TryGetResolvedCircleRadius(selectedId, out double selectedRadius)) + return BuildCircleText(selectedRadius); + + if (solvedRing.Count >= 3) + { + // The readout is hidden but the label is not: it stays as a small anchor so + // its context menu is still reachable to switch the measurement back on. + if (!showShapeMeasurement) + return "Construction"; + + double perimeter = GeometryMathHelper.PolygonPerimeter(solvedRing, isClosed: true) * ScaleFactor; + double area = GeometryMathHelper.PolygonArea(solvedRing) * ScaleFactor * ScaleFactor; + return MeasurementFormattingHelper.FormatPerimeterArea(perimeter, area, Units); + } + + // No derived shape to report on, but a circle is a measurement in its own right. + if (geometry.Lines.Count == 0 && + geometry.GetResolvedCircles() is { Count: > 0 } circles) + return BuildCircleText(circles[0].Radius); + + return solveStatus switch + { + ConstructionSolver.SolveStatus.NotEnoughLines when geometry.Lines.Count == 0 && geometry.Points.Count > 0 => + "Click a point to select it", + ConstructionSolver.SolveStatus.NotEnoughLines when geometry.Lines.Count == 0 => + "Drag along an edge to add a line", + ConstructionSolver.SolveStatus.NotEnoughLines => + $"Add {3 - geometry.Lines.Count} more line(s) to form a shape", + ConstructionSolver.SolveStatus.NoUsableCorners => + "Lines are parallel — no corner formed", + ConstructionSolver.SolveStatus.SelfIntersecting => + "Lines cross — check the construction", + ConstructionSolver.SolveStatus.Degenerate => + "Shape has collapsed — move a point", + _ => "No shape yet" + }; + } + + /// + /// Radius of one circle as currently fitted, or false when its points have gone + /// collinear and it has no finite circle right now. + /// + private bool TryGetResolvedCircleRadius(Guid circleId, out double radius) + { + foreach ((Guid id, Point _, double resolvedRadius) in geometry.GetResolvedCircles()) + { + if (id != circleId) continue; + + radius = resolvedRadius; + return true; + } + + radius = 0; + return false; + } + + /// Matches the standalone circle measurement tool's readout. + private string BuildCircleText(double radius) + { + double scaledRadius = radius * ScaleFactor; + double circumference = 2 * Math.PI * scaledRadius; + double area = Math.PI * scaledRadius * scaledRadius; + + return $"r: {scaledRadius:N2} {Units}, C: {circumference:N2} {Units}, A: {area:N2} {Units}²"; + } + + /// + /// Narrates the build gesture. The faint shape is only discoverable if something + /// says it is there, so the readout doubles as the prompt for the next step. + /// + private string? BuildSelectionHint() + { + // A note about the gesture that just happened outranks a prompt for the next one. + if (transientHint is not null) + return transientHint; + + if (selectedLineId is not null) + return "Line selected — press Delete to remove it"; + + if (selectedCircleId is not null) + return "Circle selected — press Delete to remove it"; + + return selectedPointIds.Count switch + { + 1 => "Select a second point to connect them", + 2 when EffectiveSelectedLineId is not null => + "Already connected — press Delete to disconnect, or select a third point for a circle", + 2 => "Click the faint line to connect them, or select a third point for a circle", + 3 when EffectiveSelectedCircleId is not null => + "Already circled — press Delete to remove the circle", + 3 when !TryGetSelectionCircle(out _, out _) => + "These three points are in a straight line — no circle through them", + 3 => "Click the faint circle to draw it", + _ => null + }; + } + + private void PositionMeasurementText() + { + Point centre = solvedRing.Count >= 3 + ? ConstructionSolver.Centroid(solvedRing) + : ConstructionSolver.Centroid([.. geometry.Points.Select(p => p.Position)]); + + if (geometry.Points.Count == 0 && solvedRing.Count == 0) + { + MeasurementText.Visibility = Visibility.Collapsed; + return; + } + + MeasurementText.Visibility = Visibility.Visible; + Canvas.SetLeft(MeasurementText, centre.X - (MeasurementText.ActualWidth / 2)); + Canvas.SetTop(MeasurementText, centre.Y - MeasurementText.ActualHeight - (10 * visualScale)); + } + + #endregion + + #region Input + + private void PointHandle_MouseDown(object sender, MouseButtonEventArgs e) + { + if (sender is not Ellipse handle || handle.Tag is not string indexString) return; + + // Right-click belongs to the context menu. + if (e.ChangedButton != MouseButton.Left) return; + + if (!int.TryParse(indexString, out int index)) return; + if (index < 0 || index >= geometry.Points.Count) return; + + Guid pointId = geometry.Points[index].Id; + + // Selection has to come first: without it, aiming at a point to pick it nudges + // the point instead, which makes connecting two points nearly impossible. + if (!selectedPointIds.Contains(pointId)) + { + SelectPoint(pointId); + e.Handled = true; + return; + } + + // A derived point's position is computed from its parents, so there is nothing + // to drag — move the geometry that defines it instead. + if (geometry.Points[index].IsDerived) + { + e.Handled = true; + return; + } + + pointDraggingIndex = index; + MeasurementPointMouseDown?.Invoke(sender, e); + e.Handled = true; + } + + private void CandidateHandle_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton != MouseButton.Left) return; + if (sender is not Ellipse handle || handle.Tag is not int index) return; + if (index < 0 || index >= derivedCandidates.Count) return; + + // Captured before the edit, which rebuilds the candidate list underneath us. + DerivedPointCandidate candidate = derivedCandidates[index]; + + e.Handled = true; + + Guid keptId = Guid.Empty; + Edit(() => keptId = geometry.KeepDerivedPoint(candidate)); + + // Keeping it is also picking it, so it can go straight into a line or a circle. + if (keptId != Guid.Empty) + SelectPoint(keptId); + } + + private void GhostLine_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton != MouseButton.Left) return; + + e.Handled = true; + + // Whichever of the two the selection is offering; only one can apply, since they + // need a different number of points. + if (!TryConnectSelectedPoints()) + TryCircleSelectedPoints(); + } + + private void LineHitPath_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton != MouseButton.Left) return; + if (sender is not Path path || path.Tag is not Guid lineId) return; + + // Toggle, so a mis-click on a line is undone by clicking it again. + selectedLineId = selectedLineId == lineId ? null : lineId; + selectedCircleId = null; + selectedPointIds.Clear(); + + e.Handled = true; + Refresh(); + } + + private void CircleHitPath_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton != MouseButton.Left) return; + if (sender is not Path path || path.Tag is not Guid circleId) return; + + selectedCircleId = selectedCircleId == circleId ? null : circleId; + selectedLineId = null; + selectedPointIds.Clear(); + + e.Handled = true; + Refresh(); + } + + private void CircleDeleteMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem item || item.Tag is not Guid circleId) return; + + RemoveConstructionCircle(circleId); + } + + private void ClearSelectionMenuItem_Click(object sender, RoutedEventArgs e) => ClearSelection(); + + private void LineExtendMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem item || item.Tag is not Guid lineId) return; + + SetLineExtended(lineId, item.IsChecked); + } + + private void LineMeasurementMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem item || item.Tag is not Guid lineId) return; + + SetLineMeasurementVisible(lineId, item.IsChecked); + } + + private void CircleMeasurementMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem item || item.Tag is not Guid circleId) return; + + SetCircleMeasurementVisible(circleId, item.IsChecked); + } + + private void LineDeleteMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem item || item.Tag is not Guid lineId) return; + + RemoveConstructionLine(lineId); + } + + private void PointDeleteMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem item) return; + + // The menu's placement target is the handle it was opened from. + if (item.Parent is not ContextMenu menu || menu.PlacementTarget is not Ellipse handle) return; + if (handle.Tag is not string indexString || !int.TryParse(indexString, out int index)) return; + if (index < 0 || index >= geometry.Points.Count) return; + + RemoveConstructionPoint(geometry.Points[index].Id); + } + + private void CopyMeasurementMenuItem_Click(object sender, RoutedEventArgs e) => + Clipboard.SetText(ConstructionTextBlock.Text); + + private void ShapeMeasurementMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem item) return; + + ShowShapeMeasurement = item.IsChecked; + } + + private void MeasurementContextMenu_Opened(object sender, RoutedEventArgs e) => + ShowShapeMeasurementMenuItem.IsChecked = showShapeMeasurement; + + private void MeasurementButton_Click(object sender, RoutedEventArgs e) + { + ContextMenu? contextMenu = MeasurementText.ContextMenu; + if (contextMenu is null) return; + + contextMenu.PlacementTarget = MeasurementText; + contextMenu.IsOpen = true; + e.Handled = true; + } + + private void RemoveMeasurementMenuItem_Click(object sender, RoutedEventArgs e) => + RemoveControlRequested?.Invoke(this, EventArgs.Empty); + + private async void ChangeColorMenuItem_Click(object sender, RoutedEventArgs e) + { + if (Application.Current.MainWindow is not MainWindow mainWindow) + return; + + Color? picked = await ColorPickerDialog.PickColorAsync(mainWindow, strokeColor, "Change Construction Color"); + if (picked is Color color) + StrokeColor = color; + } + + #endregion + + #region Persistence + + public ConstructionGeometryDto ToDto() + { + // The saved form is a geometry snapshot plus the readout settings the snapshot + // deliberately leaves out. + ConstructionGeometryDto dto = CaptureGeometry(); + dto.ScaleFactor = ScaleFactor; + dto.Units = Units; + dto.ShowShapeMeasurement = showShapeMeasurement; + dto.StrokeColor = strokeColor.ToString(); + return dto; + } + + public void FromDto(ConstructionGeometryDto dto) + { + // Loading a project is not an edit, so this deliberately bypasses the undo + // transaction machinery. + scaleFactor = dto.ScaleFactor; + units = dto.Units; + showShapeMeasurement = dto.ShowShapeMeasurement; + + // Absent from projects saved before this existed — leave the construction in its + // just-constructed appearance (blue points/lines, orange shape) rather than + // forcing every old project's shape from orange to blue. + if (!string.IsNullOrEmpty(dto.StrokeColor)) + { + try { StrokeColor = (Color)ColorConverter.ConvertFromString(dto.StrokeColor); } + catch { /* Keep the just-constructed default on a corrupt value. */ } + } + + RestoreGeometry(dto); + } + + #endregion +} + +/// +/// A completed construction edit, as the before/after snapshots the undo stack needs. +/// +public class ConstructionGeometryEditedEventArgs( + ConstructionGeometryDto before, + ConstructionGeometryDto after) : EventArgs +{ + public ConstructionGeometryDto Before { get; } = before; + public ConstructionGeometryDto After { get; } = after; +} diff --git a/MagickCrop/Controls/DistanceMeasurementControl.xaml b/MagickCrop/Controls/DistanceMeasurementControl.xaml index b45bb9d..917019e 100644 --- a/MagickCrop/Controls/DistanceMeasurementControl.xaml +++ b/MagickCrop/Controls/DistanceMeasurementControl.xaml @@ -55,6 +55,10 @@ Click="SetRealWorldLengthMenuItem_Click" Header="Set Real World Length" ToolTip="Set a real-world length for this line" /> + strokeColor; + set + { + strokeColor = value; + UpdateColors(); + } + } + public DistanceMeasurementControl() { InitializeComponent(); UpdatePositions(); } + private void UpdateColors() + { + SolidColorBrush brush = new(strokeColor); + MeasurementLine.Stroke = brush; + StartPoint.Fill = brush; + EndPoint.Fill = brush; + } + public bool IsDragGizmoVisible { get => StartPoint.Visibility == Visibility.Visible; @@ -189,6 +210,16 @@ private void RemoveMeasurementMenuItem_Click(object sender, RoutedEventArgs e) RemoveControlRequested?.Invoke(this, EventArgs.Empty); } + private async void ChangeColorMenuItem_Click(object sender, RoutedEventArgs e) + { + if (Application.Current.MainWindow is not MainWindow mainWindow) + return; + + Color? picked = await ColorPickerDialog.PickColorAsync(mainWindow, strokeColor, "Change Measurement Color"); + if (picked is Color color) + StrokeColor = color; + } + /// /// Convert this control to a data transfer object /// @@ -199,7 +230,8 @@ public DistanceMeasurementControlDto ToDto() StartPosition = startPosition, EndPosition = endPosition, ScaleFactor = ScaleFactor, - Units = Units + Units = Units, + StrokeColor = strokeColor.ToString() }; } @@ -212,6 +244,11 @@ public void FromDto(DistanceMeasurementControlDto dto) endPosition = dto.EndPosition; ScaleFactor = dto.ScaleFactor; Units = dto.Units; + + try { strokeColor = (Color)ColorConverter.ConvertFromString(dto.StrokeColor); } + catch { strokeColor = (Color)ColorConverter.ConvertFromString("#0066FF"); } + UpdateColors(); + UpdatePositions(); } } diff --git a/MagickCrop/Controls/HorizontalLineControl.xaml b/MagickCrop/Controls/HorizontalLineControl.xaml index f9ee700..98307c1 100644 --- a/MagickCrop/Controls/HorizontalLineControl.xaml +++ b/MagickCrop/Controls/HorizontalLineControl.xaml @@ -9,7 +9,10 @@ mc:Ignorable="d"> - + + diff --git a/MagickCrop/Controls/MarkupShapeControl.xaml.cs b/MagickCrop/Controls/MarkupShapeControl.xaml.cs index 2bee2fb..20cd003 100644 --- a/MagickCrop/Controls/MarkupShapeControl.xaml.cs +++ b/MagickCrop/Controls/MarkupShapeControl.xaml.cs @@ -1,3 +1,4 @@ +using MagickCrop.Helpers; using MagickCrop.Models; using MagickCrop.Models.MeasurementControls; using System.Windows; @@ -196,6 +197,16 @@ private void RemoveMenuItem_Click(object sender, RoutedEventArgs e) RemoveControlRequested?.Invoke(this, EventArgs.Empty); } + private async void ChangeColorMenuItem_Click(object sender, RoutedEventArgs e) + { + if (Application.Current.MainWindow is not MainWindow mainWindow) + return; + + Color? picked = await ColorPickerDialog.PickColorAsync(mainWindow, strokeColor, "Change Shape Color"); + if (picked is Color color) + StrokeColor = color; + } + public MarkupShapeDto ToDto() { return new MarkupShapeDto diff --git a/MagickCrop/Controls/MarkupTextControl.xaml b/MagickCrop/Controls/MarkupTextControl.xaml index c6d063d..95a9158 100644 --- a/MagickCrop/Controls/MarkupTextControl.xaml +++ b/MagickCrop/Controls/MarkupTextControl.xaml @@ -8,6 +8,7 @@ mc:Ignorable="d"> + diff --git a/MagickCrop/Controls/MarkupTextControl.xaml.cs b/MagickCrop/Controls/MarkupTextControl.xaml.cs index 99e0fdf..929f288 100644 --- a/MagickCrop/Controls/MarkupTextControl.xaml.cs +++ b/MagickCrop/Controls/MarkupTextControl.xaml.cs @@ -1,3 +1,4 @@ +using MagickCrop.Helpers; using MagickCrop.Models.MeasurementControls; using System.Windows; using System.Windows.Controls; @@ -197,6 +198,16 @@ private void RemoveMenuItem_Click(object sender, RoutedEventArgs e) RemoveControlRequested?.Invoke(this, EventArgs.Empty); } + private async void ChangeColorMenuItem_Click(object sender, RoutedEventArgs e) + { + if (Application.Current.MainWindow is not MainWindow mainWindow) + return; + + Color? picked = await ColorPickerDialog.PickColorAsync(mainWindow, textColor, "Change Text Color"); + if (picked is Color color) + TextColor = color; + } + public MarkupTextDto ToDto() { return new MarkupTextDto diff --git a/MagickCrop/Controls/PolygonMeasurementControl.xaml b/MagickCrop/Controls/PolygonMeasurementControl.xaml index bbdbcbb..0b7f50e 100644 --- a/MagickCrop/Controls/PolygonMeasurementControl.xaml +++ b/MagickCrop/Controls/PolygonMeasurementControl.xaml @@ -38,6 +38,10 @@ Click="CopyMeasurementMenuItem_Click" Header="Copy Measurement" ToolTip="Copy the polygon properties" /> + strokeColor; + set + { + strokeColor = value; + UpdateColors(); + } + } + public PolygonMeasurementControl() { InitializeComponent(); } + private void UpdateColors() + { + SolidColorBrush brush = new(strokeColor); + PolygonPath.Stroke = brush; + PolygonPath.Fill = new SolidColorBrush(Color.FromArgb(0x20, strokeColor.R, strokeColor.G, strokeColor.B)); + PreviewLine.Stroke = brush; + + foreach (Ellipse point in vertexPoints) + point.Fill = new SolidColorBrush(strokeColor); + + // The first vertex's "click to close" highlight is a fixed state indicator, not + // part of the shape's identity color, so it is reapplied on top. + if (!isClosed && vertices.Count >= 3) + UpdateFirstVertexAppearance(); + } + public bool IsDragGizmoVisible { get => areDragGizmosVisible; @@ -170,7 +197,7 @@ private void CreateVertexPoint(Point position, int index) { Width = areEndpointCapsVisible ? 6 : 12, Height = areEndpointCapsVisible ? 6 : 12, - Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0066FF")), + Fill = new SolidColorBrush(strokeColor), Stroke = Brushes.White, StrokeThickness = 1, Opacity = 0.8, @@ -249,7 +276,7 @@ private void ResetFirstVertexAppearance() // Reset to normal appearance firstVertex.Width = 12; firstVertex.Height = 12; - firstVertex.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0066FF")); + firstVertex.Fill = new SolidColorBrush(strokeColor); firstVertex.StrokeThickness = 1; // Reposition after size change @@ -376,6 +403,16 @@ private void RemoveMeasurementMenuItem_Click(object sender, RoutedEventArgs e) RemoveControlRequested?.Invoke(this, EventArgs.Empty); } + private async void ChangeColorMenuItem_Click(object sender, RoutedEventArgs e) + { + if (Application.Current.MainWindow is not MainWindow mainWindow) + return; + + Color? picked = await ColorPickerDialog.PickColorAsync(mainWindow, strokeColor, "Change Measurement Color"); + if (picked is Color color) + StrokeColor = color; + } + public PolygonMeasurementControlDto ToDto() { return new PolygonMeasurementControlDto @@ -383,7 +420,8 @@ public PolygonMeasurementControlDto ToDto() Vertices = [.. vertices], ScaleFactor = ScaleFactor, Units = Units, - IsClosed = isClosed + IsClosed = isClosed, + StrokeColor = strokeColor.ToString() }; } @@ -405,6 +443,9 @@ public void FromDto(PolygonMeasurementControlDto dto) ScaleFactor = dto.ScaleFactor; Units = dto.Units; + try { strokeColor = (Color)ColorConverter.ConvertFromString(dto.StrokeColor); } + catch { strokeColor = (Color)ColorConverter.ConvertFromString("#0066FF"); } + // Recreate vertex points for (int i = 0; i < vertices.Count; i++) { @@ -418,6 +459,7 @@ public void FromDto(PolygonMeasurementControlDto dto) } UpdatePolygonPath(); + UpdateColors(); UpdateDisplay(); System.Diagnostics.Debug.WriteLine($"FromDto: Polygon restoration complete"); diff --git a/MagickCrop/Controls/QuadrilateralSelector.xaml.cs b/MagickCrop/Controls/QuadrilateralSelector.xaml.cs index 099534c..09e27eb 100644 --- a/MagickCrop/Controls/QuadrilateralSelector.xaml.cs +++ b/MagickCrop/Controls/QuadrilateralSelector.xaml.cs @@ -21,8 +21,13 @@ public class QuadrilateralViewModel public QuadrilateralViewModel(QuadrilateralDetector.DetectedQuadrilateral quad, int index) { Quadrilateral = quad ?? throw new ArgumentNullException(nameof(quad)); - Name = $"Quad: {index + 1}"; - Description = $"Confidence: {quad.Confidence:P0}"; + + // A labelled quadrilateral did not come from contour detection, so a + // confidence percentage would be meaningless for it. + Name = quad.Label ?? $"Quad: {index + 1}"; + Description = quad.Label is null + ? $"Confidence: {quad.Confidence:P0}" + : "From your construction lines"; // Scale points for preview (60x60 canvas) PreviewPoints = ScalePointsForPreview(quad); @@ -43,8 +48,10 @@ private static PointCollection ScalePointsForPreview(QuadrilateralDetector.Detec double targetSize = 50; double padding = 5; - // Calculate scale to fit in preview - double scale = Math.Min(targetSize / width, targetSize / height); + // A degenerate quad would divide by zero here and produce NaN preview points. + double scale = width > 0 && height > 0 + ? Math.Min(targetSize / width, targetSize / height) + : 1.0; // Create scaled points PointCollection scaledPoints = diff --git a/MagickCrop/Controls/RectangleMeasurementControl.xaml b/MagickCrop/Controls/RectangleMeasurementControl.xaml index cc4f262..ab5ec31 100644 --- a/MagickCrop/Controls/RectangleMeasurementControl.xaml +++ b/MagickCrop/Controls/RectangleMeasurementControl.xaml @@ -50,6 +50,10 @@ Click="CopyMeasurementMenuItem_Click" Header="Copy Measurement" ToolTip="Copy the rectangle dimensions" /> + strokeColor; + set + { + strokeColor = value; + UpdateColors(); + } + } + public RectangleMeasurementControl() { InitializeComponent(); UpdatePositions(); } + private void UpdateColors() + { + MeasurementRectangle.Stroke = new SolidColorBrush(strokeColor); + MeasurementRectangle.Fill = new SolidColorBrush(Color.FromArgb(0x20, strokeColor.R, strokeColor.G, strokeColor.B)); + SolidColorBrush pointBrush = new(strokeColor); + TopLeftPoint.Fill = pointBrush; + BottomRightPoint.Fill = pointBrush; + } + public bool IsDragGizmoVisible { get => TopLeftPoint.Visibility == Visibility.Visible; @@ -163,6 +185,16 @@ private void RemoveMeasurementMenuItem_Click(object sender, RoutedEventArgs e) RemoveControlRequested?.Invoke(this, EventArgs.Empty); } + private async void ChangeColorMenuItem_Click(object sender, RoutedEventArgs e) + { + if (Application.Current.MainWindow is not MainWindow mainWindow) + return; + + Color? picked = await ColorPickerDialog.PickColorAsync(mainWindow, strokeColor, "Change Measurement Color"); + if (picked is Color color) + StrokeColor = color; + } + public RectangleMeasurementControlDto ToDto() { return new RectangleMeasurementControlDto @@ -170,7 +202,8 @@ public RectangleMeasurementControlDto ToDto() TopLeft = topLeft, BottomRight = bottomRight, ScaleFactor = ScaleFactor, - Units = Units + Units = Units, + StrokeColor = strokeColor.ToString() }; } @@ -183,6 +216,11 @@ public void FromDto(RectangleMeasurementControlDto dto) bottomRight = dto.BottomRight; ScaleFactor = dto.ScaleFactor; // This will use the property setter Units = dto.Units; // This will use the property setter + + try { strokeColor = (Color)ColorConverter.ConvertFromString(dto.StrokeColor); } + catch { strokeColor = (Color)ColorConverter.ConvertFromString("#0066FF"); } + UpdateColors(); + UpdatePositions(); } } diff --git a/MagickCrop/Controls/StrokeLengthDisplay.xaml b/MagickCrop/Controls/StrokeLengthDisplay.xaml index 7951b5a..c4da5e0 100644 --- a/MagickCrop/Controls/StrokeLengthDisplay.xaml +++ b/MagickCrop/Controls/StrokeLengthDisplay.xaml @@ -17,6 +17,7 @@ + diff --git a/MagickCrop/Controls/StrokeLengthDisplay.xaml.cs b/MagickCrop/Controls/StrokeLengthDisplay.xaml.cs index 3f2cb5a..e080405 100644 --- a/MagickCrop/Controls/StrokeLengthDisplay.xaml.cs +++ b/MagickCrop/Controls/StrokeLengthDisplay.xaml.cs @@ -1,7 +1,9 @@ -using MagickCrop.Models; +using MagickCrop.Helpers; +using MagickCrop.Models; using System.Windows; using System.Windows.Controls; using System.Windows.Ink; +using System.Windows.Media; namespace MagickCrop.Controls; @@ -54,6 +56,20 @@ private void RemoveMeasurementMenuItem_Click(object sender, RoutedEventArgs e) RemoveControlRequested?.Invoke(this, EventArgs.Empty); } + private async void ChangeColorMenuItem_Click(object sender, RoutedEventArgs e) + { + if (Application.Current.MainWindow is not MainWindow mainWindow) + return; + + Color? picked = await ColorPickerDialog.PickColorAsync(mainWindow, _stroke.DrawingAttributes.Color, "Change Stroke Color"); + if (picked is not Color color) + return; + + DrawingAttributes attributes = _stroke.DrawingAttributes.Clone(); + attributes.Color = color; + _stroke.DrawingAttributes = attributes; + } + private void MeasurementButton_Click(object sender, RoutedEventArgs e) { ContextMenu? contextMenu = MeasurementText.ContextMenu; diff --git a/MagickCrop/Controls/VerticalLineControl.xaml b/MagickCrop/Controls/VerticalLineControl.xaml index f60934c..d4ff0c5 100644 --- a/MagickCrop/Controls/VerticalLineControl.xaml +++ b/MagickCrop/Controls/VerticalLineControl.xaml @@ -9,7 +9,10 @@ mc:Ignorable="d"> - + +/// Finds where a boundary crosses a probe line — the user drags a short segment +/// perpendicular to an edge, and this locates the transition along it, so a construction +/// point lands on the edge of the paper rather than near it. +/// +/// Pure static math over and an image buffer; no WPF elements, no +/// control dependencies, in the same spirit as . +/// +/// +/// This is a one-dimensional derivative-of-Gaussian edge detector: smooth, differentiate, +/// take the peak. That is the same maths Canny performs internally, without the non-maximum +/// suppression and hysteresis thresholding that reduce it to a binary mask — those throw +/// away exactly the sub-pixel position this needs, and their thresholds are tuned against +/// the whole image, so a faint paper edge often does not survive them. +/// +/// It runs over colour rather than brightness, because a brightness step is ambiguous +/// evidence: a shadow falling across one sheet of paper produces a strong one with no +/// boundary behind it. A change of hue rarely happens without a change of material, so +/// chroma is the more trustworthy signal and is weighted accordingly. +/// +public static class BoundaryProbeAnalyzer +{ + /// A probe shorter than this cannot be told apart from a click. + private const double MinProbeLength = 4.0; + + /// Samples per pixel of probe length, so the answer can land between pixels. + private const double SamplesPerPixel = 2.0; + + private const int MinSamples = 16; + private const int MaxSamples = 512; + + /// + /// Half-width of the band of parallel scan lines, in pixels. Averaging across lines + /// laid along the boundary is the single biggest noise win available here: a real + /// straight edge reinforces across every lane while grain and texture cancel. + /// + private const double LaneHalfWidth = 4.0; + + /// Lanes across the band. Odd so one runs down the probe itself. + private const int LaneCount = 9; + + /// + /// Standard deviation of the smoothing kernel, in samples. Small — the buffer is + /// already lightly blurred, and over-smoothing drags a boundary toward whatever else + /// is nearby. + /// + private const double SmoothingSigma = 1.2; + + private const int SmoothingRadius = 3; + + /// + /// How much more a change of hue counts than an equal-sized change of brightness. + /// Above one on purpose: brightness varies across a single surface through shading and + /// shadow, so a luminance step is weak evidence of a boundary, while two materials + /// meeting almost always changes hue. + /// + private const double ChromaWeight = 1.5; + + /// + /// Weight given to a candidate at the very ends of the probe, relative to one at the + /// middle. The user aims the middle of the drag at the boundary, so the middle is much + /// more likely to be right — but this stays well above zero so a genuinely stronger + /// edge near an end can still win. + /// + private const double CenterWeightFloor = 0.35; + + /// + /// Fraction of the peak gradient that still counts as part of the same transition. + /// The run above this is what gets averaged to find the middle of a gradient. + /// + private const double HalfMaximum = 0.5; + + /// How far the peak must stand out from the profile's own texture to score full marks. + private const double PeakRatioTarget = 4.0; + + /// Colour range across the probe, 0-1, that scores full marks for contrast. + private const double ContrastTarget = 0.08; + + /// Below this the result is offered but flagged, rather than trusted. + private const double WeakConfidence = 0.35; + + /// A probe flatter than this (in 0-255 units) has nothing to find. + private const double FlatProfileRange = 0.5; + + /// Keeps the result off the exact endpoints, where it would look like a failure. + private const double MinT = 0.02; + private const double MaxT = 0.98; + + /// The boundary position, in the same space as the probe endpoints. + /// Where it fell along the probe, 0 at the start and 1 at the end. + /// 0-1. Combines how sharply the peak stands out with how much colour change there was to work with. + /// True when the result is a best guess rather than a clear boundary. + public readonly record struct BoundaryProbeResult(Point Position, double T, double Confidence, bool IsWeak); + + /// + /// Locates the boundary crossing the probe from to + /// , both in image pixel coordinates. + /// + /// + /// False only when the probe is unusable — too short, or off a null buffer. A probe + /// across a blank wall still returns true, at the midpoint, flagged weak: the gesture + /// was well formed and the user gets something to nudge. + /// + public static bool TryFindBoundary( + ImageSampleBuffer? image, + Point startPixel, + Point endPixel, + out BoundaryProbeResult result) + { + result = default; + + if (image is null) return false; + + double dx = endPixel.X - startPixel.X; + double dy = endPixel.Y - startPixel.Y; + double length = Math.Sqrt((dx * dx) + (dy * dy)); + + if (double.IsNaN(length) || double.IsInfinity(length) || length < MinProbeLength) + return false; + + Profiles profiles = SampleProfiles(image, startPixel, dx, dy, length); + + // Nothing but noise along the whole probe: hand back the middle rather than an + // arbitrary argmax over a flat array. + if (profiles.ContrastRange < FlatProfileRange) + { + result = BuildResult(startPixel, dx, dy, 0.5, 0.0); + return true; + } + + double[] gradient = CombinedGradient(profiles); + int peakIndex = FindWeightedPeak(gradient); + + if (peakIndex < 0) + { + result = BuildResult(startPixel, dx, dy, 0.5, 0.0); + return true; + } + + double centerIndex = FindGradientCenter(gradient, peakIndex); + double t = Math.Clamp(centerIndex / (gradient.Length - 1), MinT, MaxT); + double confidence = ScoreConfidence(gradient, peakIndex, profiles.ContrastRange); + + result = BuildResult(startPixel, dx, dy, t, confidence); + return true; + } + + /// + /// The probe reduced to three signals, in an opponent-colour space: how light it is, + /// how red against green, and how blue against yellow. Splitting colour this way is + /// what lets brightness and hue be weighed against each other instead of being mixed + /// together and lost. + /// + /// + /// How much colour varies along the whole probe, already weighted, in 0-255 units. + /// Drives the confidence score and the flat-probe test. + /// + private readonly record struct Profiles( + double[] Luma, + double[] RedGreen, + double[] BlueYellow, + double ContrastRange); + + /// + /// Splits a colour into brightness and two hue axes, each spanning the same 0-255 + /// range so is the only thing tipping the balance between + /// them, rather than an accident of the encoding. + /// + private static void ToOpponent( + double red, double green, double blue, + out double luma, out double redGreen, out double blueYellow) + { + luma = (0.299 * red) + (0.587 * green) + (0.114 * blue); + redGreen = (red - green) / 2.0; + blueYellow = (blue - ((red + green) / 2.0)) / 2.0; + } + + /// + /// Averages colour along a band of lines parallel to the probe. The band is capped at + /// half the probe length so a short probe placed near a corner does not smear the + /// corner into its own reading. + /// + private static Profiles SampleProfiles( + ImageSampleBuffer image, + Point start, + double dx, + double dy, + double length) + { + int n = Math.Clamp((int)Math.Round(length * SamplesPerPixel), MinSamples, MaxSamples); + + // Perpendicular to the probe is parallel to the boundary, which is the direction + // the lanes spread along. + double perpX = -dy / length; + double perpY = dx / length; + + double halfWidth = Math.Min(LaneHalfWidth, length / 4.0); + int laneCount = halfWidth >= 1.0 ? LaneCount : 1; + double laneSpacing = laneCount > 1 ? (2 * halfWidth) / (laneCount - 1) : 0; + + double[] luma = new double[n]; + double[] redGreen = new double[n]; + double[] blueYellow = new double[n]; + + for (int i = 0; i < n; i++) + { + double t = (double)i / (n - 1); + double x = start.X + (dx * t); + double y = start.Y + (dy * t); + + double sumRed = 0, sumGreen = 0, sumBlue = 0; + + for (int lane = 0; lane < laneCount; lane++) + { + double offset = laneCount > 1 ? -halfWidth + (lane * laneSpacing) : 0; + + image.SampleBilinear( + x + (perpX * offset), y + (perpY * offset), + out double red, out double green, out double blue); + + sumRed += red; + sumGreen += green; + sumBlue += blue; + } + + ToOpponent( + sumRed / laneCount, sumGreen / laneCount, sumBlue / laneCount, + out luma[i], out redGreen[i], out blueYellow[i]); + } + + Smooth(luma); + Smooth(redGreen); + Smooth(blueYellow); + + // How much the probe's colour varies end to end, on the same weighted footing the + // gradient uses. This stands in for the local auto-level a single-channel version + // would do: what matters is contrast across this probe, not across the photo, so a + // white sheet on a pale desk still reads as having something to find. + double lumaRange = Range(luma); + double redGreenRange = Range(redGreen) * ChromaWeight; + double blueYellowRange = Range(blueYellow) * ChromaWeight; + + double contrastRange = Math.Sqrt( + (lumaRange * lumaRange) + + (redGreenRange * redGreenRange) + + (blueYellowRange * blueYellowRange)); + + return new Profiles(luma, redGreen, blueYellow, contrastRange); + } + + private static double Range(double[] profile) + { + double min = double.MaxValue; + double max = double.MinValue; + + foreach (double value in profile) + { + if (value < min) min = value; + if (value > max) max = value; + } + + return max - min; + } + + /// + /// How fast the colour is changing at each point along the probe: the length of the + /// per-channel derivative vector, with the two hue axes scaled up by + /// . + /// + /// + /// Combining the channels as a vector length rather than summing them means a + /// transition shows up whichever channel carries it, so yellow meeting blue reads as + /// strongly as black meeting white — and a hue change with no brightness change, which + /// a greyscale reading cannot see at all, reads more strongly still. + /// + private static double[] CombinedGradient(Profiles profiles) + { + int n = profiles.Luma.Length; + double[] gradient = new double[n]; + + for (int i = 1; i < n - 1; i++) + { + double luma = Derivative(profiles.Luma, i); + double redGreen = Derivative(profiles.RedGreen, i) * ChromaWeight; + double blueYellow = Derivative(profiles.BlueYellow, i) * ChromaWeight; + + gradient[i] = Math.Sqrt( + (luma * luma) + (redGreen * redGreen) + (blueYellow * blueYellow)); + } + + return gradient; + } + + /// + /// Central difference. Signed here — the channels are combined as a vector length, so + /// the sign of each one matters until they are put together. + /// + private static double Derivative(double[] profile, int index) => + (profile[index + 1] - profile[index - 1]) / 2.0; + + /// Gaussian smoothing in place, clamping at the ends. + private static void Smooth(double[] profile) + { + double[] kernel = new double[(SmoothingRadius * 2) + 1]; + double kernelSum = 0; + + for (int k = -SmoothingRadius; k <= SmoothingRadius; k++) + { + double weight = Math.Exp(-(k * k) / (2 * SmoothingSigma * SmoothingSigma)); + kernel[k + SmoothingRadius] = weight; + kernelSum += weight; + } + + double[] source = (double[])profile.Clone(); + + for (int i = 0; i < profile.Length; i++) + { + double sum = 0; + for (int k = -SmoothingRadius; k <= SmoothingRadius; k++) + { + int index = Math.Clamp(i + k, 0, source.Length - 1); + sum += source[index] * kernel[k + SmoothingRadius]; + } + + profile[i] = sum / kernelSum; + } + } + + /// + /// Strongest gradient after weighting toward the middle of the probe. Returns -1 when + /// the probe is entirely flat. + /// + private static int FindWeightedPeak(double[] gradient) + { + int n = gradient.Length; + int best = -1; + double bestScore = 0; + + for (int i = 1; i < n - 1; i++) + { + if (gradient[i] <= 0) continue; + + double t = (double)i / (n - 1); + + // Raised cosine: 1 at the middle, CenterWeightFloor at either end. + double centered = Math.Cos(Math.PI / 2 * ((2 * t) - 1)); + double weight = CenterWeightFloor + ((1 - CenterWeightFloor) * centered * centered); + + double score = gradient[i] * weight; + if (score > bestScore) + { + bestScore = score; + best = i; + } + } + + return best; + } + + /// + /// The middle of the transition rather than its steepest point: walk out from the peak + /// while the gradient holds above half its maximum, then take the gradient-weighted + /// centroid of that run. + /// + /// + /// On a sharp step edge the run is one or two samples wide and this agrees with the + /// peak. On a soft or slightly asymmetric gradient — a shadow, a defocused edge, the + /// rolled edge of a stack of paper — it lands mid-ramp, which is where the boundary + /// actually is and where a peak-only answer would drift. + /// + private static double FindGradientCenter(double[] gradient, int peakIndex) + { + double threshold = gradient[peakIndex] * HalfMaximum; + + int low = peakIndex; + while (low - 1 >= 1 && gradient[low - 1] >= threshold) + low--; + + int high = peakIndex; + while (high + 1 <= gradient.Length - 2 && gradient[high + 1] >= threshold) + high++; + + double weightedSum = 0; + double weightSum = 0; + + for (int i = low; i <= high; i++) + { + weightedSum += i * gradient[i]; + weightSum += gradient[i]; + } + + return weightSum > 0 ? weightedSum / weightSum : peakIndex; + } + + /// + /// Two independent ways the reading can be untrustworthy, multiplied: the peak may not + /// stand out from the profile's own texture, and there may not have been much colour + /// change to read in the first place. Either one alone is enough to make the answer a + /// guess. + /// + private static double ScoreConfidence(double[] gradient, int peakIndex, double contrastRange) + { + int n = gradient.Length; + double sum = 0; + int count = 0; + + for (int i = 1; i < n - 1; i++) + { + sum += gradient[i]; + count++; + } + + double mean = count > 0 ? sum / count : 0; + double peakRatio = mean > 0 ? gradient[peakIndex] / mean : 0; + + double sharpness = Math.Min(peakRatio / PeakRatioTarget, 1.0); + double contrast = Math.Min(contrastRange / 255.0 / ContrastTarget, 1.0); + + return Math.Clamp(sharpness * contrast, 0, 1); + } + + private static BoundaryProbeResult BuildResult(Point start, double dx, double dy, double t, double confidence) + { + Point position = new(start.X + (dx * t), start.Y + (dy * t)); + + return new BoundaryProbeResult(position, t, confidence, confidence < WeakConfidence); + } +} diff --git a/MagickCrop/Helpers/ColorPalette.cs b/MagickCrop/Helpers/ColorPalette.cs new file mode 100644 index 0000000..8bea105 --- /dev/null +++ b/MagickCrop/Helpers/ColorPalette.cs @@ -0,0 +1,24 @@ +using System.Windows.Media; + +namespace MagickCrop.Helpers; + +/// +/// The named swatch colors offered wherever the user can pick a color, matching the set +/// already used by the Markup tool's color palette in MainWindow.xaml. +/// +public static class ColorPalette +{ + public static readonly (string Name, Color Color)[] Swatches = + [ + ("Red", Colors.Red), + ("OrangeRed", Colors.OrangeRed), + ("Yellow", Colors.Yellow), + ("LimeGreen", Colors.LimeGreen), + ("Cyan", Colors.Cyan), + ("DodgerBlue", Colors.DodgerBlue), + ("MediumPurple", Colors.MediumPurple), + ("DeepPink", Colors.DeepPink), + ("White", Colors.White), + ("Black", Colors.Black), + ]; +} diff --git a/MagickCrop/Helpers/ColorPickerDialog.cs b/MagickCrop/Helpers/ColorPickerDialog.cs new file mode 100644 index 0000000..125f5af --- /dev/null +++ b/MagickCrop/Helpers/ColorPickerDialog.cs @@ -0,0 +1,30 @@ +using MagickCrop.Controls; +using System.Windows.Media; +using Wpf.Ui.Controls; + +namespace MagickCrop.Helpers; + +/// +/// Shows a in a modal dialog, using the same +/// DialogHost = Presenter pattern already established for this app's other dialogs +/// (e.g. the "Change Thickness" dialog on the guide-line controls). +/// +public static class ColorPickerDialog +{ + public static async Task PickColorAsync(MainWindow owner, Color currentColor, string title = "Change Color") + { + ColorSwatchPicker picker = new() { SelectedColor = currentColor }; + + ContentDialog dialog = new() + { + Title = title, + Content = picker, + PrimaryButtonText = "Apply", + CloseButtonText = "Cancel", + DialogHost = owner.Presenter + }; + + ContentDialogResult result = await dialog.ShowAsync(); + return result == ContentDialogResult.Primary ? picker.SelectedColor : null; + } +} diff --git a/MagickCrop/Helpers/ConstructionFaceSolver.cs b/MagickCrop/Helpers/ConstructionFaceSolver.cs new file mode 100644 index 0000000..43c8483 --- /dev/null +++ b/MagickCrop/Helpers/ConstructionFaceSolver.cs @@ -0,0 +1,481 @@ +using MagickCrop.Models.Construction; +using System.Windows; + +namespace MagickCrop.Helpers; + +/// +/// Finds every bounded cell in the planar arrangement formed by a set of construction +/// lines — every enclosed region the lines carve out, not just the single outer shape +/// solves. Three lines crossing pairwise bound one +/// triangle; a grid of lines bounds one cell per square. Clicking adjacent cells and +/// merging them is how a user builds an irregular polygon out of straight construction +/// edges. +/// +/// Pure static math over ; no WPF elements, no control dependencies. +/// +public static class ConstructionFaceSolver +{ + /// Distance below which two computed vertices are treated as the same point. + private const double VertexMergeEpsilon = 0.75; + + /// Below this a cell has collapsed to a sliver and is not a usable shape. + private const double MinFaceArea = 9.0; + + /// Slack when testing whether a crossing falls within both segments. + private const double SegmentParamEpsilon = 1e-6; + + /// + /// Finds every bounded face formed by treating each line as infinite and clipping it + /// to . Clipping turns "infinite" into "a long segment" so the + /// arrangement stays finite; any face that still touches the clip edge is the + /// unbounded outside of the arrangement leaking in, not a real enclosed cell, and is + /// dropped. + /// + public static List SolveFaces( + IReadOnlyList<(Guid Id, Point Start, Point End)> lines, + Rect bounds) + { + if (lines is null || lines.Count < 3 || bounds.Width <= 0 || bounds.Height <= 0) + return []; + + List<(Point A, Point B)> segments = []; + foreach ((Guid _, Point start, Point end) in lines) + { + if (TryClipLineToBounds(start, end, bounds, out Point a, out Point b)) + segments.Add((a, b)); + } + + if (segments.Count < 3) + return []; + + List vertexPositions = []; + List vertexTouchesClipBounds = []; + + int FindOrAddVertex(Point p, bool isClipBoundary) + { + for (int i = 0; i < vertexPositions.Count; i++) + { + if (GeometryMathHelper.Distance(vertexPositions[i], p) <= VertexMergeEpsilon) + { + vertexTouchesClipBounds[i] |= isClipBoundary; + return i; + } + } + + vertexPositions.Add(p); + vertexTouchesClipBounds.Add(isClipBoundary); + return vertexPositions.Count - 1; + } + + HashSet<(int A, int B)> edgeSet = []; + + for (int i = 0; i < segments.Count; i++) + { + (Point a, Point b) = segments[i]; + + List<(double T, int VertexIndex)> onLine = + [ + (0, FindOrAddVertex(a, isClipBoundary: true)), + (1, FindOrAddVertex(b, isClipBoundary: true)) + ]; + + for (int j = 0; j < segments.Count; j++) + { + if (i == j) continue; + + (Point c, Point d) = segments[j]; + if (TrySegmentIntersect(a, b, c, d, out Point crossing, out double t)) + onLine.Add((t, FindOrAddVertex(crossing, isClipBoundary: false))); + } + + onLine.Sort((x, y) => x.T.CompareTo(y.T)); + + for (int k = 0; k < onLine.Count - 1; k++) + { + int u = onLine[k].VertexIndex; + int v = onLine[k + 1].VertexIndex; + if (u == v) continue; + + edgeSet.Add(u < v ? (u, v) : (v, u)); + } + } + + if (edgeSet.Count == 0) + return []; + + List[] sortedNeighbors = BuildSortedNeighbors(vertexPositions, edgeSet); + + List faces = []; + HashSet<(int U, int V)> visited = []; + + foreach ((int a, int b) in edgeSet) + { + TryTraceFace(a, b, vertexPositions, vertexTouchesClipBounds, sortedNeighbors, visited, faces); + TryTraceFace(b, a, vertexPositions, vertexTouchesClipBounds, sortedNeighbors, visited, faces); + } + + return faces; + } + + /// + /// Merges the faces the caller has selected into one or more outer boundaries. An edge + /// shared by two selected faces is internal to the union and cancels out; an edge + /// belonging to only one selected face is on the merged shape's outline. Tracing those + /// surviving edges into loops is the same face-walk used to find the faces themselves, + /// just run over this smaller edge set. + /// + public static List> UnionFaces(IReadOnlyList faces, IEnumerable selectedIndices) + { + Dictionary<(PointKey A, PointKey B), int> edgeCounts = []; + Dictionary<(PointKey A, PointKey B), (Point A, Point B)> edgeLookup = []; + + foreach (int index in selectedIndices) + { + if (index < 0 || index >= faces.Count) continue; + + foreach ((Point a, Point b) in faces[index].Edges) + { + PointKey ka = new(a); + PointKey kb = new(b); + (PointKey, PointKey) key = ka.CompareTo(kb) <= 0 ? (ka, kb) : (kb, ka); + + edgeCounts[key] = edgeCounts.GetValueOrDefault(key) + 1; + edgeLookup[key] = (a, b); + } + } + + List<(Point A, Point B)> boundary = + [.. edgeCounts.Where(kv => kv.Value % 2 != 0).Select(kv => edgeLookup[kv.Key])]; + + return TraceLoops(boundary); + } + + #region Face arrangement + + private static List[] BuildSortedNeighbors(List vertexPositions, HashSet<(int A, int B)> edgeSet) + { + List[] adjacency = new List[vertexPositions.Count]; + for (int i = 0; i < adjacency.Length; i++) + adjacency[i] = []; + + foreach ((int u, int v) in edgeSet) + { + adjacency[u].Add(v); + adjacency[v].Add(u); + } + + List[] sorted = new List[vertexPositions.Count]; + for (int v = 0; v < vertexPositions.Count; v++) + { + Point origin = vertexPositions[v]; + List neighbors = adjacency[v]; + neighbors.Sort((n1, n2) => AngleTo(origin, vertexPositions[n1]).CompareTo(AngleTo(origin, vertexPositions[n2]))); + sorted[v] = neighbors; + } + + return sorted; + } + + private static double AngleTo(Point from, Point to) => Math.Atan2(to.Y - from.Y, to.X - from.X); + + /// + /// Walks the face that starts by leaving toward + /// : at each vertex, turn to the next line in angular order + /// after the one just arrived on. This is the standard planar-graph face trace — every + /// directed edge belongs to exactly one face, so running it from every directed edge + /// enumerates all of them, bounded and unbounded alike. + /// + private static void TryTraceFace( + int startU, + int startV, + List vertexPositions, + List vertexTouchesClipBounds, + List[] sortedNeighbors, + HashSet<(int U, int V)> visited, + List faces) + { + (int U, int V) start = (startU, startV); + if (visited.Contains(start)) + return; + + List faceVertices = []; + bool touchesClipBounds = false; + bool closed = false; + + (int U, int V) current = start; + int maxSteps = sortedNeighbors.Sum(n => n.Count) + 4; + + for (int step = 0; step < maxSteps; step++) + { + visited.Add(current); + faceVertices.Add(current.V); + touchesClipBounds |= vertexTouchesClipBounds[current.V]; + + List neighborsOfV = sortedNeighbors[current.V]; + int idx = neighborsOfV.IndexOf(current.U); + if (idx < 0) + return; // Malformed graph; abandon this trace rather than loop forever. + + int next = neighborsOfV[(idx + 1) % neighborsOfV.Count]; + (int U, int V) nextEdge = (current.V, next); + + if (nextEdge == start) + { + closed = true; + break; + } + + current = nextEdge; + } + + // Ran out of steps without returning to the start half-edge: something is + // topologically off (should not happen for a valid planar graph). Discard rather + // than risk treating an open walk as a closed polygon. + if (!closed || touchesClipBounds || faceVertices.Count < 3) + return; + + double area = GeometryMathHelper.PolygonArea(faceVertices.Select(i => vertexPositions[i]).ToList()); + if (area < MinFaceArea) + return; + + List ring = [.. faceVertices.Select(i => vertexPositions[i])]; + List<(Point, Point)> edges = []; + for (int k = 0; k < ring.Count; k++) + edges.Add((ring[k], ring[(k + 1) % ring.Count])); + + faces.Add(new ConstructionFace(ring, edges)); + } + + #endregion + + #region Union tracing + + /// + /// Traces closed loops out of an unordered bag of edges, the same way + /// traces a face, but without the clip-bounds check — this + /// edge set has no clip artefacts to filter, since it only ever contains edges copied + /// from already-solved faces. Two loops emerge per real boundary (clockwise and + /// counter-clockwise); duplicate windings are dropped by keeping only positive signed + /// area, which also happens to be exactly the filter that separates a genuine outer + /// loop from a hole if the selection has one. + /// + private static List> TraceLoops(List<(Point A, Point B)> edges) + { + if (edges.Count == 0) + return []; + + List vertices = []; + Dictionary keyToIndex = []; + + int IndexOf(Point p) + { + PointKey key = new(p); + if (keyToIndex.TryGetValue(key, out int existing)) + return existing; + + vertices.Add(p); + int index = vertices.Count - 1; + keyToIndex[key] = index; + return index; + } + + HashSet<(int A, int B)> edgeIndexSet = []; + foreach ((Point a, Point b) in edges) + { + int ia = IndexOf(a); + int ib = IndexOf(b); + if (ia == ib) continue; + + edgeIndexSet.Add(ia < ib ? (ia, ib) : (ib, ia)); + } + + List[] sortedNeighbors = BuildSortedNeighbors(vertices, edgeIndexSet); + + HashSet<(int U, int V)> visited = []; + List> loops = []; + + foreach ((int a, int b) in edgeIndexSet) + { + TryTraceLoop(a, b, vertices, sortedNeighbors, visited, loops); + TryTraceLoop(b, a, vertices, sortedNeighbors, visited, loops); + } + + return loops; + } + + private static void TryTraceLoop( + int startU, + int startV, + List vertices, + List[] sortedNeighbors, + HashSet<(int U, int V)> visited, + List> loops) + { + (int U, int V) start = (startU, startV); + if (visited.Contains(start)) + return; + + List loopVertices = []; + (int U, int V) current = start; + int maxSteps = sortedNeighbors.Sum(n => n.Count) + 4; + bool closed = false; + + for (int step = 0; step < maxSteps; step++) + { + visited.Add(current); + loopVertices.Add(current.V); + + List neighborsOfV = sortedNeighbors[current.V]; + int idx = neighborsOfV.IndexOf(current.U); + if (idx < 0) + return; + + int next = neighborsOfV[(idx + 1) % neighborsOfV.Count]; + (int U, int V) nextEdge = (current.V, next); + + if (nextEdge == start) + { + closed = true; + break; + } + + current = nextEdge; + } + + if (!closed || loopVertices.Count < 3) + return; + + List ring = [.. loopVertices.Select(i => vertices[i])]; + + // Keeping only one winding direction both drops the mirror-image duplicate every + // loop produces and, for a selection with a hole, keeps the outer boundary over + // the inner one — the two are wound oppositely. + if (SignedArea(ring) <= 0) + return; + + if (GeometryMathHelper.PolygonArea(ring) < MinFaceArea) + return; + + loops.Add(ring); + } + + private static double SignedArea(IReadOnlyList ring) + { + double area = 0; + for (int i = 0; i < ring.Count; i++) + { + Point a = ring[i]; + Point b = ring[(i + 1) % ring.Count]; + area += (a.X * b.Y) - (b.X * a.Y); + } + + return area * 0.5; + } + + #endregion + + #region Line clipping and intersection + + /// + /// Clips the infinite line through / to + /// by first extending it far past the bounds in both + /// directions, then clipping that long segment against the rectangle. + /// + private static bool TryClipLineToBounds(Point p1, Point p2, Rect bounds, out Point a, out Point b) + { + a = default; + b = default; + + Vector direction = p2 - p1; + if (direction.Length < 1e-9) + return false; + + direction.Normalize(); + + double diagonal = Math.Sqrt((bounds.Width * bounds.Width) + (bounds.Height * bounds.Height)) + 1; + Point far1 = p1 - (direction * diagonal * 4); + Point far2 = p1 + (direction * diagonal * 4); + + return TryLiangBarskyClip(far1, far2, bounds, out a, out b); + } + + private static bool TryLiangBarskyClip(Point p0, Point p1, Rect rect, out Point clippedStart, out Point clippedEnd) + { + clippedStart = default; + clippedEnd = default; + + double t0 = 0; + double t1 = 1; + double dx = p1.X - p0.X; + double dy = p1.Y - p0.Y; + + Span p = [-dx, dx, -dy, dy]; + Span q = [p0.X - rect.Left, rect.Right - p0.X, p0.Y - rect.Top, rect.Bottom - p0.Y]; + + for (int i = 0; i < 4; i++) + { + if (Math.Abs(p[i]) < 1e-12) + { + if (q[i] < 0) return false; // Parallel to this edge and outside it. + continue; + } + + double t = q[i] / p[i]; + if (p[i] < 0) t0 = Math.Max(t0, t); + else t1 = Math.Min(t1, t); + } + + if (t0 > t1) + return false; + + clippedStart = new Point(p0.X + (t0 * dx), p0.Y + (t0 * dy)); + clippedEnd = new Point(p0.X + (t1 * dx), p0.Y + (t1 * dy)); + return true; + } + + /// + /// True segment-segment intersection (unlike , + /// which treats its inputs as infinite lines) — the arrangement is built from segments + /// already clipped to the construction bounds, so a crossing only counts here if it + /// falls within both of them. + /// + private static bool TrySegmentIntersect(Point a1, Point a2, Point b1, Point b2, out Point point, out double paramOnFirst) + { + point = default; + paramOnFirst = 0; + + double d1x = a2.X - a1.X; + double d1y = a2.Y - a1.Y; + double d2x = b2.X - b1.X; + double d2y = b2.Y - b1.Y; + + double denom = (d1x * d2y) - (d1y * d2x); + if (Math.Abs(denom) < 1e-9) + return false; + + double t = (((b1.X - a1.X) * d2y) - ((b1.Y - a1.Y) * d2x)) / denom; + double u = (((b1.X - a1.X) * d1y) - ((b1.Y - a1.Y) * d1x)) / denom; + + if (t < -SegmentParamEpsilon || t > 1 + SegmentParamEpsilon || + u < -SegmentParamEpsilon || u > 1 + SegmentParamEpsilon) + return false; + + point = new Point(a1.X + (t * d1x), a1.Y + (t * d1y)); + paramOnFirst = t; + return true; + } + + #endregion + + /// Rounded coordinates so shared vertices compare equal despite float noise. + private readonly record struct PointKey(long X, long Y) : IComparable + { + public PointKey(Point p) : this((long)Math.Round(p.X * 100), (long)Math.Round(p.Y * 100)) { } + + public int CompareTo(PointKey other) + { + int xCompare = X.CompareTo(other.X); + return xCompare != 0 ? xCompare : Y.CompareTo(other.Y); + } + } +} diff --git a/MagickCrop/Helpers/ConstructionSolver.cs b/MagickCrop/Helpers/ConstructionSolver.cs new file mode 100644 index 0000000..8b13ea8 --- /dev/null +++ b/MagickCrop/Helpers/ConstructionSolver.cs @@ -0,0 +1,347 @@ +using System.Windows; + +namespace MagickCrop.Helpers; + +/// +/// Derives shape corners from construction lines. A corner is the intersection of two +/// lines, so the user places points along an edge — where they are easy to place +/// precisely — and the corner falls out, even when it lands outside the image. +/// +/// Pure static math over ; no WPF elements, no control dependencies. +/// +public static class ConstructionSolver +{ + /// + /// Minimum angle between two lines for their intersection to be usable. Below this + /// the crossing point slides wildly for a sub-pixel change in either line. + /// + private const double MinAngleSine = 0.0872; // sin(5 degrees) + + /// A line shorter than this has no meaningful direction. + private const double MinLineLength = 1e-6; + + /// + /// Relative slack when testing whether a candidate corner lies inside a half-plane. + /// A real corner sits exactly on two of the lines, so it must survive its own + /// boundary test through floating-point error. + /// + private const double InsideEpsilon = 1e-6; + + /// Below this a ring has collapsed to a sliver and is not a usable shape. + private const double MinShapeArea = 1.0; + + public enum SolveStatus + { + Solved, + NotEnoughLines, + NoUsableCorners, + SelfIntersecting, + Degenerate + } + + public class SolveResult + { + public SolveStatus Status { get; init; } + + /// Corners in ring order. Empty unless is Solved. + public IReadOnlyList Ring { get; init; } = []; + + public bool IsSolved => Status == SolveStatus.Solved; + } + + private readonly record struct Candidate(Point Position, Guid LineA, Guid LineB); + + /// + /// Intersects two lines given two points on each. Returns false when the lines are + /// too close to parallel, or when either pair of points is too close together to + /// define a direction. + /// + /// + /// The homogeneous cross product's w component scales with the input segment + /// lengths, so it cannot be epsilon-tested directly — a short edge and a long edge + /// at the same angle produce very different w. Testing the sine of the angle between + /// the normalized directions instead makes the threshold mean the same thing at every + /// scale. + /// + public static bool TryIntersect(Point a1, Point b1, Point a2, Point b2, out Point intersection) + { + intersection = default; + + Vector d1 = b1 - a1; + Vector d2 = b2 - a2; + + if (d1.Length < MinLineLength || d2.Length < MinLineLength) + return false; + + d1.Normalize(); + d2.Normalize(); + + double cross = (d1.X * d2.Y) - (d1.Y * d2.X); + if (Math.Abs(cross) < MinAngleSine) + return false; + + // Homogeneous line coefficients: l = (A.Y - B.Y, B.X - A.X, A.X*B.Y - B.X*A.Y) + double l1a = a1.Y - b1.Y; + double l1b = b1.X - a1.X; + double l1c = (a1.X * b1.Y) - (b1.X * a1.Y); + + double l2a = a2.Y - b2.Y; + double l2b = b2.X - a2.X; + double l2c = (a2.X * b2.Y) - (b2.X * a2.Y); + + double w = (l1a * l2b) - (l2a * l1b); + if (Math.Abs(w) < double.Epsilon) + return false; + + double x = ((l1b * l2c) - (l2b * l1c)) / w; + double y = ((l2a * l1c) - (l1a * l2c)) / w; + + if (double.IsNaN(x) || double.IsNaN(y) || double.IsInfinity(x) || double.IsInfinity(y)) + return false; + + intersection = new Point(x, y); + return true; + } + + /// + /// Solves the shape formed by a set of construction lines. Order-independent: the + /// user can draw the edges in any sequence. + /// + /// Each line as its id and two defining points. + /// + /// Every placed point. Used to size the region where real corners can live, which is + /// how vanishing points get rejected. + /// + public static SolveResult Solve( + IReadOnlyList<(Guid Id, Point Start, Point End)> lines, + IReadOnlyList constructionPoints) + { + if (lines is null || lines.Count < 3) + return new SolveResult { Status = SolveStatus.NotEnoughLines }; + + List candidates = CollectCandidates(lines, constructionPoints); + + // Every line must contribute exactly two corners, and every corner must sit on + // exactly two lines. An angular sort will happily produce a plausible-looking + // ring out of the wrong pairs; these counts are what catch that. + if (candidates.Count != lines.Count) + return new SolveResult { Status = candidates.Count < lines.Count ? SolveStatus.NoUsableCorners : SolveStatus.SelfIntersecting }; + + foreach ((Guid id, _, _) in lines) + { + int usage = candidates.Count(c => c.LineA == id || c.LineB == id); + if (usage != 2) + return new SolveResult { Status = SolveStatus.SelfIntersecting }; + } + + List ring = OrderAsRing([.. candidates.Select(c => c.Position)]); + + if (GeometryMathHelper.PolygonArea(ring) < MinShapeArea) + return new SolveResult { Status = SolveStatus.Degenerate }; + + return new SolveResult { Status = SolveStatus.Solved, Ring = ring }; + } + + /// + /// A line as an inward-facing half-plane: points p with n · p + c >= 0 are on + /// the shape's side of it. The normal is a unit vector, so the expression is a signed + /// distance in pixels. + /// + private readonly record struct HalfPlane(Guid LineId, double Nx, double Ny, double C) + { + public double SignedDistance(Point p) => (Nx * p.X) + (Ny * p.Y) + C; + } + + /// + /// Intersects every pair of lines and keeps only the crossings that are actual + /// corners of the region the lines bound. + /// + /// + /// Four edge lines produce six crossings: four corners plus the two vanishing points + /// of the opposite-edge pairs. Distance from the construction does not separate them + /// reliably — a trapezoid in perspective has strongly converging sides, so its + /// vanishing point can sit closer than a legitimate corner does. + /// + /// Treating each line as an inward half-plane does separate them exactly: a real + /// corner satisfies every other line's constraint, while a vanishing point always + /// falls outside at least one. This assumes a convex shape, which also matches the + /// angular ring ordering below. + /// + private static List CollectCandidates( + IReadOnlyList<(Guid Id, Point Start, Point End)> lines, + IReadOnlyList constructionPoints) + { + Point centre = Centroid(constructionPoints); + + double radius = 0; + foreach (Point point in constructionPoints) + radius = Math.Max(radius, GeometryMathHelper.Distance(point, centre)); + + double epsilon = InsideEpsilon * Math.Max(1.0, radius); + + List halfPlanes = BuildHalfPlanes(lines, centre); + List candidates = []; + + for (int i = 0; i < lines.Count; i++) + { + for (int j = i + 1; j < lines.Count; j++) + { + if (!TryIntersect(lines[i].Start, lines[i].End, lines[j].Start, lines[j].End, out Point crossing)) + continue; + + if (!IsInsideAllOtherLines(crossing, halfPlanes, lines[i].Id, lines[j].Id, epsilon)) + continue; + + candidates.Add(new Candidate(crossing, lines[i].Id, lines[j].Id)); + } + } + + return candidates; + } + + private static List BuildHalfPlanes( + IReadOnlyList<(Guid Id, Point Start, Point End)> lines, + Point centre) + { + List halfPlanes = []; + + foreach ((Guid id, Point start, Point end) in lines) + { + Vector direction = end - start; + if (direction.Length < MinLineLength) continue; + + direction.Normalize(); + + // Normal to the line, flipped if needed so the construction's middle is on + // the positive side. + double nx = -direction.Y; + double ny = direction.X; + double c = -((nx * start.X) + (ny * start.Y)); + + if ((nx * centre.X) + (ny * centre.Y) + c < 0) + { + nx = -nx; + ny = -ny; + c = -c; + } + + halfPlanes.Add(new HalfPlane(id, nx, ny, c)); + } + + return halfPlanes; + } + + private static bool IsInsideAllOtherLines( + Point candidate, + List halfPlanes, + Guid lineA, + Guid lineB, + double epsilon) + { + foreach (HalfPlane halfPlane in halfPlanes) + { + // The candidate lies exactly on the two lines that made it. + if (halfPlane.LineId == lineA || halfPlane.LineId == lineB) continue; + + if (halfPlane.SignedDistance(candidate) < -epsilon) + return false; + } + + return true; + } + + /// + /// Sorts corners into ring order by their angle about their own centroid. For a + /// convex shape this is the polygon boundary, independent of the order the user + /// drew the edges in. + /// + private static List OrderAsRing(IReadOnlyList corners) + { + Point centre = Centroid(corners); + + return [.. corners.OrderBy(corner => Math.Atan2(corner.Y - centre.Y, corner.X - centre.X))]; + } + + public static Point Centroid(IReadOnlyList points) + { + if (points is null || points.Count == 0) + return default; + + double x = 0; + double y = 0; + foreach (Point point in points) + { + x += point.X; + y += point.Y; + } + + return new Point(x / points.Count, y / points.Count); + } + + /// + /// True when the ring winds consistently — every turn in the same rotational + /// direction. A ring that changes direction is a bowtie. + /// + public static bool IsConvexRing(IReadOnlyList ring) + { + if (ring is null || ring.Count < 3) return false; + + bool sawPositive = false; + bool sawNegative = false; + + for (int i = 0; i < ring.Count; i++) + { + Point a = ring[i]; + Point b = ring[(i + 1) % ring.Count]; + Point c = ring[(i + 2) % ring.Count]; + + double cross = ((b.X - a.X) * (c.Y - b.Y)) - ((b.Y - a.Y) * (c.X - b.X)); + + if (cross > 0) sawPositive = true; + else if (cross < 0) sawNegative = true; + + if (sawPositive && sawNegative) return false; + } + + return true; + } + + /// + /// Rotates a ring so the corner nearest the top-left starts it, and orients it + /// clockwise. labels + /// corners by x+y / x-y extremes, which mislabels a strongly rotated quad; feeding + /// it a consistently wound ring keeps the labels honest. + /// + public static List NormalizeWinding(IReadOnlyList ring) + { + if (ring is null || ring.Count < 3) return [.. ring ?? []]; + + List ordered = [.. ring]; + + // Shoelace sign gives the winding direction. Y grows downward on a canvas, so a + // positive signed area is counter-clockwise on screen; flip it to clockwise. + double signedArea = 0; + for (int i = 0; i < ordered.Count; i++) + { + Point a = ordered[i]; + Point b = ordered[(i + 1) % ordered.Count]; + signedArea += (a.X * b.Y) - (b.X * a.Y); + } + + if (signedArea > 0) + ordered.Reverse(); + + int startIndex = 0; + double best = double.MaxValue; + for (int i = 0; i < ordered.Count; i++) + { + double score = ordered[i].X + ordered[i].Y; + if (score >= best) continue; + + best = score; + startIndex = i; + } + + return [.. ordered.Skip(startIndex), .. ordered.Take(startIndex)]; + } +} diff --git a/MagickCrop/Helpers/GeometryMathHelper.cs b/MagickCrop/Helpers/GeometryMathHelper.cs index 9aa6f8a..ff5541e 100644 --- a/MagickCrop/Helpers/GeometryMathHelper.cs +++ b/MagickCrop/Helpers/GeometryMathHelper.cs @@ -10,6 +10,13 @@ public static class GeometryMathHelper public static Point MidPoint(Point a, Point b) => new((a.X + b.X) / 2.0, (a.Y + b.Y) / 2.0); + public static double Distance(Point a, Point b) + { + double dx = b.X - a.X; + double dy = b.Y - a.Y; + return Math.Sqrt((dx * dx) + (dy * dy)); + } + public static Point GetEllipseCenter(Ellipse ellipse) => new(Canvas.GetLeft(ellipse) + (ellipse.Width / 2), Canvas.GetTop(ellipse) + (ellipse.Height / 2)); @@ -64,6 +71,37 @@ public static double PolygonPerimeter(IReadOnlyList vertices, bool isClos return perimeter; } + /// + /// The unique circle through three points. Returns false when they are collinear + /// (or two coincide), where no finite circle exists. + /// + public static bool TryGetCircumcircle(Point a, Point b, Point c, out Point center, out double radius) + { + center = default; + radius = 0; + + // Twice the signed area of the triangle: zero exactly when the points are + // collinear, and the denominator of the circumcenter either way. + double d = 2 * ((a.X * (b.Y - c.Y)) + (b.X * (c.Y - a.Y)) + (c.X * (a.Y - b.Y))); + + if (Math.Abs(d) < 1e-9) return false; + + double aSquared = (a.X * a.X) + (a.Y * a.Y); + double bSquared = (b.X * b.X) + (b.Y * b.Y); + double cSquared = (c.X * c.X) + (c.Y * c.Y); + + double x = ((aSquared * (b.Y - c.Y)) + (bSquared * (c.Y - a.Y)) + (cSquared * (a.Y - b.Y))) / d; + double y = ((aSquared * (c.X - b.X)) + (bSquared * (a.X - c.X)) + (cSquared * (b.X - a.X))) / d; + + if (double.IsNaN(x) || double.IsNaN(y) || double.IsInfinity(x) || double.IsInfinity(y)) + return false; + + center = new Point(x, y); + radius = Distance(center, a); + + return !double.IsNaN(radius) && !double.IsInfinity(radius) && radius > 0; + } + public static double PolygonArea(IReadOnlyList vertices) { if (vertices is null || vertices.Count < 3) return 0; diff --git a/MagickCrop/Helpers/ImageSampleBuffer.cs b/MagickCrop/Helpers/ImageSampleBuffer.cs new file mode 100644 index 0000000..bc62f2a --- /dev/null +++ b/MagickCrop/Helpers/ImageSampleBuffer.cs @@ -0,0 +1,114 @@ +using ImageMagick; +using System.IO; + +namespace MagickCrop.Helpers; + +/// +/// An RGB copy of an image, held in memory so it can be sampled many times per second +/// without touching disk. +/// +/// The app's canonical image is a temp file that is rewritten by every edit, so anything +/// that wants pixel data normally re-reads and re-decodes it. That is fine for a one-shot +/// operation and far too slow for a gesture that samples while the mouse moves — hence +/// this buffer, built once per image and rebuilt when the path changes. +/// +/// +/// Colour is kept rather than reduced to luminance: a boundary between two colours of +/// similar brightness — yellow against blue, or red against a grey of the same lightness — +/// is plain to the eye and almost invisible in a greyscale copy. +/// +public sealed class ImageSampleBuffer +{ + private const int Channels = 3; + + private readonly byte[] pixels; + + public int Width { get; } + public int Height { get; } + + private ImageSampleBuffer(byte[] pixels, int width, int height) + { + this.pixels = pixels; + Width = width; + Height = height; + } + + /// + /// Reads an image off disk into memory. Returns null when the file cannot be read, so + /// callers can degrade rather than throw mid-gesture. + /// + /// + /// Deliberately just a decode — no denoising pass. A blur here cost ten times as long + /// as everything else combined (~960ms of ~1070ms on a 6MP photo) and bought nothing: + /// averages a band of parallel lanes, which smooths + /// along the boundary, and runs a Gaussian over the profile, which smooths across it. + /// That is a separable 2D blur already, done over the few hundred samples a probe + /// actually touches rather than over all six million pixels. + /// + public static ImageSampleBuffer? FromFile(string? imagePath) + { + if (string.IsNullOrEmpty(imagePath) || !File.Exists(imagePath)) + return null; + + try + { + using MagickImage image = new(imagePath); + + int width = (int)image.Width; + int height = (int)image.Height; + if (width <= 0 || height <= 0) + return null; + + // ToByteArray scales this Q16 build's values to 0-255 per channel. + byte[]? rgb = image.GetPixelsUnsafe().ToByteArray(PixelMapping.RGB); + if (rgb is null || rgb.Length < width * height * Channels) + return null; + + return new ImageSampleBuffer(rgb, width, height); + } + catch (Exception) + { + // A missing or half-written temp file must not take down the gesture. + return null; + } + } + + /// + /// Colour at a fractional pixel position, bilinearly interpolated. Coordinates are + /// clamped to the image, so a sample lane that runs off the edge repeats the border + /// rather than reading a wrapped or zero pixel — a fabricated black border would look + /// exactly like a boundary. + /// + public void SampleBilinear(double x, double y, out double red, out double green, out double blue) + { + x = Math.Clamp(x, 0, Width - 1); + y = Math.Clamp(y, 0, Height - 1); + + int x0 = (int)x; + int y0 = (int)y; + int x1 = Math.Min(x0 + 1, Width - 1); + int y1 = Math.Min(y0 + 1, Height - 1); + + double fx = x - x0; + double fy = y - y0; + + int topLeft = ((y0 * Width) + x0) * Channels; + int topRight = ((y0 * Width) + x1) * Channels; + int bottomLeft = ((y1 * Width) + x0) * Channels; + int bottomRight = ((y1 * Width) + x1) * Channels; + + red = Interpolate(topLeft, topRight, bottomLeft, bottomRight, 0, fx, fy); + green = Interpolate(topLeft, topRight, bottomLeft, bottomRight, 1, fx, fy); + blue = Interpolate(topLeft, topRight, bottomLeft, bottomRight, 2, fx, fy); + } + + private double Interpolate( + int topLeft, int topRight, int bottomLeft, int bottomRight, + int channel, double fx, double fy) + { + double top = (pixels[topLeft + channel] * (1 - fx)) + (pixels[topRight + channel] * fx); + double bottom = (pixels[bottomLeft + channel] * (1 - fx)) + (pixels[bottomRight + channel] * fx); + + return (top * (1 - fy)) + (bottom * fy); + } +} diff --git a/MagickCrop/Helpers/MagickExtensions.cs b/MagickCrop/Helpers/MagickExtensions.cs index 68f8edd..1f87b1a 100644 --- a/MagickCrop/Helpers/MagickExtensions.cs +++ b/MagickCrop/Helpers/MagickExtensions.cs @@ -1,4 +1,5 @@ using ImageMagick; +using System.IO; namespace MagickCrop; @@ -11,4 +12,77 @@ internal static void ScaleAll(this MagickGeometry geometry, double factor) geometry.Width = (uint)(geometry.Width * factor); geometry.Height = (uint)(geometry.Height * factor); } + + /// + /// Picks an encoder that can actually represent the image. Falls back to PNG when the + /// image has no known format, when the format has no encoder, or when the image carries + /// transparency that the current format cannot store. + /// + internal static MagickFormat GetSafeWriteFormat(this IMagickImage image) + { + MagickFormat format = image.Format; + + if (format is MagickFormat.Unknown) + return MagickFormat.Png; + + if (image.HasAlpha && format is MagickFormat.Jpeg or MagickFormat.Jpg or MagickFormat.Jpe or MagickFormat.Bmp) + return MagickFormat.Png; + + IMagickFormatInfo? formatInfo = MagickFormatInfo.Create(format); + + if (formatInfo is null || !formatInfo.SupportsWriting) + return MagickFormat.Png; + + return format; + } + + /// + /// Writes the image to a new temp file using an encoder that is guaranteed to exist. + /// hands back a ".tmp" name and ImageMagick resolves the + /// encoder from the extension, which yields and throws + /// no encode delegate for this image format. This gives the temp file a real + /// extension and passes the format explicitly. + /// + internal static async Task WriteToTempFileAsync(this IMagickImage image, MagickFormat? format = null) + { + MagickFormat targetFormat = format ?? image.GetSafeWriteFormat(); + string tempFileName = CreateTempFileName(targetFormat); + + image.Format = targetFormat; + await image.WriteAsync(tempFileName, targetFormat); + + return tempFileName; + } + + /// + internal static string WriteToTempFile(this IMagickImage image, MagickFormat? format = null) + { + MagickFormat targetFormat = format ?? image.GetSafeWriteFormat(); + string tempFileName = CreateTempFileName(targetFormat); + + image.Format = targetFormat; + image.Write(tempFileName, targetFormat); + + return tempFileName; + } + + private static string CreateTempFileName(MagickFormat format) + { + string tempFileName = Path.GetTempFileName(); + string withExtension = Path.ChangeExtension(tempFileName, format.ToString().ToLowerInvariant()); + + if (!string.Equals(tempFileName, withExtension, StringComparison.OrdinalIgnoreCase)) + { + try + { + File.Delete(tempFileName); + } + catch (IOException) + { + // Leaving the zero-byte placeholder behind is harmless. + } + } + + return withExtension; + } } diff --git a/MagickCrop/Helpers/QuadrilateralDetector.cs b/MagickCrop/Helpers/QuadrilateralDetector.cs index b11157d..a2dcb8b 100644 --- a/MagickCrop/Helpers/QuadrilateralDetector.cs +++ b/MagickCrop/Helpers/QuadrilateralDetector.cs @@ -35,6 +35,12 @@ public class DetectedQuadrilateral public double Area { get; set; } public double Confidence { get; set; } + /// + /// Overrides the generated name in the selector list. Set for quadrilaterals that + /// did not come from contour detection, such as a user's construction. + /// + public string? Label { get; set; } + public DetectedQuadrilateral(System.Windows.Point[] points, double area, double confidence) { if (points.Length != 4) diff --git a/MagickCrop/MainWindow.Construction.cs b/MagickCrop/MainWindow.Construction.cs new file mode 100644 index 0000000..f7e9d50 --- /dev/null +++ b/MagickCrop/MainWindow.Construction.cs @@ -0,0 +1,898 @@ +using MagickCrop.Controls; +using MagickCrop.Helpers; +using MagickCrop.Models.MeasurementControls; +using System.Collections.ObjectModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; + +namespace MagickCrop; + +/// +/// Parametric construction geometry: place points along an object's edges, connect them +/// into edge lines, and let the corners fall out of where those lines cross. Moving a +/// point re-solves the shape. +/// +public partial class MainWindow +{ + /// Identifies which construction tool the user has active, across both tabs. + public enum ConstructionTool + { + None, + Edge, + Point, + Line, + Boundary, + Face + } + + private const string ConstructionEdgeTag = "ConstructionEdge"; + private const string ConstructionPointTag = "ConstructionPoint"; + private const string ConstructionLineTag = "ConstructionLine"; + private const string ConstructionBoundaryTag = "ConstructionBoundary"; + private const string ConstructionFaceTag = "ConstructionFace"; + + /// Minimum drag distance before an edge drag counts as more than a click. + private const double ConstructionDragThreshold = 5.0; + + private readonly ObservableCollection constructionControls = []; + + private ConstructionOverlayControl? activeConstructionControl; + + /// The overlay currently accepting new points and lines. + private ConstructionOverlayControl? constructionOverlay; + + /// The end point being dragged out while the Edge tool creates a line. + private Guid? constructionDragEndPointId; + private Guid? constructionDragLineId; + + /// Where the in-progress boundary probe was pressed, in canvas coordinates. + private Point? boundaryProbeStart; + + // In-memory copy of the current image, kept so a probe can sample on every mouse move + // without re-decoding the file. Keyed by the path it was built from, which is how it + // invalidates itself: every edit writes a new temp file. + private ImageSampleBuffer? probeBuffer; + private string? probeBufferPath; + private bool isWarmingProbeBuffer; + + #region Tool state + + private ConstructionTool ActiveConstructionTool + { + get + { + foreach (ToggleButton toggle in AllToolToggles()) + { + if (toggle.IsChecked != true || toggle.Tag is not string tag) continue; + + switch (tag) + { + case ConstructionEdgeTag: return ConstructionTool.Edge; + case ConstructionPointTag: return ConstructionTool.Point; + case ConstructionLineTag: return ConstructionTool.Line; + case ConstructionBoundaryTag: return ConstructionTool.Boundary; + case ConstructionFaceTag: return ConstructionTool.Face; + } + } + + return ConstructionTool.None; + } + } + + private bool IsConstructionToolActive => ActiveConstructionTool != ConstructionTool.None; + + /// + /// Grab radius in canvas units. Divided by the zoom so it stays constant on screen — + /// at 5x zoom a 15px screen radius is only 3 canvas units. + /// + private double ConstructionHitTolerance => + Defaults.VertexCloseTolerance / Math.Max(MinZoom, canvasScale.ScaleX); + + #endregion + + #region Overlay lifecycle + + /// + /// Returns the overlay to build into, creating and wiring it on first use. + /// + private ConstructionOverlayControl EnsureConstructionOverlay() + { + if (constructionOverlay is not null) + return constructionOverlay; + + ConstructionOverlayControl overlay = new() + { + ScaleFactor = ScaleInput?.Value ?? 1.0, + Units = MeasurementUnits?.Text ?? "pixels" + }; + + WireConstructionOverlay(overlay); + + constructionControls.Add(overlay); + ShapeCanvas.Children.Add(overlay); + constructionOverlay = overlay; + + UpdateConstructionImageBounds(); + UpdateConstructionVisualScale(); + SyncConstructionToolState(); + + return overlay; + } + + private void WireConstructionOverlay(ConstructionOverlayControl overlay) + { + overlay.MeasurementPointMouseDown += ConstructionPoint_MouseDown; + overlay.RemoveControlRequested += ConstructionControl_RemoveControlRequested; + overlay.ConstructionChanged += ConstructionOverlay_Changed; + overlay.GeometryEdited += ConstructionOverlay_GeometryEdited; + overlay.FaceSelectionChanged += ConstructionOverlay_Changed; + } + + private void UnwireConstructionOverlay(ConstructionOverlayControl overlay) + { + overlay.MeasurementPointMouseDown -= ConstructionPoint_MouseDown; + overlay.RemoveControlRequested -= ConstructionControl_RemoveControlRequested; + overlay.ConstructionChanged -= ConstructionOverlay_Changed; + overlay.GeometryEdited -= ConstructionOverlay_GeometryEdited; + overlay.FaceSelectionChanged -= ConstructionOverlay_Changed; + } + + /// + /// Every committed point/line edit lands here and becomes one undo step. The overlay + /// hands over before/after snapshots; it does not know about the undo stack itself. + /// + private void ConstructionOverlay_GeometryEdited(object? sender, ConstructionGeometryEditedEventArgs e) + { + if (sender is not ConstructionOverlayControl overlay) return; + + UndoRedo.AddUndo(new ConstructionGeometryEditedItem(overlay, e.Before, e.After)); + } + + private void ConstructionControl_RemoveControlRequested(object sender, EventArgs e) + { + if (sender is not ConstructionOverlayControl overlay) return; + + RemoveConstructionOverlays([overlay], recordUndo: true); + } + + private void ConstructionOverlay_Changed(object? sender, EventArgs e) => + UpdateConstructionApplyButtons(); + + /// + /// Removes every construction overlay without an undo step. Called from the shared + /// measurement teardown, where the whole undo stack is going away anyway. + /// + private void RemoveConstructionControls() + { + RemoveConstructionOverlays([.. constructionControls], recordUndo: false); + ReleaseProbeBuffer(); + } + + /// + /// Takes overlays off the canvas, optionally as an undoable step. The controls + /// themselves are kept alive so an undo can put back the very same instances that + /// earlier geometry undo items still reference. + /// + private void RemoveConstructionOverlays(List overlays, bool recordUndo) + { + if (overlays.Count == 0) return; + + foreach (ConstructionOverlayControl overlay in overlays) + { + UnwireConstructionOverlay(overlay); + constructionControls.Remove(overlay); + ShapeCanvas.Children.Remove(overlay); + } + + if (recordUndo) + { + UndoRedo.AddUndo(new ConstructionOverlaysRemovedItem( + overlays, + constructionControls, + ShapeCanvas, + WireConstructionOverlay, + UnwireConstructionOverlay, + AfterConstructionOverlaysRestored)); + } + + AfterConstructionOverlaysRestored(); + } + + /// + /// Re-establishes the window's view of the overlays after they are added or removed, + /// including which one new points and lines get built into. + /// + private void AfterConstructionOverlaysRestored() + { + constructionOverlay = constructionControls.FirstOrDefault(); + activeConstructionControl = null; + constructionDragEndPointId = null; + constructionDragLineId = null; + + UpdateConstructionImageBounds(); + UpdateConstructionVisualScale(); + SyncConstructionToolState(); + UpdateConstructionApplyButtons(); + } + + private void UpdateConstructionImageBounds() + { + if (MainImage is null) return; + + Rect bounds = new(0, 0, + Math.Max(1, MainImage.ActualWidth), + Math.Max(1, MainImage.ActualHeight)); + + foreach (ConstructionOverlayControl overlay in constructionControls) + overlay.ImageBounds = bounds; + } + + /// + /// Counter-scales construction gizmos against the canvas zoom, alongside the + /// transform handles. + /// + private void UpdateConstructionVisualScale() + { + double inverseScale = 1.0 / Math.Max(MinZoom, canvasScale.ScaleX); + + foreach (ConstructionOverlayControl overlay in constructionControls) + overlay.UpdateVisualScale(inverseScale); + } + + #endregion + + #region Tool input + + /// + /// Handles a canvas click for whichever construction tool is active. + /// Returns true when the click was consumed. + /// + private bool HandleConstructionMouseDown(Point canvasPoint, MouseButtonEventArgs e) + { + switch (ActiveConstructionTool) + { + case ConstructionTool.Edge: + StartConstructionEdgeDrag(canvasPoint); + e.Handled = true; + return true; + + case ConstructionTool.Point: + PlaceConstructionPoint(canvasPoint); + e.Handled = true; + return true; + + case ConstructionTool.Line: + HandleConstructionLineClick(canvasPoint); + e.Handled = true; + return true; + + case ConstructionTool.Boundary: + StartBoundaryProbe(canvasPoint); + e.Handled = true; + return true; + + case ConstructionTool.Face: + // Selection happens on the face Path elements themselves, which set + // e.Handled before the click ever reaches here. Reaching this case means + // the click landed on empty space between cells, so there is nothing to do. + return false; + + default: + return false; + } + } + + /// + /// Begins a drag that lays down a whole edge: a start point (reusing an existing one + /// if the press landed on it) and an end point that follows the cursor. + /// + private void StartConstructionEdgeDrag(Point canvasPoint) + { + ConstructionOverlayControl overlay = EnsureConstructionOverlay(); + double tolerance = ConstructionHitTolerance; + + // Starting a new edge abandons whatever was picked before it. + overlay.ClearSelection(); + + // Points, the line, and every position the loose end passes through are all one + // undo step — the user laid down one edge. + overlay.BeginDrag(); + + Guid startId = overlay.FindPointNear(canvasPoint, tolerance) ?? overlay.AddPoint(canvasPoint); + Guid endId = overlay.AddPoint(canvasPoint); + + constructionDragEndPointId = endId; + constructionDragLineId = overlay.AddLine(startId, endId); + + draggingMode = DraggingMode.ConstructionEdgeCreate; + isCreatingMeasurement = true; + ShapeCanvas.CaptureMouse(); + ShowPixelZoom(canvasPoint); + } + + private void PlaceConstructionPoint(Point canvasPoint) + { + ConstructionOverlayControl overlay = EnsureConstructionOverlay(); + + // Landing on an existing point selects it rather than doing nothing — a click + // aimed at a point should always mean "this one", however close it lands. This + // is checked first so picking a second point to connect still works. + if (overlay.TrySelectPointNear(canvasPoint, ConstructionHitTolerance)) + return; + + // Genuinely empty space: the click means "done with that selection". + overlay.ClearSelection(); + overlay.AddPoint(canvasPoint); + ShowPixelZoom(canvasPoint); + } + + /// + /// Two-click line creation. Each click picks (or creates) a point and hands it to the + /// overlay's selection; because the tool sets ConnectOnSecondSelection, the second + /// pick connects immediately and stays selected so the next click chains on. + /// + private void HandleConstructionLineClick(Point canvasPoint) + { + ConstructionOverlayControl overlay = EnsureConstructionOverlay(); + + Guid pointId = overlay.FindPointNear(canvasPoint, ConstructionHitTolerance) + ?? overlay.AddPoint(canvasPoint); + + overlay.SelectPoint(pointId); + ShowPixelZoom(canvasPoint); + } + + /// + /// Drives the in-progress gesture on mouse move. Returns true when consumed. + /// + private bool HandleConstructionMouseMove(Point canvasPoint) + { + if (draggingMode == DraggingMode.ConstructionBoundaryProbe) + return UpdateBoundaryProbe(canvasPoint); + + if (draggingMode == DraggingMode.ConstructionEdgeCreate && + constructionOverlay is not null && + constructionDragEndPointId is Guid dragEndId) + { + constructionOverlay.MoveConstructionPoint(dragEndId, canvasPoint); + return true; + } + + // Rubber band for the Line tool, driven off the selection rather than a parallel + // copy of it, so it always points at the point that is actually highlighted. + if (ActiveConstructionTool == ConstructionTool.Line && + constructionOverlay?.SingleSelectedPointPosition is Point start) + { + constructionOverlay.ShowPreviewLine(start, canvasPoint); + return true; + } + + constructionOverlay?.HidePreviewLine(); + return false; + } + + /// + /// Completes an edge drag. A drag that never really moved is treated as a click that + /// just placed a point; otherwise the loose end snaps onto an existing point when it + /// lands near one, which is what joins edges into a shape. + /// + private bool HandleConstructionMouseUp(Point canvasPoint) + { + if (draggingMode == DraggingMode.ConstructionBoundaryProbe) + return FinishBoundaryProbe(canvasPoint); + + if (draggingMode != DraggingMode.ConstructionEdgeCreate) + return false; + + ConstructionOverlayControl? overlay = constructionOverlay; + Guid? endId = constructionDragEndPointId; + Guid? lineId = constructionDragLineId; + + constructionDragEndPointId = null; + constructionDragLineId = null; + isCreatingMeasurement = false; + draggingMode = DraggingMode.None; + ShapeCanvas.ReleaseMouseCapture(); + + if (overlay is null || endId is not Guid dragEndId || lineId is not Guid dragLineId) + { + overlay?.EndDrag(); + return true; + } + + try + { + bool movedFarEnough = + Math.Abs(canvasPoint.X - clickedPoint.X) > ConstructionDragThreshold || + Math.Abs(canvasPoint.Y - clickedPoint.Y) > ConstructionDragThreshold; + + if (!movedFarEnough) + { + // Not a drag — drop the degenerate line and its loose end, keeping the + // point the press created. + overlay.RemoveConstructionLine(dragLineId); + overlay.RemoveConstructionPoint(dragEndId); + return true; + } + + overlay.MoveConstructionPoint(dragEndId, canvasPoint); + + if (overlay.FindPointNear(canvasPoint, ConstructionHitTolerance, exclude: dragEndId) is Guid reuseId) + { + overlay.SetLineEnd(dragLineId, reuseId); + overlay.RemoveConstructionPoint(dragEndId); + } + + return true; + } + finally + { + overlay.EndDrag(); + } + } + + #region Boundary probe + + /// + /// Begins a probe: the user drags a short line across a boundary and the point lands + /// on the transition rather than wherever the cursor happened to stop. + /// + /// + /// Unlike the Edge tool this creates nothing up front. There is no point to drag until + /// the analysis has somewhere to put one, so the whole gesture is a preview and the + /// geometry is only touched on release. + /// + private void StartBoundaryProbe(Point canvasPoint) + { + ConstructionOverlayControl overlay = EnsureConstructionOverlay(); + + // Starting a probe abandons whatever was picked before it, as every other tool does. + overlay.ClearSelection(); + + boundaryProbeStart = canvasPoint; + draggingMode = DraggingMode.ConstructionBoundaryProbe; + isCreatingMeasurement = true; + ShapeCanvas.CaptureMouse(); + ShowPixelZoom(canvasPoint); + + // Normally already warm from when the tool was picked. Doing it here rather than + // on the first mouse move keeps any decode cost out of the middle of the drag. + GetProbeBuffer(); + } + + /// + /// Tracks the probe as it is dragged, showing both the probe line and where the point + /// would land if it were released now. + /// + private bool UpdateBoundaryProbe(Point canvasPoint) + { + if (boundaryProbeStart is not Point start || constructionOverlay is null) + return false; + + constructionOverlay.ShowPreviewLine(start, canvasPoint); + + if (TryProbeBoundary(start, canvasPoint, out Point found, out bool isWeak)) + { + constructionOverlay.ShowBoundaryCandidate(found, isWeak); + + // The crosshairs belong on the edge that was found, not on the cursor — the + // whole point of the gesture is that those are different places, and seeing + // the transition magnified is how the user judges whether it picked right. + UpdatePixelZoom(canvasPoint, found); + } + else + { + constructionOverlay.HideBoundaryCandidate(); + UpdatePixelZoom(canvasPoint); + } + + return true; + } + + /// + /// Commits the probe as a single free point. A probe too short to say which way the + /// boundary runs places nothing rather than guessing at the press position. + /// + private bool FinishBoundaryProbe(Point canvasPoint) + { + ConstructionOverlayControl? overlay = constructionOverlay; + Point? probeStart = boundaryProbeStart; + + boundaryProbeStart = null; + isCreatingMeasurement = false; + draggingMode = DraggingMode.None; + ShapeCanvas.ReleaseMouseCapture(); + + overlay?.HidePreviewLine(); + overlay?.HideBoundaryCandidate(); + + if (overlay is null || probeStart is not Point start) + return true; + + bool movedFarEnough = + Math.Abs(canvasPoint.X - start.X) > ConstructionDragThreshold || + Math.Abs(canvasPoint.Y - start.Y) > ConstructionDragThreshold; + + if (!movedFarEnough) + return true; + + if (!TryProbeBoundary(start, canvasPoint, out Point found, out bool isWeak)) + return true; + + // AddPoint is one Edit, so the whole gesture lands on the undo stack as one step. + overlay.AddPoint(found); + + // Said rather than enforced: a weak reading is still usually close, and the point + // is an ordinary one the user can drag. + overlay.TransientHint = isWeak + ? "Weak boundary — check the point and nudge it if needed" + : null; + + return true; + } + + /// + /// Runs one probe. Canvas coordinates in and out; the analysis itself happens in image + /// pixel space, at the resolution the pixels actually have rather than the one they + /// are displayed at. + /// + private bool TryProbeBoundary(Point canvasStart, Point canvasEnd, out Point found, out bool isWeak) + { + found = default; + isWeak = true; + + Point startPixel = ConvertCanvasToImageCoordinates(canvasStart); + Point endPixel = ConvertCanvasToImageCoordinates(canvasEnd); + + if (!BoundaryProbeAnalyzer.TryFindBoundary( + GetProbeBuffer(), startPixel, endPixel, + out BoundaryProbeAnalyzer.BoundaryProbeResult result)) + return false; + + found = ConvertImageToCanvasCoordinates(result.Position); + isWeak = result.IsWeak; + return true; + } + + /// + /// The sample buffer for the image on screen right now, building it if the warm-up + /// has not finished or the image has changed since. + /// + private ImageSampleBuffer? GetProbeBuffer() + { + string? path = ViewModel.ImagePath; + if (string.IsNullOrEmpty(path)) + return null; + + if (probeBuffer is not null && probeBufferPath == path) + return probeBuffer; + + // Sampling the previous image's pixels would put the point in the wrong place + // silently, which is worse than a brief pause here. + probeBuffer = ImageSampleBuffer.FromFile(path); + probeBufferPath = probeBuffer is null ? null : path; + return probeBuffer; + } + + /// + /// Builds the sample buffer off the UI thread, so picking the tool absorbs the decode + /// instead of the first press. Shows a ring next to the tool button while it runs. + /// + private async void WarmProbeBuffer() + { + string? path = ViewModel.ImagePath; + + if (string.IsNullOrEmpty(path) || path == probeBufferPath) + { + SetProbeWarmingIndicator(false); + return; + } + + // Reselecting the tool while a build is still running: the in-flight one finishes + // the job, but the indicator still has to be put back up for it. + SetProbeWarmingIndicator(true); + + if (isWarmingProbeBuffer) + return; + + isWarmingProbeBuffer = true; + + try + { + ImageSampleBuffer? buffer = await Task.Run(() => ImageSampleBuffer.FromFile(path)); + + // The tool may have been put down, or the image edited again, while this was + // decoding. Either way the result is stale; GetProbeBuffer rebuilds on demand. + if (buffer is not null && + path == ViewModel.ImagePath && + ActiveConstructionTool == ConstructionTool.Boundary) + { + probeBuffer = buffer; + probeBufferPath = path; + } + } + finally + { + isWarmingProbeBuffer = false; + SetProbeWarmingIndicator(false); + } + } + + /// + /// Drops the sample buffer. Called when the tool is put down: it is the largest thing + /// this window holds that nothing else needs, and it is cheap enough to rebuild. + /// + private void ReleaseProbeBuffer() + { + probeBuffer = null; + probeBufferPath = null; + SetProbeWarmingIndicator(false); + } + + private void SetProbeWarmingIndicator(bool isWarming) + { + Visibility visibility = isWarming ? Visibility.Visible : Visibility.Collapsed; + + // Mirrored on both tabs, like the tool button itself. + if (ConstructionBoundaryProgressRing is not null) + ConstructionBoundaryProgressRing.Visibility = visibility; + + if (ConstructionBoundaryProgressRingTransform is not null) + ConstructionBoundaryProgressRingTransform.Visibility = visibility; + } + + #endregion + + /// Clears any half-finished construction gesture. + private void CancelConstructionGesture() + { + if (constructionOverlay is not null) + { + if (constructionDragLineId is Guid lineId) + constructionOverlay.RemoveConstructionLine(lineId); + + if (constructionDragEndPointId is Guid endId) + constructionOverlay.RemoveConstructionPoint(endId); + + constructionOverlay.HidePreviewLine(); + } + + foreach (ConstructionOverlayControl overlay in constructionControls) + { + // Closes any drag abandoned by the cancel, so what survives it is still one + // undo step. + overlay.EndDrag(); + overlay.ClearSelection(); + overlay.HideBoundaryCandidate(); + } + + constructionDragEndPointId = null; + constructionDragLineId = null; + boundaryProbeStart = null; + } + + /// + /// Drops the selection on every overlay. This is what a click on bare canvas means: + /// the click reached the canvas only because no point, line, or faint line claimed + /// it first, so the user was pointing at nothing. + /// + private void ClearConstructionSelection() + { + foreach (ConstructionOverlayControl overlay in constructionControls) + overlay.ClearSelection(); + } + + /// + /// Pushes the active tool down to the overlays. Only the Line tool wants a second + /// pick to connect on its own; every other tool leaves the faint line to be clicked. + /// + private void SyncConstructionToolState() + { + ConstructionTool tool = ActiveConstructionTool; + bool connectOnSecondSelection = tool == ConstructionTool.Line; + bool faceSelectionActive = tool == ConstructionTool.Face; + + foreach (ConstructionOverlayControl overlay in constructionControls) + { + overlay.ConnectOnSecondSelection = connectOnSecondSelection; + overlay.IsFaceSelectionModeActive = faceSelectionActive; + } + + // Reading the image for probing takes a moment, so it happens when the tool is + // picked rather than partway through the first drag — and the copy is dropped + // again the moment the tool is put down, since nothing else uses it. + if (tool == ConstructionTool.Boundary) + WarmProbeBuffer(); + else + ReleaseProbeBuffer(); + } + + /// + /// True when any overlay has a point or line picked, so the window can tell whether + /// Delete belongs to the construction or to some other selection. + /// + private bool HasConstructionSelection => + constructionControls.Any(overlay => overlay.HasSelection); + + /// + /// Deletes the selected line — or, when no line is selected, the selected points. + /// Removing a connection deliberately leaves its two points in place. + /// + private bool DeleteSelectedConstruction() + { + bool deleted = false; + + foreach (ConstructionOverlayControl overlay in constructionControls) + deleted |= overlay.DeleteSelection(); + + return deleted; + } + + private void ConstructionPoint_MouseDown(object sender, MouseButtonEventArgs? e) + { + if (!HandleMeasurementMouseDown( + sender, e, DraggingMode.ConstructionPoint, control => activeConstructionControl = control)) + return; + + // The whole drag is one undo step, not one per mouse move. + activeConstructionControl?.BeginDrag(); + } + + #endregion + + #region Applying the constructed shape + + /// + /// The constructed quadrilateral in canvas coordinates, or null when the construction + /// is not exactly four solved corners. + /// + private QuadrilateralDetector.DetectedQuadrilateral? GetConstructedQuadrilateral() + { + foreach (ConstructionOverlayControl overlay in constructionControls) + { + if (overlay.TryGetQuadrilateral(out QuadrilateralDetector.DetectedQuadrilateral quad)) + return quad; + } + + return null; + } + + /// + /// Prepends the constructed shape to a detection list so "Detect Shape" surfaces it + /// alongside the automatically found quadrilaterals. + /// + private void PrependConstructedQuadrilateral(List quads) + { + if (GetConstructedQuadrilateral() is not QuadrilateralDetector.DetectedQuadrilateral quad) + return; + + quad.Label = "Constructed shape"; + quads.Insert(0, quad); + } + + private bool HasConstructedQuadrilateral => GetConstructedQuadrilateral() is not null; + + /// + /// The constructed shape's solved ring in canvas coordinates, whatever its corner + /// count. Unlike this is not restricted to + /// four corners — it backs the "Add as Polygon" action, which accepts any polygon. + /// + private bool TryGetConstructedRing(out IReadOnlyList ring) + { + foreach (ConstructionOverlayControl overlay in constructionControls) + { + if (overlay.TryGetRing(out ring)) + return true; + } + + ring = []; + return false; + } + + private bool HasConstructedRing => TryGetConstructedRing(out _); + + /// True when any overlay has one or more shapes picked with the Face tool. + private bool HasSelectedConstructionFaces => constructionControls.Any(overlay => overlay.HasSelectedFaces); + + private void UpdateConstructionApplyButtons() + { + bool quadrilateralEnabled = HasConstructedQuadrilateral; + + foreach (Button? button in new[] + { + ApplyConstructionToTransformButton, + ApplyConstructionToCropButton, + ApplyConstructionToUnWarpButton, + ApplyConstructionToTransformButtonTransformTab + }) + { + if (button is not null) + button.IsEnabled = quadrilateralEnabled; + } + + if (ApplyConstructionToPolygonButton is not null) + ApplyConstructionToPolygonButton.IsEnabled = HasSelectedConstructionFaces || HasConstructedRing; + } + + private void ApplyConstructionToTransform_Click(object sender, RoutedEventArgs e) + { + if (GetConstructedQuadrilateral() is not QuadrilateralDetector.DetectedQuadrilateral quad) + return; + + ShowTransformControls(); + PositionCornerMarkers(quad); + } + + private void ApplyConstructionToCrop_Click(object sender, RoutedEventArgs e) + { + if (GetConstructedQuadrilateral() is not QuadrilateralDetector.DetectedQuadrilateral quad) + return; + + ShowCroppingControls(); + PositionCroppingRectangle(quad); + } + + private void ApplyConstructionToUnWarp_Click(object sender, RoutedEventArgs e) + { + if (GetConstructedQuadrilateral() is not QuadrilateralDetector.DetectedQuadrilateral quad) + return; + + ShowUnWarpControls(); + PositionUnWarpMarkers(quad); + } + + /// + /// Turns the constructed shape into one or more polygon measurements. Shapes picked + /// with the Face tool are merged — clicking cells that border each other selects them + /// all, and this is what folds them into a single polygon per contiguous group. With + /// nothing individually selected it falls back to the whole solved shape, same as + /// before the Face tool existed. + /// + private void ApplyConstructionToPolygon_Click(object sender, RoutedEventArgs e) + { + List> rings = []; + + foreach (ConstructionOverlayControl overlay in constructionControls) + { + if (overlay.TryGetSelectedFacesUnion(out List> unionRings)) + rings.AddRange(unionRings); + } + + if (rings.Count == 0 && TryGetConstructedRing(out IReadOnlyList ring) && ring.Count >= 3) + rings.Add(ConstructionSolver.NormalizeWinding(ring)); + + foreach (List polygonRing in rings) + AddPolygonMeasurementFromRing(polygonRing); + + foreach (ConstructionOverlayControl overlay in constructionControls) + overlay.ClearFaceSelection(); + } + + private void AddPolygonMeasurementFromRing(List ring) + { + PolygonMeasurementControl control = new(); + + control.FromDto(new PolygonMeasurementControlDto + { + Vertices = ring, + ScaleFactor = ScaleInput.Value ?? 1.0, + Units = MeasurementUnits.Text, + IsClosed = true + }); + + control.MeasurementPointMouseDown += PolygonMeasurementPoint_MouseDown; + control.RemoveControlRequested += PolygonMeasurementControl_RemoveControlRequested; + polygonMeasurementTools.Add(control); + ShapeCanvas.Children.Add(control); + } + + private void ClearConstruction_Click(object sender, RoutedEventArgs e) + { + RemoveConstructionOverlays([.. constructionControls], recordUndo: true); + UncheckAllBut(); + } + + #endregion +} diff --git a/MagickCrop/MainWindow.xaml b/MagickCrop/MainWindow.xaml index 4a0532e..8e2e05e 100644 --- a/MagickCrop/MainWindow.xaml +++ b/MagickCrop/MainWindow.xaml @@ -1112,7 +1112,8 @@ Margin="0,4" Checked="ToolSelector_Checked" Click="ToolSelector_Clicked" - ToolTip="Add a distance measurement"> + ToolTip="Add a distance measurement" + Unchecked="ToolSelector_Unchecked"> @@ -1126,7 +1127,8 @@ Margin="0,4" Checked="ToolSelector_Checked" Click="ToolSelector_Clicked" - ToolTip="Add an angle measurement"> + ToolTip="Add an angle measurement" + Unchecked="ToolSelector_Unchecked"> @@ -1140,7 +1142,8 @@ Margin="0,4" Checked="ToolSelector_Checked" Click="ToolSelector_Clicked" - ToolTip="Add a rectangle measurement"> + ToolTip="Add a rectangle measurement" + Unchecked="ToolSelector_Unchecked"> @@ -1154,7 +1157,8 @@ Margin="0,4" Checked="ToolSelector_Checked" Click="ToolSelector_Clicked" - ToolTip="Add a circle measurement"> + ToolTip="Add a circle measurement" + Unchecked="ToolSelector_Unchecked"> @@ -1168,7 +1172,8 @@ Margin="0,4" Checked="ToolSelector_Checked" Click="ToolSelector_Clicked" - ToolTip="Add a horizontal guide line"> + ToolTip="Add a horizontal guide line" + Unchecked="ToolSelector_Unchecked"> @@ -1182,7 +1187,8 @@ Margin="0,4" Checked="ToolSelector_Checked" Click="ToolSelector_Clicked" - ToolTip="Add a vertical guide line"> + ToolTip="Add a vertical guide line" + Unchecked="ToolSelector_Unchecked"> @@ -1196,7 +1202,8 @@ Margin="0,4" Checked="ToolSelector_Checked" Click="ToolSelector_Clicked" - ToolTip="Add a polygon measurement"> + ToolTip="Add a polygon measurement" + Unchecked="ToolSelector_Unchecked"> @@ -1222,6 +1229,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 || rectangleMeasurementTools.Count > 0 || polygonMeasurementTools.Count > 0 - || circleMeasurementTools.Count > 0; + || circleMeasurementTools.Count > 0 + || constructionControls.Any(control => !control.IsEmpty); MagickGeometry IMainWindowView.GetLocalAdjustmentRegion() => LocalAdjustmentRectangle.CropShape; @@ -109,6 +110,11 @@ void IMainWindowView.SetBusy(bool busy) private readonly ObservableCollection verticalLineControls = []; private readonly ObservableCollection horizontalLineControls = []; + // Panels whose ToggleButtons form one mutually-exclusive tool group. Construction + // tools appear in both the Measure and Transform tabs, so this cannot be one panel. + private List? toolPanels; + private bool isSyncingToolToggles; + // --- Markup state --- private readonly ObservableCollection markupShapeControls = []; private MarkupShapeControl? activeMarkupShapeControl; @@ -245,7 +251,11 @@ public MainWindow() canvasTranslate.Changed += CanvasTranslate_Changed; CanvasMiniMap.ViewportCenterRequested += CanvasMiniMap_ViewportCenterRequested; MainGrid.SizeChanged += (_, _) => UpdateMiniMap(); - MainImage.SizeChanged += (_, _) => UpdateMiniMap(); + MainImage.SizeChanged += (_, _) => + { + UpdateMiniMap(); + UpdateConstructionImageBounds(); + }; DependencyPropertyDescriptor .FromProperty(System.Windows.Controls.Image.SourceProperty, typeof(System.Windows.Controls.Image)) ?.AddValueChanged(MainImage, (_, _) => UpdateMiniMap()); @@ -261,6 +271,9 @@ public MainWindow() foreach (UIElement element in _polygonElements) element.Visibility = Visibility.Collapsed; + // MeasureToolsPanel stays first so the existing tools keep their exact behaviour. + toolPanels = [MeasureToolsPanel, ConstructionToolsPanel, ConstructionToolsPanelTransform]; + // Tri-fold elements: all 8 markers + fold guide lines (built later) _triFoldElements.AddRange([TopLeft, TopRight, BottomRight, BottomLeft, UpperFoldLeft, UpperFoldRight, LowerFoldLeft, LowerFoldRight]); @@ -429,6 +442,12 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e) // Update pixel zoom if it should be shown (including before first measurement placement) Point mousePos = e.GetPosition(ShapeCanvas); + + // A boundary probe magnifies the edge it found rather than the cursor, so it + // drives the loupe itself from the construction block further down — by which + // point it knows where the edge is. + bool probeOwnsPixelZoom = draggingMode == DraggingMode.ConstructionBoundaryProbe; + if (ShouldShowPixelZoom()) { // Show the pixel zoom if not already visible @@ -436,7 +455,7 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e) { ShowPixelZoom(mousePos); } - else + else if (!probeOwnsPixelZoom) { UpdatePixelZoom(mousePos); } @@ -486,6 +505,15 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e) return; } + // --- CONSTRUCTION PLACEMENT LOGIC --- + // Must stay above the buttons-released block below: the Line tool's rubber band + // has to follow the cursor between its two clicks, with no button held. + if (HandleConstructionMouseMove(mousePos)) + { + e.Handled = true; + return; + } + if (Mouse.MiddleButton == MouseButtonState.Released && Mouse.LeftButton == MouseButtonState.Released) { if (draggingMode == DraggingMode.Panning) @@ -528,6 +556,13 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e) activeCircleMeasureControl = null; } + if (draggingMode == DraggingMode.ConstructionPoint && activeConstructionControl is not null) + { + activeConstructionControl.ResetActivePoint(); + activeConstructionControl.EndDrag(); + activeConstructionControl = null; + } + if (draggingMode == DraggingMode.EdgeCorrectionDragging) { edgeCorrectionDragIndex = -1; @@ -684,6 +719,17 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e) return; } + if (draggingMode == DraggingMode.ConstructionPoint && activeConstructionControl is not null) + { + int pointIndex = activeConstructionControl.GetActivePointIndex(); + if (pointIndex >= 0) + { + activeConstructionControl.MovePoint(pointIndex, movingPoint); + } + e.Handled = true; + return; + } + if (draggingMode == DraggingMode.EdgeCorrectionDragging && edgeCorrectionDragIndex >= 0) { Point imagePoint = e.GetPosition(MainImage); @@ -854,6 +900,7 @@ private void UpdateTransformVisualScale() lines?.StrokeThickness = 2 * inverseScale; + UpdateConstructionVisualScale(); UpdateCornerNavButtons(); } @@ -1014,8 +1061,7 @@ await Task.Run(() => }); } - string tempFileName = System.IO.Path.GetTempFileName(); - await image.WriteAsync(tempFileName); + string tempFileName = await image.WriteToTempFileAsync(); ViewModel.ImagePath = tempFileName; // Reset ImageGrid so it auto-sizes to the new image's aspect ratio. @@ -1342,6 +1388,7 @@ private BitmapSource RenderImageWithSelectedOverlays( includedElements.UnionWith(circleMeasurementTools); includedElements.UnionWith(verticalLineControls); includedElements.UnionWith(horizontalLineControls); + includedElements.UnionWith(constructionControls); includedElements.UnionWith(ShapeCanvas.Children.OfType()); } @@ -1936,6 +1983,18 @@ void OnFirstCancel(object? s, EventArgs args) return; } + if (IsConstructionToolActive) + { + HandleConstructionMouseDown(clickedPoint, e); + return; + } + + // Reaching here means no construction point, line, or faint line claimed the + // click — it landed on bare canvas, which deselects. Left button only, so a + // right-click aimed at a context menu leaves the selection alone. + if (e.ChangedButton == MouseButton.Left) + ClearConstructionSelection(); + if (MeasureDistanceToggle.IsChecked is true) { double scale = ScaleInput.Value ?? 1.0; @@ -2130,6 +2189,16 @@ private void ShapeCanvas_MouseUp(object sender, MouseButtonEventArgs e) return; } + // --- CONSTRUCTION EDGE DRAG / BOUNDARY PROBE --- + // Keyed to their own dragging modes, so they must be tested before the generic + // CreatingMeasurement block below. + if (draggingMode is DraggingMode.ConstructionEdgeCreate or DraggingMode.ConstructionBoundaryProbe) + { + HandleConstructionMouseUp(e.GetPosition(ShapeCanvas)); + e.Handled = true; + return; + } + if (isCreatingMeasurement && draggingMode == DraggingMode.CreatingMeasurement) { Point endPoint = e.GetPosition(ShapeCanvas); @@ -2903,8 +2972,7 @@ await Task.Run(() => await Task.Run(() => ApplyColorPoint(magickImage)); } - string tempFileName = System.IO.Path.GetTempFileName(); - await magickImage.WriteAsync(tempFileName); + string tempFileName = await magickImage.WriteToTempFileAsync(); MagickImageUndoRedoItem undoRedoItem = new(MainImage, ViewModel.ImagePath, tempFileName); UndoRedo.AddUndo(undoRedoItem); @@ -2986,8 +3054,7 @@ private async void ApplyCropButton_Click(object sender, RoutedEventArgs e) magickImage.Crop(cropGeometry); - string tempFileName = System.IO.Path.GetTempFileName(); - await magickImage.WriteAsync(tempFileName); + string tempFileName = await magickImage.WriteToTempFileAsync(); MagickImageUndoRedoItem undoRedoItem = new(MainImage, ViewModel.ImagePath, tempFileName); UndoRedo.AddUndo(undoRedoItem); @@ -3042,21 +3109,25 @@ private async Task RunCropDetectionAsync() QuadrilateralDetector.DetectionResult detectionResult = await Task.Run(() => QuadrilateralDetector.DetectQuadrilateralsWithDimensions(ViewModel.ImagePath, minArea: QuadDetectionMinArea, maxResults: QuadDetectionMaxResults)); - if (detectionResult.Quadrilaterals.Count == 0) + List scaledQuads = [.. detectionResult.Quadrilaterals.Select(q => + QuadrilateralDetector.ScaleToDisplay( + q, + detectionResult.ImageWidth, + detectionResult.ImageHeight, + MainImage.ActualWidth, + MainImage.ActualHeight))]; + + // A hand-built construction is most valuable on exactly the images where + // contour detection finds nothing, so it is offered even with no detections. + PrependConstructedQuadrilateral(scaledQuads); + + if (scaledQuads.Count == 0) { CropDetectInfoText.Text = "No shapes detected. Position the crop rectangle manually."; CropDetectInfoText.Visibility = Visibility.Visible; } else { - List scaledQuads = [.. detectionResult.Quadrilaterals.Select(q => - QuadrilateralDetector.ScaleToDisplay( - q, - detectionResult.ImageWidth, - detectionResult.ImageHeight, - MainImage.ActualWidth, - MainImage.ActualHeight))]; - CropQuadrilateralSelectorControl.SetQuadrilaterals(scaledQuads); CropQuadrilateralSelectorControl.QuadrilateralHoverEnter -= QuadrilateralSelector_HoverEnter; CropQuadrilateralSelectorControl.QuadrilateralHoverExit -= QuadrilateralSelector_HoverExit; @@ -3349,8 +3420,7 @@ private async void ApplyTriFoldButton_Click(object sender, RoutedEventArgs e) return; } - string tempFileName = System.IO.Path.GetTempFileName(); - await result.WriteAsync(tempFileName); + string tempFileName = await result.WriteToTempFileAsync(); MagickImageUndoRedoItem undoRedoItem = new(MainImage, ViewModel.ImagePath, tempFileName); UndoRedo.AddUndo(undoRedoItem); @@ -3414,22 +3484,24 @@ private async Task RunUnWarpDetectionAsync() QuadrilateralDetector.DetectQuadrilateralsWithDimensions( ViewModel.ImagePath, minArea: QuadDetectionMinArea, maxResults: QuadDetectionMaxResults)); - if (detectionResult.Quadrilaterals.Count == 0) + List scaledQuads = + [.. detectionResult.Quadrilaterals.Select(q => + QuadrilateralDetector.ScaleToDisplay( + q, + detectionResult.ImageWidth, + detectionResult.ImageHeight, + MainImage.ActualWidth, + MainImage.ActualHeight))]; + + PrependConstructedQuadrilateral(scaledQuads); + + if (scaledQuads.Count == 0) { UnWarpDetectInfoText.Text = "No shapes detected. Position the corner markers manually."; UnWarpDetectInfoText.Visibility = Visibility.Visible; } else { - List scaledQuads = - [.. detectionResult.Quadrilaterals.Select(q => - QuadrilateralDetector.ScaleToDisplay( - q, - detectionResult.ImageWidth, - detectionResult.ImageHeight, - MainImage.ActualWidth, - MainImage.ActualHeight))]; - UnWarpQuadrilateralSelectorControl.SetQuadrilaterals(scaledQuads); UnWarpQuadrilateralSelectorControl.QuadrilateralHoverEnter -= QuadrilateralSelector_HoverEnter; UnWarpQuadrilateralSelectorControl.QuadrilateralHoverExit -= QuadrilateralSelector_HoverExit; @@ -3703,8 +3775,7 @@ private async void ApplyUnWarpButton_Click(object sender, RoutedEventArgs e) return; } - string tempFileName = System.IO.Path.GetTempFileName(); - await result.WriteAsync(tempFileName); + string tempFileName = await result.WriteToTempFileAsync(); MagickImageUndoRedoItem undoRedoItem = new(MainImage, ViewModel.ImagePath, tempFileName); UndoRedo.AddUndo(undoRedoItem); @@ -3985,8 +4056,7 @@ private async void ApplyEdgeCorrectionButton_Click(object sender, RoutedEventArg return; } - string tempFileName = System.IO.Path.GetTempFileName(); - await result.WriteAsync(tempFileName); + string tempFileName = await result.WriteToTempFileAsync(); MagickImageUndoRedoItem undoRedoItem = new(MainImage, ViewModel.ImagePath, tempFileName); UndoRedo.AddUndo(undoRedoItem); @@ -4334,8 +4404,7 @@ private async void ApplyGridStraightenButton_Click(object sender, RoutedEventArg return; } - string tempFileName = System.IO.Path.GetTempFileName(); - await result.WriteAsync(tempFileName); + string tempFileName = await result.WriteToTempFileAsync(); MagickImageUndoRedoItem undoRedoItem = new(MainImage, ViewModel.ImagePath, tempFileName); UndoRedo.AddUndo(undoRedoItem); @@ -4385,21 +4454,23 @@ private async Task RunTransformDetectionAsync() QuadrilateralDetector.DetectionResult detectionResult = await Task.Run(() => QuadrilateralDetector.DetectQuadrilateralsWithDimensions(ViewModel.ImagePath, minArea: QuadDetectionMinArea, maxResults: QuadDetectionMaxResults)); - if (detectionResult.Quadrilaterals.Count == 0) + List scaledQuads = [.. detectionResult.Quadrilaterals.Select(q => + QuadrilateralDetector.ScaleToDisplay( + q, + detectionResult.ImageWidth, + detectionResult.ImageHeight, + MainImage.ActualWidth, + MainImage.ActualHeight))]; + + PrependConstructedQuadrilateral(scaledQuads); + + if (scaledQuads.Count == 0) { TransformDetectInfoText.Text = "No shapes detected. Position the corner markers manually."; TransformDetectInfoText.Visibility = Visibility.Visible; } else { - List scaledQuads = [.. detectionResult.Quadrilaterals.Select(q => - QuadrilateralDetector.ScaleToDisplay( - q, - detectionResult.ImageWidth, - detectionResult.ImageHeight, - MainImage.ActualWidth, - MainImage.ActualHeight))]; - QuadrilateralSelectorControl.SetQuadrilaterals(scaledQuads); QuadrilateralSelectorControl.QuadrilateralHoverEnter -= QuadrilateralSelector_HoverEnter; QuadrilateralSelectorControl.QuadrilateralHoverExit -= QuadrilateralSelector_HoverExit; @@ -4515,8 +4586,7 @@ private async void ApplyResizeButton_Click(object sender, RoutedEventArgs e) magickImage.Resize(resizeGeometry); - string tempFileName = System.IO.Path.GetTempFileName(); - await magickImage.WriteAsync(tempFileName); + string tempFileName = await magickImage.WriteToTempFileAsync(); ResizeUndoRedoItem undoRedoItem = new(MainImage, ImageGrid, oldGridSize, ViewModel.ImagePath, tempFileName); UndoRedo.AddUndo(undoRedoItem); @@ -5083,6 +5153,8 @@ private void RemoveMeasurementControls() horizontalLineControls.Clear(); + RemoveConstructionControls(); + ClearAllStrokesAndLengths(); ClearAllMarkup(); draggingMode = DraggingMode.None; @@ -5287,6 +5359,9 @@ private void ScaleInput_ValueChanged(object sender, RoutedEventArgs e) foreach (CircleMeasurementControl tool in circleMeasurementTools) tool.ScaleFactor = newScale; + foreach (ConstructionOverlayControl tool in constructionControls) + tool.ScaleFactor = newScale; + // Update stroke measurements UpdateStrokeMeasurements(); } @@ -5308,6 +5383,9 @@ private void MeasurementUnits_TextChanged(object sender, TextChangedEventArgs e) foreach (CircleMeasurementControl tool in circleMeasurementTools) tool.Units = textBox.Text; + foreach (ConstructionOverlayControl tool in constructionControls) + tool.Units = textBox.Text; + // Update stroke measurements UpdateStrokeMeasurements(); } @@ -5369,6 +5447,9 @@ private void SetMeasurementsVisibility(Visibility visibility) foreach (HorizontalLineControl control in horizontalLineControls) control.Visibility = visibility; + foreach (ConstructionOverlayControl control in constructionControls) + control.Visibility = visibility; + DrawingCanvas.Visibility = visibility; } @@ -5418,6 +5499,9 @@ private MagickCropMeasurementPackage BuildCurrentPackage(PackageMetadata? metada foreach (PolygonMeasurementControl control in polygonMeasurementTools) package.Measurements.PolygonMeasurements.Add(control.ToDto()); + foreach (ConstructionOverlayControl control in constructionControls) + package.Measurements.Constructions.Add(control.ToDto()); + foreach (VerticalLineControl control in verticalLineControls) package.Measurements.VerticalLines.Add(control.ToDto()); @@ -5682,6 +5766,24 @@ private async Task LoadMeasurementPackageAsync(string fileName) } Debug.WriteLine($"Loaded polygon measurements. Total in collection: {polygonMeasurementTools.Count}"); + // Add parametric constructions + foreach (ConstructionGeometryDto dto in package.Measurements.Constructions) + { + ConstructionOverlayControl control = new(); + control.FromDto(dto); + WireConstructionOverlay(control); + constructionControls.Add(control); + ShapeCanvas.Children.Add(control); + + // The overlay the construction tools will build into. + constructionOverlay ??= control; + } + + UpdateConstructionImageBounds(); + UpdateConstructionVisualScale(); + UpdateConstructionApplyButtons(); + SyncConstructionToolState(); + foreach (VerticalLineControlDto dto in package.Measurements.VerticalLines) { VerticalLineControl control = new(); @@ -5924,6 +6026,10 @@ private void ResetTransientState() activePolygonPlacementControl = null; } + // --- Construction placement --- + CancelConstructionGesture(); + activeConstructionControl = null; + // --- Circle measurement placement --- isPlacingCircleMeasurement = false; if (activeCirclePlacementControl != null) @@ -6340,11 +6446,72 @@ private void ToolSelector_Checked(object sender, RoutedEventArgs e) UncheckAllBut(toggleButton); } - private bool IsAnyToolSelected() + /// + /// Turning a tool off has to go through the same funnel as turning one on. + /// + /// + /// Without this, clicking a checked construction tool cleared only the button that was + /// clicked. Its twin in the other tab stayed checked, and since + /// reads whichever toggle is checked across both + /// tabs, the tool went on being active behind a button that looked off. The tool state + /// never resynced either, so the sample buffer was never released. + /// + private void ToolSelector_Unchecked(object sender, RoutedEventArgs e) + { + // A programmatic uncheck is already part of a sync that will finish the job. + if (isSyncingToolToggles) + return; + + if (sender is not ToggleButton toggleButton) + return; + + UncheckTwinsOf(toggleButton); + + // Single funnel for tool changes, same as UncheckAllBut ends with. + SyncConstructionToolState(); + } + + /// + /// Clears the other buttons carrying the same Tag. Construction tools appear once per + /// tab, and the pair has to move together in both directions. + /// + private void UncheckTwinsOf(ToggleButton toggleButton) { - List toolToggleButtons = [.. MeasureToolsPanel.Children.OfType()]; + if (toggleButton.Tag is not string tag) + return; - foreach (ToggleButton button in toolToggleButtons) + isSyncingToolToggles = true; + try + { + foreach (ToggleButton button in AllToolToggles()) + { + if (button != toggleButton && button.Tag as string == tag) + button.IsChecked = false; + } + } + finally + { + isSyncingToolToggles = false; + } + } + + /// + /// Every tool toggle across all tool panels. Construction tools appear in both the + /// Measure and Transform tabs, so mutual exclusion cannot look at one panel alone. + /// + private IEnumerable AllToolToggles() + { + if (toolPanels is null) + return MeasureToolsPanel?.Children.OfType() ?? []; + + return toolPanels + .Where(panel => panel is not null) + .SelectMany(panel => panel.Children.OfType()); + } + + private bool IsAnyToolSelected() + { + foreach (ToggleButton button in AllToolToggles()) if (button.IsChecked == true) return true; @@ -6353,17 +6520,40 @@ private bool IsAnyToolSelected() private void UncheckAllBut(ToggleButton? toggleButton = null) { - List toolToggleButtons = [.. MeasureToolsPanel.Children.OfType()]; + // Checking a twin raises its own Checked event, which lands back here. + if (isSyncingToolToggles) + return; + + isSyncingToolToggles = true; + try + { + // A construction tool has a twin button in the other tab. Both carry the same + // Tag, so keeping the twin checked keeps both tabs showing the same active tool. + string? keepTag = toggleButton?.Tag as string; - foreach (ToggleButton button in toolToggleButtons) - if (button != toggleButton) - button.IsChecked = false; + foreach (ToggleButton button in AllToolToggles()) + { + if (button == toggleButton) + continue; + + bool isTwin = keepTag is not null && button.Tag as string == keepTag; + button.IsChecked = isTwin; + } + } + finally + { + isSyncingToolToggles = false; + } if (toggleButton is null) { draggingMode = DraggingMode.None; isCreatingMeasurement = false; + CancelConstructionGesture(); } + + // Single funnel for tool changes, so the overlays always know which one is live. + SyncConstructionToolState(); } private void ToolSelector_Clicked(object sender, RoutedEventArgs e) @@ -6461,6 +6651,18 @@ private void FluentWindow_PreviewKeyDown(object sender, KeyEventArgs e) } } + // Delete removes the selected construction line — keeping its two points — or, + // when no line is selected, the selected points themselves. Placed after the + // markup handler so a markup selection still wins its own Delete. + if (!typingInTextBox && e.Key is Key.Delete or Key.Back && HasConstructionSelection) + { + if (DeleteSelectedConstruction()) + { + e.Handled = true; + return; + } + } + if (e.Key == Key.Escape) { // Escape while editing a markup text cancels just that edit @@ -6850,15 +7052,16 @@ private async void ApplyRotationButton_Click(object sender, RoutedEventArgs e) try { string previousPath = ViewModel.ImagePath!; - string tempFileName = System.IO.Path.GetTempFileName(); - await Task.Run(() => + string tempFileName = await Task.Run(() => { using MagickImage mi = new(previousPath); mi.BackgroundColor = MagickColors.Transparent; mi.VirtualPixelMethod = VirtualPixelMethod.Transparent; mi.Rotate(angle); - mi.Write(tempFileName); + + // Rotation fills the corners with transparency, so keep an alpha-capable format. + return mi.WriteToTempFile(MagickFormat.Png); }); MagickImageUndoRedoItem undoItem = new(MainImage, previousPath, tempFileName); @@ -6964,15 +7167,21 @@ private void ShowPixelZoom(Point mousePosition) /// Updates the pixel precision zoom control position and preview. /// /// Mouse position in ShapeCanvas coordinates - private void UpdatePixelZoom(Point mousePosition) + /// + /// What to magnify, in ShapeCanvas coordinates, when that is not the cursor. The + /// boundary probe passes the edge it found, so the crosshairs sit on the point that + /// is about to be placed rather than on the hand placing it. The panel itself still + /// parks by the cursor, so it does not jump about as the found point moves. + /// + private void UpdatePixelZoom(Point mousePosition, Point? focusPoint = null) { if (PixelZoomControl.Visibility != Visibility.Visible) return; try { - // Convert mouse position to image coordinates - Point imagePosition = ConvertCanvasToImageCoordinates(mousePosition); + // Convert the point of interest to image coordinates + Point imagePosition = ConvertCanvasToImageCoordinates(focusPoint ?? mousePosition); PixelZoomControl.CurrentPosition = imagePosition; // Convert ShapeCanvas coordinates to MainGrid coordinates @@ -7017,6 +7226,22 @@ private Point ConvertCanvasToImageCoordinates(Point canvasPoint) Math.Clamp(pixelY, 0, source.PixelHeight - 1)); } + /// + /// Converts a point from MainImage pixel coordinates back to ShapeCanvas coordinates, + /// for results that come out of analysing the image at its own resolution. + /// + private Point ConvertImageToCanvasCoordinates(Point imagePoint) + { + if (MainImage.Source is not BitmapSource source + || source.PixelWidth <= 0 + || source.PixelHeight <= 0) + return new Point(0, 0); + + return new Point( + imagePoint.X * MainImage.ActualWidth / source.PixelWidth, + imagePoint.Y * MainImage.ActualHeight / source.PixelHeight); + } + /// /// Checks if pixel zoom should be shown for the current operation. /// Shows when a measurement tool is active, including hover before first placement. @@ -7033,7 +7258,10 @@ private bool ShouldShowPixelZoom() DraggingMode.MeasureAngle or DraggingMode.MeasureRectangle or DraggingMode.MeasurePolygon or - DraggingMode.MeasureCircle) + DraggingMode.MeasureCircle or + DraggingMode.ConstructionPoint or + DraggingMode.ConstructionEdgeCreate or + DraggingMode.ConstructionBoundaryProbe) return true; // Show during measurement creation (active drag) @@ -7066,6 +7294,10 @@ DraggingMode.MeasurePolygon or VerticalLineToggle?.IsChecked == true) return true; + // Construction points need at least as much precision as any other measurement. + if (IsConstructionToolActive) + return true; + return false; } @@ -7085,6 +7317,16 @@ private void ToolsTabControl_SelectionChanged(object sender, SelectionChangedEve SetMeasurementDragGizmosVisibility(measurementTabActive); SetMarkupDragGizmosVisibility(markupTabActive); + + // Construction tools live in both the Measure and Transform tabs, so the overlay + // must stay interactive across both. It is deliberately kept out of + // SetMeasurementDragGizmosVisibility, which would kill hit-testing on Transform. + bool constructionTabActive = measurementTabActive || TransformTabItem?.IsSelected == true; + foreach (ConstructionOverlayControl control in constructionControls) + { + control.IsHitTestVisible = constructionTabActive; + control.IsDragGizmoVisible = constructionTabActive; + } } private void DeactivateAllMarkupTools() diff --git a/MagickCrop/Models/Construction/ConstructionFace.cs b/MagickCrop/Models/Construction/ConstructionFace.cs new file mode 100644 index 0000000..653733f --- /dev/null +++ b/MagickCrop/Models/Construction/ConstructionFace.cs @@ -0,0 +1,16 @@ +using System.Windows; + +namespace MagickCrop.Models.Construction; + +/// +/// One bounded cell of the planar arrangement formed by every construction line crossing +/// every other — not just the single outer shape +/// solves, but every enclosed region the lines carve out, down to a single triangle. +/// +/// Corners in winding order. +/// +/// The ring's edges as consecutive point pairs. Kept alongside rather +/// than derived on demand because union merges faces by cancelling out edges two selected +/// faces share, and that needs the edges in exactly this per-face form. +/// +public sealed record ConstructionFace(IReadOnlyList Ring, IReadOnlyList<(Point A, Point B)> Edges); diff --git a/MagickCrop/Models/Construction/ConstructionGeometry.cs b/MagickCrop/Models/Construction/ConstructionGeometry.cs new file mode 100644 index 0000000..354b3df --- /dev/null +++ b/MagickCrop/Models/Construction/ConstructionGeometry.cs @@ -0,0 +1,480 @@ +using System.Windows; + +namespace MagickCrop.Models.Construction; + +/// Where a point's position comes from. +public enum ConstructionPointSource +{ + /// Placed by the user; the stored position is the truth. + Free, + + /// Where two lines cross. Re-fitted whenever either line moves. + LineIntersection, + + /// The centre of a circle. Re-fitted whenever the circle moves. + CircleCenter +} + +/// +/// A point in the construction. Lines and circles are defined by these, so moving one +/// moves everything that references it. +/// +/// A point is either free — placed by the user, storing its own position — or derived, +/// in which case the stored position is a cache re-fitted from +/// / on every refresh. A derived point +/// only exists here at all because the user chose to keep it. +/// +public class ConstructionPoint +{ + public Guid Id { get; init; } = Guid.NewGuid(); + public Point Position { get; set; } + + public ConstructionPointSource Source { get; set; } = ConstructionPointSource.Free; + + /// First parent: a line for an intersection, the circle for a centre. + public Guid ParentAId { get; set; } + + /// Second line of an intersection; unused by a circle centre. + public Guid ParentBId { get; set; } + + public bool IsDerived => Source != ConstructionPointSource.Free; + + /// Detaches the point from its parents, leaving it where it last sat. + public void Release() + { + Source = ConstructionPointSource.Free; + ParentAId = Guid.Empty; + ParentBId = Guid.Empty; + } +} + +/// +/// A position the construction implies but does not own — a crossing or a centre that +/// is offered to the user, and only becomes a real if +/// they keep it. +/// +public readonly record struct DerivedPointCandidate( + ConstructionPointSource Source, + Guid ParentAId, + Guid ParentBId, + Point Position); + +/// +/// A line through two construction points. Points are referenced by +/// rather than index so removing a point cannot silently repoint a line. +/// +public class ConstructionLine +{ + public Guid Id { get; init; } = Guid.NewGuid(); + public Guid StartPointId { get; set; } + public Guid EndPointId { get; set; } + + /// + /// When true the line is drawn past its two points to the construction bounds, + /// so the corner it forms with a neighbouring edge is visible. Defaults to true + /// because derived corners are the whole point of the feature. + /// + public bool IsExtended { get; set; } = true; + + /// + /// When true the line's length is drawn beside it. Off by default because a + /// construction is usually several lines and labelling every one at once buries the + /// shape they define. + /// + public bool ShowMeasurement { get; set; } +} + +/// +/// A circle through three construction points. Like a line it stores only the point +/// references — centre and radius are derived, so moving any of the three re-fits the +/// circle rather than dragging a stored one around. +/// +public class ConstructionCircle +{ + public Guid Id { get; init; } = Guid.NewGuid(); + public Guid PointAId { get; set; } + public Guid PointBId { get; set; } + public Guid PointCId { get; set; } + + /// + /// When true the circle's radius, circumference, and area are drawn at its centre, + /// without having to select it first. + /// + public bool ShowMeasurement { get; set; } + + public IEnumerable PointIds + { + get + { + yield return PointAId; + yield return PointBId; + yield return PointCId; + } + } +} + +/// +/// The point/line/circle graph behind a parametric construction. Pure model: no WPF +/// elements, no rendering, no solving. Corners are derived on demand by +/// and deliberately never stored here. +/// +public class ConstructionGeometry +{ + private readonly List points = []; + private readonly List lines = []; + private readonly List circles = []; + + public IReadOnlyList Points => points; + public IReadOnlyList Lines => lines; + public IReadOnlyList Circles => circles; + + public Guid AddPoint(Point position) + { + ConstructionPoint point = new() { Position = position }; + points.Add(point); + return point.Id; + } + + /// + /// Adds a point that already has an identity. Used when restoring from a DTO so + /// the saved line references stay valid. + /// + public void AddPoint( + Guid id, + Point position, + ConstructionPointSource source = ConstructionPointSource.Free, + Guid parentAId = default, + Guid parentBId = default) => + points.Add(new ConstructionPoint + { + Id = id, + Position = position, + Source = source, + ParentAId = parentAId, + ParentBId = parentBId + }); + + /// + /// Promotes an offered crossing or centre into a point the construction owns. It + /// stays derived — it keeps tracking its parents — but now survives them. + /// + public Guid KeepDerivedPoint(DerivedPointCandidate candidate) + { + ConstructionPoint point = new() + { + Position = candidate.Position, + Source = candidate.Source, + ParentAId = candidate.ParentAId, + ParentBId = candidate.ParentBId + }; + points.Add(point); + return point.Id; + } + + public Guid AddLine(Guid startPointId, Guid endPointId, bool isExtended = true) + { + ConstructionLine line = new() + { + StartPointId = startPointId, + EndPointId = endPointId, + IsExtended = isExtended + }; + lines.Add(line); + return line.Id; + } + + public void AddLine(Guid id, Guid startPointId, Guid endPointId, bool isExtended, bool showMeasurement = false) + { + lines.Add(new ConstructionLine + { + Id = id, + StartPointId = startPointId, + EndPointId = endPointId, + IsExtended = isExtended, + ShowMeasurement = showMeasurement + }); + } + + public Guid AddCircle(Guid pointAId, Guid pointBId, Guid pointCId) + { + ConstructionCircle circle = new() + { + PointAId = pointAId, + PointBId = pointBId, + PointCId = pointCId + }; + circles.Add(circle); + return circle.Id; + } + + public void AddCircle(Guid id, Guid pointAId, Guid pointBId, Guid pointCId, bool showMeasurement = false) + { + circles.Add(new ConstructionCircle + { + Id = id, + PointAId = pointAId, + PointBId = pointBId, + PointCId = pointCId, + ShowMeasurement = showMeasurement + }); + } + + /// + /// Removes a point and every line or circle that referenced it — either one missing + /// a defining point has no meaning, so neither can be left behind. + /// + public void RemovePoint(Guid pointId) + { + lines.RemoveAll(line => line.StartPointId == pointId || line.EndPointId == pointId); + circles.RemoveAll(circle => circle.PointIds.Contains(pointId)); + points.RemoveAll(point => point.Id == pointId); + } + + public void RemoveLine(Guid lineId) => lines.RemoveAll(line => line.Id == lineId); + + public void RemoveCircle(Guid circleId) => circles.RemoveAll(circle => circle.Id == circleId); + + public void Clear() + { + points.Clear(); + lines.Clear(); + circles.Clear(); + } + + public ConstructionPoint? FindPoint(Guid pointId) => + points.FirstOrDefault(point => point.Id == pointId); + + public ConstructionLine? FindLine(Guid lineId) => + lines.FirstOrDefault(line => line.Id == lineId); + + /// + /// The line joining two points, in either direction, or null when they are not + /// connected. Used to decide whether a pair of selected points still needs a line. + /// + public ConstructionLine? FindLineBetween(Guid pointA, Guid pointB) => + lines.FirstOrDefault(line => + (line.StartPointId == pointA && line.EndPointId == pointB) || + (line.StartPointId == pointB && line.EndPointId == pointA)); + + public ConstructionCircle? FindCircle(Guid circleId) => + circles.FirstOrDefault(circle => circle.Id == circleId); + + /// + /// The circle defined by three points in any order, or null when they do not already + /// define one. Order-insensitive because the user picks the three in any sequence. + /// + public ConstructionCircle? FindCircleThrough(Guid pointA, Guid pointB, Guid pointC) + { + HashSet wanted = [pointA, pointB, pointC]; + return circles.FirstOrDefault(circle => wanted.SetEquals(circle.PointIds)); + } + + public int IndexOfPoint(Guid pointId) => + points.FindIndex(point => point.Id == pointId); + + public bool MovePoint(Guid pointId, Point position) + { + ConstructionPoint? point = FindPoint(pointId); + if (point is null) return false; + + point.Position = position; + return true; + } + + /// + /// Finds the nearest point within , or null. + /// Callers must pass a tolerance already divided by the canvas zoom so the + /// grab radius stays constant in screen space. + /// + public ConstructionPoint? FindPointNear(Point position, double tolerance, Guid? exclude = null) + { + ConstructionPoint? best = null; + double bestDistance = tolerance; + + foreach (ConstructionPoint point in points) + { + if (exclude is Guid excluded && point.Id == excluded) continue; + + double distance = Helpers.GeometryMathHelper.Distance(point.Position, position); + if (distance > bestDistance) continue; + + best = point; + bestDistance = distance; + } + + return best; + } + + /// + /// Resolves each line to its two endpoint positions, skipping any line whose + /// endpoints have gone missing. + /// + public List<(Guid Id, Point Start, Point End)> GetResolvedLines() + { + List<(Guid, Point, Point)> resolved = []; + + foreach (ConstructionLine line in lines) + { + ConstructionPoint? start = FindPoint(line.StartPointId); + ConstructionPoint? end = FindPoint(line.EndPointId); + if (start is null || end is null) continue; + + resolved.Add((line.Id, start.Position, end.Position)); + } + + return resolved; + } + + /// + /// Positions the construction implies but does not own: where each pair of lines + /// crosses, and the centre of each circle. Anything already sitting under a real + /// point is left out — a crossing at a shared endpoint is not somewhere new to + /// click, and offering one there would put a faint dot on every vertex. + /// + /// + /// How close counts as the same place. Callers divide by the canvas zoom so the + /// threshold means the same on screen at any magnification. + /// + public List GetDerivedCandidates(double mergeTolerance) + { + List candidates = []; + List<(Guid Id, Point Start, Point End)> resolved = GetResolvedLines(); + + for (int i = 0; i < resolved.Count; i++) + { + for (int j = i + 1; j < resolved.Count; j++) + { + if (!Helpers.ConstructionSolver.TryIntersect( + resolved[i].Start, resolved[i].End, + resolved[j].Start, resolved[j].End, + out Point crossing)) + continue; + + TryOfferCandidate(candidates, new DerivedPointCandidate( + ConstructionPointSource.LineIntersection, + resolved[i].Id, + resolved[j].Id, + crossing), mergeTolerance); + } + } + + foreach ((Guid id, Point center, double _) in GetResolvedCircles()) + { + TryOfferCandidate(candidates, new DerivedPointCandidate( + ConstructionPointSource.CircleCenter, id, Guid.Empty, center), mergeTolerance); + } + + return candidates; + } + + private void TryOfferCandidate( + List candidates, + DerivedPointCandidate candidate, + double tolerance) + { + // Already a real point — kept earlier, or placed by hand. + if (FindPointNear(candidate.Position, tolerance) is not null) return; + + // Several pairs of lines can cross at one spot; offer it once. + foreach (DerivedPointCandidate offered in candidates) + { + if (Helpers.GeometryMathHelper.Distance(offered.Position, candidate.Position) <= tolerance) + return; + } + + candidates.Add(candidate); + } + + /// + /// Re-fits every kept derived point from its parents. A point whose parents are gone + /// is released rather than deleted: the user kept it precisely so it would outlive + /// them, so it stays where it last sat as an ordinary point. + /// + public void RefreshDerivedPoints() + { + foreach (ConstructionPoint point in points) + { + if (!point.IsDerived) continue; + + if (!HasLivingParents(point)) + { + point.Release(); + continue; + } + + // Parents still there but momentarily unsolvable — two lines dragged + // parallel, or a circle's points gone collinear. Hold the last position so + // dragging back restores the fit instead of permanently breaking it. + if (TryResolveDerivedPosition(point, out Point position)) + point.Position = position; + } + } + + private bool HasLivingParents(ConstructionPoint point) => point.Source switch + { + ConstructionPointSource.LineIntersection => + FindLine(point.ParentAId) is not null && FindLine(point.ParentBId) is not null, + ConstructionPointSource.CircleCenter => + FindCircle(point.ParentAId) is not null, + _ => false + }; + + private bool TryResolveDerivedPosition(ConstructionPoint point, out Point position) + { + position = point.Position; + + if (point.Source == ConstructionPointSource.CircleCenter) + { + foreach ((Guid id, Point center, double _) in GetResolvedCircles()) + { + if (id != point.ParentAId) continue; + + position = center; + return true; + } + + return false; + } + + if (point.Source != ConstructionPointSource.LineIntersection) + return false; + + ConstructionLine? lineA = FindLine(point.ParentAId); + ConstructionLine? lineB = FindLine(point.ParentBId); + if (lineA is null || lineB is null) return false; + + ConstructionPoint? a1 = FindPoint(lineA.StartPointId); + ConstructionPoint? a2 = FindPoint(lineA.EndPointId); + ConstructionPoint? b1 = FindPoint(lineB.StartPointId); + ConstructionPoint? b2 = FindPoint(lineB.EndPointId); + if (a1 is null || a2 is null || b1 is null || b2 is null) return false; + + return Helpers.ConstructionSolver.TryIntersect( + a1.Position, a2.Position, b1.Position, b2.Position, out position); + } + + /// + /// Resolves each circle to the centre and radius its three points imply, skipping + /// any whose points have gone missing or fallen into a straight line. + /// + public List<(Guid Id, Point Center, double Radius)> GetResolvedCircles() + { + List<(Guid, Point, double)> resolved = []; + + foreach (ConstructionCircle circle in circles) + { + ConstructionPoint? a = FindPoint(circle.PointAId); + ConstructionPoint? b = FindPoint(circle.PointBId); + ConstructionPoint? c = FindPoint(circle.PointCId); + if (a is null || b is null || c is null) continue; + + if (!Helpers.GeometryMathHelper.TryGetCircumcircle( + a.Position, b.Position, c.Position, out Point center, out double radius)) + continue; + + resolved.Add((circle.Id, center, radius)); + } + + return resolved; + } +} diff --git a/MagickCrop/Models/DraggingMode.cs b/MagickCrop/Models/DraggingMode.cs index 326c2a9..ed46a03 100644 --- a/MagickCrop/Models/DraggingMode.cs +++ b/MagickCrop/Models/DraggingMode.cs @@ -19,5 +19,14 @@ public enum DraggingMode MarkupShape, MarkupText, MarkupGroupSelect, - MarkupGroupMove + MarkupGroupMove, + + /// Dragging an existing construction point. + ConstructionPoint, + + /// Dragging out a new construction edge and its two points. + ConstructionEdgeCreate, + + /// Dragging a probe line across a boundary to find where the edge falls on it. + ConstructionBoundaryProbe } diff --git a/MagickCrop/Models/MeasurementControls/AngleMeasurementControlDto.cs b/MagickCrop/Models/MeasurementControls/AngleMeasurementControlDto.cs index 98a72a2..817f923 100644 --- a/MagickCrop/Models/MeasurementControls/AngleMeasurementControlDto.cs +++ b/MagickCrop/Models/MeasurementControls/AngleMeasurementControlDto.cs @@ -26,4 +26,9 @@ public AngleMeasurementControlDto() /// Third point position of the angle measurement /// public Point Point3Position { get; set; } + + /// + /// Color of the angle's legs, arc, and point handles + /// + public string StrokeColor { get; set; } = "#0066FF"; } diff --git a/MagickCrop/Models/MeasurementControls/CircleMeasurementControlDto.cs b/MagickCrop/Models/MeasurementControls/CircleMeasurementControlDto.cs index 3f50f7f..3ea90bb 100644 --- a/MagickCrop/Models/MeasurementControls/CircleMeasurementControlDto.cs +++ b/MagickCrop/Models/MeasurementControls/CircleMeasurementControlDto.cs @@ -28,4 +28,9 @@ public CircleMeasurementControlDto() /// Units of measurement (e.g., "pixels", "mm", "in") /// public string Units { get; set; } = "pixels"; + + /// + /// Color of the circle outline and its point handles + /// + public string StrokeColor { get; set; } = "#0066FF"; } diff --git a/MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs b/MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs new file mode 100644 index 0000000..cf55957 --- /dev/null +++ b/MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs @@ -0,0 +1,104 @@ +using MagickCrop.Models.Construction; +using System.Windows; + +namespace MagickCrop.Models.MeasurementControls; + +/// +/// Data transfer object for a parametric construction: the points, the lines that +/// reference them, and the measurement settings for the derived shape's readout. +/// +public class ConstructionGeometryDto : MeasurementControlDto +{ + public ConstructionGeometryDto() + { + Type = "Construction"; + } + + public List Points { get; set; } = []; + + public List Lines { get; set; } = []; + + public List Circles { get; set; } = []; + + /// + /// Scale factor for converting pixel measurements to real-world units + /// + public double ScaleFactor { get; set; } = 1.0; + + /// + /// Units of measurement (e.g., "pixels", "mm", "in") + /// + public string Units { get; set; } = "pixels"; + + /// + /// Whether the derived shape's perimeter and area readout is shown. Defaults to true + /// so projects saved before the toggle existed keep the readout they had. + /// + public bool ShowShapeMeasurement { get; set; } = true; + + /// + /// Color of the construction's points, lines, and derived shape. Null for projects + /// saved before this existed, which keep their original blue/orange appearance rather + /// than being forced onto a single color. + /// + public string? StrokeColor { get; set; } +} + +/// +/// A construction point. The id is persisted because lines reference points by id. +/// +public class ConstructionPointDto +{ + public Guid Id { get; set; } + public Point Position { get; set; } + + /// + /// How the position is produced. Absent from projects saved before derived points + /// existed, which correctly default to a free point. + /// + public ConstructionPointSource Source { get; set; } = ConstructionPointSource.Free; + + /// Line or circle the point is derived from; empty for a free point. + public Guid ParentAId { get; set; } + + /// Second line of a derived intersection; empty otherwise. + public Guid ParentBId { get; set; } +} + +/// +/// A construction line, referencing its two endpoints by id. +/// +public class ConstructionLineDto +{ + public Guid Id { get; set; } + public Guid StartPointId { get; set; } + public Guid EndPointId { get; set; } + + /// + /// Whether the line draws past its points to reveal the corners it forms. + /// + public bool IsExtended { get; set; } = true; + + /// + /// Whether the line's length is labelled. Absent from projects saved before + /// per-line readouts existed, which correctly default to unlabelled. + /// + public bool ShowMeasurement { get; set; } +} + +/// +/// A construction circle, referencing the three points it passes through by id. +/// Centre and radius are derived on load, never persisted. +/// +public class ConstructionCircleDto +{ + public Guid Id { get; set; } + public Guid PointAId { get; set; } + public Guid PointBId { get; set; } + public Guid PointCId { get; set; } + + /// + /// Whether the circle's radius, circumference, and area are labelled at its centre. + /// + public bool ShowMeasurement { get; set; } +} diff --git a/MagickCrop/Models/MeasurementControls/DistanceMeasurementControlDto.cs b/MagickCrop/Models/MeasurementControls/DistanceMeasurementControlDto.cs index 26a2a04..2084317 100644 --- a/MagickCrop/Models/MeasurementControls/DistanceMeasurementControlDto.cs +++ b/MagickCrop/Models/MeasurementControls/DistanceMeasurementControlDto.cs @@ -31,4 +31,9 @@ public DistanceMeasurementControlDto() /// Units of measurement (e.g., "pixels", "mm", "in") /// public string Units { get; set; } = "pixels"; + + /// + /// Color of the measurement line and its endpoint handles + /// + public string StrokeColor { get; set; } = "#0066FF"; } diff --git a/MagickCrop/Models/MeasurementControls/MeasurementCollection.cs b/MagickCrop/Models/MeasurementControls/MeasurementCollection.cs index 1ba3d9e..4957530 100644 --- a/MagickCrop/Models/MeasurementControls/MeasurementCollection.cs +++ b/MagickCrop/Models/MeasurementControls/MeasurementCollection.cs @@ -46,6 +46,11 @@ public class MeasurementCollection /// public List PolygonMeasurements { get; set; } = []; + /// + /// Collection of parametric constructions (points and the lines through them) + /// + public List Constructions { get; set; } = []; + /// /// Collection of serialized ink strokes /// diff --git a/MagickCrop/Models/MeasurementControls/PolygonMeasurementControlDto.cs b/MagickCrop/Models/MeasurementControls/PolygonMeasurementControlDto.cs index 1487e76..9c5b154 100644 --- a/MagickCrop/Models/MeasurementControls/PolygonMeasurementControlDto.cs +++ b/MagickCrop/Models/MeasurementControls/PolygonMeasurementControlDto.cs @@ -31,4 +31,9 @@ public PolygonMeasurementControlDto() /// Whether the polygon is closed (completed) /// public bool IsClosed { get; set; } = false; + + /// + /// Color of the polygon outline and its vertex handles + /// + public string StrokeColor { get; set; } = "#0066FF"; } \ No newline at end of file diff --git a/MagickCrop/Models/MeasurementControls/RectangleMeasurementControlDto.cs b/MagickCrop/Models/MeasurementControls/RectangleMeasurementControlDto.cs index 74d1f5a..eeebc76 100644 --- a/MagickCrop/Models/MeasurementControls/RectangleMeasurementControlDto.cs +++ b/MagickCrop/Models/MeasurementControls/RectangleMeasurementControlDto.cs @@ -12,4 +12,5 @@ public RectangleMeasurementControlDto() public Point BottomRight { get; set; } public double ScaleFactor { get; set; } = 1.0; public string Units { get; set; } = "pixels"; + public string StrokeColor { get; set; } = "#0066FF"; } diff --git a/MagickCrop/Models/UndoRedo.cs b/MagickCrop/Models/UndoRedo.cs index 3e1bf0a..90f7f30 100644 --- a/MagickCrop/Models/UndoRedo.cs +++ b/MagickCrop/Models/UndoRedo.cs @@ -1,5 +1,6 @@ using ImageMagick; using MagickCrop.Controls; +using MagickCrop.Models.MeasurementControls; using System.Collections.ObjectModel; using System.ComponentModel; using System.Runtime.CompilerServices; @@ -606,6 +607,101 @@ public override string Redo() } } +/// +/// One construction edit, stored as before/after snapshots of the whole point/line +/// graph rather than as a delta. The graph is tiny and every corner is derived from all +/// of it, so a snapshot is both cheaper to reason about and impossible to desync. +/// +public class ConstructionGeometryEditedItem : UndoRedoItem +{ + private readonly ConstructionOverlayControl _control; + private readonly ConstructionGeometryDto _before; + private readonly ConstructionGeometryDto _after; + + public ConstructionGeometryEditedItem( + ConstructionOverlayControl control, + ConstructionGeometryDto before, + ConstructionGeometryDto after) + { + _control = control; + _before = before; + _after = after; + } + + public override string Undo() + { + _control.RestoreGeometry(_before); + return string.Empty; + } + + public override string Redo() + { + _control.RestoreGeometry(_after); + return string.Empty; + } +} + +/// +/// Removal of whole construction overlays — "Clear Construction", or removing one from +/// its measurement menu. The controls are kept alive so an undo puts back the same +/// instances the geometry undo items still point at. +/// +public class ConstructionOverlaysRemovedItem : UndoRedoItem +{ + private readonly List _overlays; + private readonly ObservableCollection _collection; + private readonly Canvas _canvas; + private readonly Action _wireEvents; + private readonly Action _unwireEvents; + private readonly Action _afterChange; + + public ConstructionOverlaysRemovedItem( + List overlays, + ObservableCollection collection, + Canvas canvas, + Action wireEvents, + Action unwireEvents, + Action afterChange) + { + _overlays = overlays; + _collection = collection; + _canvas = canvas; + _wireEvents = wireEvents; + _unwireEvents = unwireEvents; + _afterChange = afterChange; + } + + public override string Undo() + { + foreach (ConstructionOverlayControl overlay in _overlays) + { + if (_collection.Contains(overlay)) continue; + + _wireEvents(overlay); + _collection.Add(overlay); + _canvas.Children.Add(overlay); + } + + _afterChange(); + return string.Empty; + } + + public override string Redo() + { + foreach (ConstructionOverlayControl overlay in _overlays) + { + if (!_collection.Contains(overlay)) continue; + + _unwireEvents(overlay); + _collection.Remove(overlay); + _canvas.Children.Remove(overlay); + } + + _afterChange(); + return string.Empty; + } +} + public class ResizeUndoRedoItem : UndoRedoItem { private readonly Image _image; diff --git a/MagickCrop/ViewModels/MainWindowViewModel.cs b/MagickCrop/ViewModels/MainWindowViewModel.cs index 4f16e5e..0ee48fb 100644 --- a/MagickCrop/ViewModels/MainWindowViewModel.cs +++ b/MagickCrop/ViewModels/MainWindowViewModel.cs @@ -441,23 +441,9 @@ await Task.Run(() => // Path.GetTempFileName() hands back a ".tmp" name, and Magick picks its // encoder from the extension - ".tmp" resolves to Unknown and fails to - // encode. Pick an explicit format, promoting to PNG when the operation - // introduced transparency that the source format cannot represent. - MagickFormat targetFormat = magickImage.Format; - - if (targetFormat is MagickFormat.Unknown) - targetFormat = MagickFormat.Png; - - if (magickImage.HasAlpha && targetFormat is MagickFormat.Jpeg or MagickFormat.Jpg or MagickFormat.Bmp) - targetFormat = MagickFormat.Png; - - magickImage.Format = targetFormat; - - string tempFileName = Path.ChangeExtension( - Path.GetTempFileName(), - targetFormat.ToString().ToLowerInvariant()); - - await magickImage.WriteAsync(tempFileName, targetFormat); + // encode. WriteToTempFileAsync picks an explicit format, promoting to PNG + // when the operation introduced transparency the source format cannot hold. + string tempFileName = await magickImage.WriteToTempFileAsync(); MagickImageUndoRedoItem undoRedoItem = new(_view.MainImageControl, ImagePath, tempFileName); UndoRedo.AddUndo(undoRedoItem);