From 7e577c3b1ad1186c27564a4b90d74a8e2f054eea Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 2 Aug 2026 10:55:11 -0500 Subject: [PATCH 01/11] Let quadrilaterals carry a label in the selector Detected quadrilaterals are named "Quad: N" and described by their contour confidence. A quadrilateral that did not come from contour detection has no meaningful confidence, so add an optional Label that overrides both. Also guard the preview scaling against a zero-width or zero-height quad, which divided by zero and produced NaN preview points. Co-Authored-By: Claude Opus 5 --- MagickCrop/Controls/QuadrilateralSelector.xaml.cs | 15 +++++++++++---- MagickCrop/Helpers/QuadrilateralDetector.cs | 6 ++++++ 2 files changed, 17 insertions(+), 4 deletions(-) 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/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) From 1d723b49bb1776ab28c7258b2ccd8d40ad2189d4 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 2 Aug 2026 10:55:41 -0500 Subject: [PATCH 02/11] Add the parametric construction model and solver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geometry for a construction the user builds out of points rather than corners: points along an object's edges, lines through those points, and circles through triples of them. Corners are never stored — ConstructionSolver derives them by intersecting the lines, which is what lets a corner sit outside the image. Points are either free, or derived from the geometry that produced them: the crossing of two lines, or the centre of a circle. A derived point re-fits from its parents on every refresh, and is released into an ordinary point if they are deleted, so keeping one never leaves a dangling reference. Entities reference each other by id rather than index so removing one cannot silently repoint another. The DTOs mirror this for project persistence, and the new fields default such that projects saved before constructions existed still load. Co-Authored-By: Claude Opus 5 --- MagickCrop/Helpers/ConstructionSolver.cs | 347 +++++++++++++ MagickCrop/Helpers/GeometryMathHelper.cs | 38 ++ .../Construction/ConstructionGeometry.cs | 465 ++++++++++++++++++ MagickCrop/Models/DraggingMode.cs | 8 +- .../ConstructionGeometryDto.cs | 80 +++ .../MeasurementCollection.cs | 5 + 6 files changed, 942 insertions(+), 1 deletion(-) create mode 100644 MagickCrop/Helpers/ConstructionSolver.cs create mode 100644 MagickCrop/Models/Construction/ConstructionGeometry.cs create mode 100644 MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs 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/Models/Construction/ConstructionGeometry.cs b/MagickCrop/Models/Construction/ConstructionGeometry.cs new file mode 100644 index 0000000..57dc66f --- /dev/null +++ b/MagickCrop/Models/Construction/ConstructionGeometry.cs @@ -0,0 +1,465 @@ +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; +} + +/// +/// 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; } + + 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) + { + lines.Add(new ConstructionLine + { + Id = id, + StartPointId = startPointId, + EndPointId = endPointId, + IsExtended = isExtended + }); + } + + 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) + { + circles.Add(new ConstructionCircle + { + Id = id, + PointAId = pointAId, + PointBId = pointBId, + PointCId = pointCId + }); + } + + /// + /// 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..a6d8e3b 100644 --- a/MagickCrop/Models/DraggingMode.cs +++ b/MagickCrop/Models/DraggingMode.cs @@ -19,5 +19,11 @@ 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 } diff --git a/MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs b/MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs new file mode 100644 index 0000000..385b962 --- /dev/null +++ b/MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs @@ -0,0 +1,80 @@ +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"; +} + +/// +/// 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; +} + +/// +/// 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; } +} 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 /// From 460b4d4cf657a6a01a49dcbaa725889f4f83d035 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 2 Aug 2026 10:56:23 -0500 Subject: [PATCH 03/11] Add the construction overlay, tools, and undo support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puts the construction model on screen. One control owns the whole graph rather than one per entity, because a derived corner is a function of all the lines and the solve needs a single owner. Interaction is selection-first: a point must be selected before it can be dragged, so aiming at one to pick it no longer nudges it out of place. Two selected points offer a faint line, three offer the circle through them, and clicking the offer builds it. Line crossings and circle centres appear as faint rings that follow their parents and vanish with them unless clicked to keep. Selecting a line or circle and pressing Delete removes it without its points. Every committed edit becomes one undo step, published by the overlay as before/after snapshots of the whole graph — the graph is small and every corner depends on all of it, so a snapshot is both cheaper to reason about and impossible to desync. A drag is one step rather than one per mouse move, and the gesture brackets are idempotent because this codebase ends drags on the next mouse move, which may never arrive. Co-Authored-By: Claude Opus 5 --- .../Controls/ConstructionOverlayControl.xaml | 66 + .../ConstructionOverlayControl.xaml.cs | 1663 +++++++++++++++++ MagickCrop/MainWindow.Construction.cs | 562 ++++++ MagickCrop/MainWindow.xaml | 164 ++ MagickCrop/MainWindow.xaml.cs | 247 ++- MagickCrop/Models/UndoRedo.cs | 96 + 6 files changed, 2760 insertions(+), 38 deletions(-) create mode 100644 MagickCrop/Controls/ConstructionOverlayControl.xaml create mode 100644 MagickCrop/Controls/ConstructionOverlayControl.xaml.cs create mode 100644 MagickCrop/MainWindow.Construction.cs diff --git a/MagickCrop/Controls/ConstructionOverlayControl.xaml b/MagickCrop/Controls/ConstructionOverlayControl.xaml new file mode 100644 index 0000000..cff55ef --- /dev/null +++ b/MagickCrop/Controls/ConstructionOverlayControl.xaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs b/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs new file mode 100644 index 0000000..45fbe68 --- /dev/null +++ b/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs @@ -0,0 +1,1663 @@ +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; + + private static readonly Brush PointBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0066FF")); + private static readonly Brush LineBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0066FF")); + private static readonly Brush SelectionBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FF6600")); + + // 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 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 = []; + + // 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; + + 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; + + 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(); + } + } + + 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; + + // 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; + + /// + /// 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; + + /// 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 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(); + + 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 + }); + } + + foreach (ConstructionCircle circle in geometry.Circles) + { + snapshot.Circles.Add(new ConstructionCircleDto + { + Id = circle.Id, + PointAId = circle.PointAId, + PointBId = circle.PointBId, + PointCId = circle.PointCId + }); + } + + 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; + + 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); + + foreach (ConstructionCircleDto circle in snapshot.Circles) + geometry.AddCircle(circle.Id, circle.PointAId, circle.PointBId, circle.PointCId); + + 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) + 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) + 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(); + RenderLines(); + RenderCircles(); + RenderPoints(); + RenderDerivedCandidates(); + RenderGhostLine(); + RenderShape(); + 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; + } + + /// + /// 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 delete = new() + { + Header = "Delete Circle", + Tag = circleId, + ToolTip = "Delete this circle (its three points are kept)" + }; + delete.Click += CircleDeleteMenuItem_Click; + + 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 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(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 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(); + + 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; + } + + #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) + { + 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() + { + 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 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 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); + + #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; + 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; + + 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/MainWindow.Construction.cs b/MagickCrop/MainWindow.Construction.cs new file mode 100644 index 0000000..445b937 --- /dev/null +++ b/MagickCrop/MainWindow.Construction.cs @@ -0,0 +1,562 @@ +using MagickCrop.Controls; +using MagickCrop.Helpers; +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 + } + + private const string ConstructionEdgeTag = "ConstructionEdge"; + private const string ConstructionPointTag = "ConstructionPoint"; + private const string ConstructionLineTag = "ConstructionLine"; + + /// 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; + + #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; + } + } + + 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; + } + + private void UnwireConstructionOverlay(ConstructionOverlayControl overlay) + { + overlay.MeasurementPointMouseDown -= ConstructionPoint_MouseDown; + overlay.RemoveControlRequested -= ConstructionControl_RemoveControlRequested; + overlay.ConstructionChanged -= ConstructionOverlay_Changed; + overlay.GeometryEdited -= ConstructionOverlay_GeometryEdited; + } + + /// + /// 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); + + /// + /// 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; + + 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.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.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(); + } + } + + /// 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(); + } + + constructionDragEndPointId = null; + constructionDragLineId = 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() + { + bool connectOnSecondSelection = ActiveConstructionTool == ConstructionTool.Line; + + foreach (ConstructionOverlayControl overlay in constructionControls) + overlay.ConnectOnSecondSelection = connectOnSecondSelection; + } + + /// + /// 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; + + private void UpdateConstructionApplyButtons() + { + bool enabled = HasConstructedQuadrilateral; + + foreach (Button? button in new[] + { + ApplyConstructionToTransformButton, + ApplyConstructionToCropButton, + ApplyConstructionToUnWarpButton, + ApplyConstructionToTransformButtonTransformTab + }) + { + if (button is not null) + button.IsEnabled = enabled; + } + } + + 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); + } + + 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..ad96b29 100644 --- a/MagickCrop/MainWindow.xaml +++ b/MagickCrop/MainWindow.xaml @@ -1222,6 +1222,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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]); @@ -486,6 +499,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 +550,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 +713,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 +894,7 @@ private void UpdateTransformVisualScale() lines?.StrokeThickness = 2 * inverseScale; + UpdateConstructionVisualScale(); UpdateCornerNavButtons(); } @@ -1342,6 +1383,7 @@ private BitmapSource RenderImageWithSelectedOverlays( includedElements.UnionWith(circleMeasurementTools); includedElements.UnionWith(verticalLineControls); includedElements.UnionWith(horizontalLineControls); + includedElements.UnionWith(constructionControls); includedElements.UnionWith(ShapeCanvas.Children.OfType()); } @@ -1936,6 +1978,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 +2184,16 @@ private void ShapeCanvas_MouseUp(object sender, MouseButtonEventArgs e) return; } + // --- CONSTRUCTION EDGE DRAG --- + // Keyed to its own dragging mode, so it must be tested before the generic + // CreatingMeasurement block below. + if (draggingMode == DraggingMode.ConstructionEdgeCreate) + { + HandleConstructionMouseUp(e.GetPosition(ShapeCanvas)); + e.Handled = true; + return; + } + if (isCreatingMeasurement && draggingMode == DraggingMode.CreatingMeasurement) { Point endPoint = e.GetPosition(ShapeCanvas); @@ -3042,21 +3106,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; @@ -3414,22 +3482,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; @@ -4385,21 +4455,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; @@ -5083,6 +5155,8 @@ private void RemoveMeasurementControls() horizontalLineControls.Clear(); + RemoveConstructionControls(); + ClearAllStrokesAndLengths(); ClearAllMarkup(); draggingMode = DraggingMode.None; @@ -5287,6 +5361,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 +5385,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 +5449,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 +5501,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 +5768,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 +6028,10 @@ private void ResetTransientState() activePolygonPlacementControl = null; } + // --- Construction placement --- + CancelConstructionGesture(); + activeConstructionControl = null; + // --- Circle measurement placement --- isPlacingCircleMeasurement = false; if (activeCirclePlacementControl != null) @@ -6340,11 +6448,23 @@ private void ToolSelector_Checked(object sender, RoutedEventArgs e) UncheckAllBut(toggleButton); } - private bool IsAnyToolSelected() + /// + /// 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() { - List toolToggleButtons = [.. MeasureToolsPanel.Children.OfType()]; + if (toolPanels is null) + return MeasureToolsPanel?.Children.OfType() ?? []; + + return toolPanels + .Where(panel => panel is not null) + .SelectMany(panel => panel.Children.OfType()); + } - foreach (ToggleButton button in toolToggleButtons) + private bool IsAnyToolSelected() + { + foreach (ToggleButton button in AllToolToggles()) if (button.IsChecked == true) return true; @@ -6353,17 +6473,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; - foreach (ToggleButton button in toolToggleButtons) - if (button != toggleButton) - button.IsChecked = false; + 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 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 +6604,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 @@ -7033,7 +7188,9 @@ private bool ShouldShowPixelZoom() DraggingMode.MeasureAngle or DraggingMode.MeasureRectangle or DraggingMode.MeasurePolygon or - DraggingMode.MeasureCircle) + DraggingMode.MeasureCircle or + DraggingMode.ConstructionPoint or + DraggingMode.ConstructionEdgeCreate) return true; // Show during measurement creation (active drag) @@ -7066,6 +7223,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 +7246,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/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; From 938f056ba439f4c4ec03648dba4e5ae553efa833 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 2 Aug 2026 12:18:53 -0500 Subject: [PATCH 04/11] Add boundary probe analyzer and image sample buffer helpers Added `BoundaryProbeAnalyzer` for sub-pixel, color-based edge detection along user probe lines using a 1D derivative-of-Gaussian algorithm. Introduced `ImageSampleBuffer` for fast, bilinear RGB sampling from images with robust file handling. Both classes are optimized for interactive measurement tools and have no WPF dependencies. --- MagickCrop/Helpers/BoundaryProbeAnalyzer.cs | 432 ++++++++++++++++++++ MagickCrop/Helpers/ImageSampleBuffer.cs | 114 ++++++ 2 files changed, 546 insertions(+) create mode 100644 MagickCrop/Helpers/BoundaryProbeAnalyzer.cs create mode 100644 MagickCrop/Helpers/ImageSampleBuffer.cs diff --git a/MagickCrop/Helpers/BoundaryProbeAnalyzer.cs b/MagickCrop/Helpers/BoundaryProbeAnalyzer.cs new file mode 100644 index 0000000..451c2c1 --- /dev/null +++ b/MagickCrop/Helpers/BoundaryProbeAnalyzer.cs @@ -0,0 +1,432 @@ +using System.Windows; + +namespace MagickCrop.Helpers; + +/// +/// 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/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); + } +} From 3e8469c6bc5e57b06c6bd3880d21dd2b0de907d8 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 2 Aug 2026 12:19:13 -0500 Subject: [PATCH 05/11] Add Boundary probe tool with live preview and image analysis Introduced a "Boundary" construction tool for probing image edges by dragging a line; analyzes pixels to find boundaries and previews the landing point. Added a live preview marker in ConstructionOverlayControl, with visual cues for weak detections. MainWindow now manages the probe gesture lifecycle, including async image sampling via ImageSampleBuffer for efficient analysis. Updated UI state, undo/redo, and hint system to support the new tool. Extended enums and tool state sync for the Boundary tool and drag mode. --- .../ConstructionOverlayControl.xaml.cs | 108 ++++++++ MagickCrop/MainWindow.Construction.cs | 256 +++++++++++++++++- MagickCrop/Models/DraggingMode.cs | 5 +- 3 files changed, 365 insertions(+), 4 deletions(-) diff --git a/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs b/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs index 45fbe68..e1fa5f0 100644 --- a/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs +++ b/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs @@ -77,6 +77,14 @@ public partial class ConstructionOverlayControl : UserControl 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 int pointDraggingIndex = -1; private bool areDragGizmosVisible = true; private bool areEndpointCapsVisible; @@ -133,6 +141,7 @@ public bool IsDragGizmoVisible // 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. @@ -277,6 +286,47 @@ public void ShowPreviewLine(Point from, Point to) 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; @@ -358,6 +408,10 @@ private void Edit(Action change) 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(); @@ -449,6 +503,8 @@ public void RestoreGeometry(ConstructionGeometryDto snapshot) // Whatever was in flight refers to a state that no longer exists. dragSnapshot = null; editSnapshot = null; + transientHint = null; + HideBoundaryCandidate(); geometry.Clear(); @@ -1295,6 +1351,53 @@ private ContextMenu BuildPointContextMenu() 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; @@ -1328,6 +1431,7 @@ public void UpdateVisualScale(double inverseScale) RenderCircles(); RenderDerivedCandidates(); RenderGhostLine(); + ApplyBoundaryCandidateAppearance(); MeasurementText.RenderTransformOrigin = new Point(0.5, 0.5); MeasurementText.RenderTransform = new ScaleTransform(visualScale, visualScale); @@ -1439,6 +1543,10 @@ private string BuildCircleText(double radius) /// 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"; diff --git a/MagickCrop/MainWindow.Construction.cs b/MagickCrop/MainWindow.Construction.cs index 445b937..e1ac14d 100644 --- a/MagickCrop/MainWindow.Construction.cs +++ b/MagickCrop/MainWindow.Construction.cs @@ -21,12 +21,14 @@ public enum ConstructionTool None, Edge, Point, - Line + Line, + Boundary } private const string ConstructionEdgeTag = "ConstructionEdge"; private const string ConstructionPointTag = "ConstructionPoint"; private const string ConstructionLineTag = "ConstructionLine"; + private const string ConstructionBoundaryTag = "ConstructionBoundary"; /// Minimum drag distance before an edge drag counts as more than a click. private const double ConstructionDragThreshold = 5.0; @@ -42,6 +44,16 @@ public enum ConstructionTool 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 @@ -57,6 +69,7 @@ private ConstructionTool ActiveConstructionTool case ConstructionEdgeTag: return ConstructionTool.Edge; case ConstructionPointTag: return ConstructionTool.Point; case ConstructionLineTag: return ConstructionTool.Line; + case ConstructionBoundaryTag: return ConstructionTool.Boundary; } } @@ -145,8 +158,11 @@ private void ConstructionOverlay_Changed(object? sender, EventArgs e) => /// 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() => + private void RemoveConstructionControls() + { RemoveConstructionOverlays([.. constructionControls], recordUndo: false); + ReleaseProbeBuffer(); + } /// /// Takes overlays off the canvas, optionally as an undoable step. The controls @@ -246,6 +262,11 @@ private bool HandleConstructionMouseDown(Point canvasPoint, MouseButtonEventArgs e.Handled = true; return true; + case ConstructionTool.Boundary: + StartBoundaryProbe(canvasPoint); + e.Handled = true; + return true; + default: return false; } @@ -316,6 +337,9 @@ private void HandleConstructionLineClick(Point canvasPoint) /// private bool HandleConstructionMouseMove(Point canvasPoint) { + if (draggingMode == DraggingMode.ConstructionBoundaryProbe) + return UpdateBoundaryProbe(canvasPoint); + if (draggingMode == DraggingMode.ConstructionEdgeCreate && constructionOverlay is not null && constructionDragEndPointId is Guid dragEndId) @@ -344,6 +368,9 @@ constructionOverlay is not null && /// private bool HandleConstructionMouseUp(Point canvasPoint) { + if (draggingMode == DraggingMode.ConstructionBoundaryProbe) + return FinishBoundaryProbe(canvasPoint); + if (draggingMode != DraggingMode.ConstructionEdgeCreate) return false; @@ -394,6 +421,218 @@ private bool HandleConstructionMouseUp(Point canvasPoint) } } + #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() { @@ -414,10 +653,12 @@ private void CancelConstructionGesture() // undo step. overlay.EndDrag(); overlay.ClearSelection(); + overlay.HideBoundaryCandidate(); } constructionDragEndPointId = null; constructionDragLineId = null; + boundaryProbeStart = null; } /// @@ -437,10 +678,19 @@ private void ClearConstructionSelection() /// private void SyncConstructionToolState() { - bool connectOnSecondSelection = ActiveConstructionTool == ConstructionTool.Line; + ConstructionTool tool = ActiveConstructionTool; + bool connectOnSecondSelection = tool == ConstructionTool.Line; foreach (ConstructionOverlayControl overlay in constructionControls) overlay.ConnectOnSecondSelection = connectOnSecondSelection; + + // 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(); } /// diff --git a/MagickCrop/Models/DraggingMode.cs b/MagickCrop/Models/DraggingMode.cs index a6d8e3b..ed46a03 100644 --- a/MagickCrop/Models/DraggingMode.cs +++ b/MagickCrop/Models/DraggingMode.cs @@ -25,5 +25,8 @@ public enum DraggingMode ConstructionPoint, /// Dragging out a new construction edge and its two points. - ConstructionEdgeCreate + ConstructionEdgeCreate, + + /// Dragging a probe line across a boundary to find where the edge falls on it. + ConstructionBoundaryProbe } From 6660995df4f3db4129b6750fbcce2566f16e96ee Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 2 Aug 2026 12:32:55 -0500 Subject: [PATCH 06/11] Add safe temp file writing & edge tool UI sync Centralize MagickImage temp file writing with extension/encoder logic, updating all usages to ensure correct format. Add ConstructionBoundaryToggle and ProgressRing controls for "Find Edge Point" tool in both tabs, with tooltips/icons. Sync tool toggle state across tabs, handling Unchecked events. Improve pixel zoom and coordinate conversion for edge probing. Refactor for clarity and update comments/tooltips. --- MagickCrop/Helpers/MagickExtensions.cs | 74 +++++++++++ MagickCrop/MainWindow.xaml | 93 ++++++++++++-- MagickCrop/MainWindow.xaml.cs | 127 +++++++++++++++---- MagickCrop/ViewModels/MainWindowViewModel.cs | 20 +-- 4 files changed, 255 insertions(+), 59 deletions(-) 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/MainWindow.xaml b/MagickCrop/MainWindow.xaml index ad96b29..a1505bf 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"> @@ -1234,7 +1241,8 @@ Checked="ToolSelector_Checked" Click="ToolSelector_Clicked" Tag="ConstructionEdge" - ToolTip="Drag along an object's edge to lay down an edge line. Release on an existing point to reuse it."> + ToolTip="Drag along an object's edge to lay down an edge line. Release on an existing point to reuse it." + Unchecked="ToolSelector_Unchecked"> @@ -1249,7 +1257,8 @@ Checked="ToolSelector_Checked" Click="ToolSelector_Clicked" Tag="ConstructionPoint" - ToolTip="Click to place a construction point. Clicking an existing point selects it instead."> + ToolTip="Click to place a construction point. Clicking an existing point selects it instead." + Unchecked="ToolSelector_Unchecked"> @@ -1264,7 +1273,8 @@ Checked="ToolSelector_Checked" Click="ToolSelector_Clicked" Tag="ConstructionLine" - ToolTip="Click two points to connect them, then keep clicking to chain more edges"> + ToolTip="Click two points to connect them, then keep clicking to chain more edges" + Unchecked="ToolSelector_Unchecked"> @@ -1272,6 +1282,32 @@ + + + + + + + + + + + ToolTip="Drag along an object's edge to lay down an edge line. Release on an existing point to reuse it." + Unchecked="ToolSelector_Unchecked"> @@ -1975,7 +2012,8 @@ Checked="ToolSelector_Checked" Click="ToolSelector_Clicked" Tag="ConstructionPoint" - ToolTip="Click to place a construction point. Clicking an existing point selects it instead."> + ToolTip="Click to place a construction point. Clicking an existing point selects it instead." + Unchecked="ToolSelector_Unchecked"> @@ -1990,7 +2028,8 @@ Checked="ToolSelector_Checked" Click="ToolSelector_Clicked" Tag="ConstructionLine" - ToolTip="Click two points to connect them, then keep clicking to chain more edges"> + ToolTip="Click two points to connect them, then keep clicking to chain more edges" + Unchecked="ToolSelector_Unchecked"> @@ -1998,6 +2037,32 @@ + + + + + + + + + + }); } - 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. @@ -2184,10 +2189,10 @@ private void ShapeCanvas_MouseUp(object sender, MouseButtonEventArgs e) return; } - // --- CONSTRUCTION EDGE DRAG --- - // Keyed to its own dragging mode, so it must be tested before the generic + // --- CONSTRUCTION EDGE DRAG / BOUNDARY PROBE --- + // Keyed to their own dragging modes, so they must be tested before the generic // CreatingMeasurement block below. - if (draggingMode == DraggingMode.ConstructionEdgeCreate) + if (draggingMode is DraggingMode.ConstructionEdgeCreate or DraggingMode.ConstructionBoundaryProbe) { HandleConstructionMouseUp(e.GetPosition(ShapeCanvas)); e.Handled = true; @@ -2967,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); @@ -3050,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); @@ -3417,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); @@ -3773,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); @@ -4055,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); @@ -4404,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); @@ -4587,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); @@ -6448,6 +6446,55 @@ private void ToolSelector_Checked(object sender, RoutedEventArgs e) UncheckAllBut(toggleButton); } + /// + /// 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) + { + if (toggleButton.Tag is not string tag) + return; + + 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. @@ -7005,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); @@ -7119,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 @@ -7172,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. @@ -7190,7 +7260,8 @@ DraggingMode.MeasureRectangle or DraggingMode.MeasurePolygon or DraggingMode.MeasureCircle or DraggingMode.ConstructionPoint or - DraggingMode.ConstructionEdgeCreate) + DraggingMode.ConstructionEdgeCreate or + DraggingMode.ConstructionBoundaryProbe) return true; // Show during measurement creation (active drag) 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); From e46585ad89e5cc018ad212eb641b93f7bd07c9cf Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 2 Aug 2026 12:37:03 -0500 Subject: [PATCH 07/11] Hide construction tool UI by default; update button style The construction tool usage TextBlock and the "Construction (Parametric)" GroupBox are now hidden by default. The "Use for Transform" button style was changed from Primary to Secondary for visual consistency. --- MagickCrop/MainWindow.xaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/MagickCrop/MainWindow.xaml b/MagickCrop/MainWindow.xaml index a1505bf..23042fa 100644 --- a/MagickCrop/MainWindow.xaml +++ b/MagickCrop/MainWindow.xaml @@ -1316,13 +1316,14 @@ FontStyle="Italic" Foreground="{wpfui:ThemeResource TextFillColorSecondaryBrush}" Text="Corners are where the edge lines cross. Click a point to select it, then drag to reshape. Select two points and click the faint line to connect them, or a third point for the circle through all three. Faint rings mark line crossings and circle centres — click one to keep it as a point. Find Edge Point drags a short line across a boundary and drops the point on the transition. Select a line or circle and press Delete to remove it — its points stay." - TextWrapping="Wrap" /> + TextWrapping="Wrap" + Visibility="Collapsed" /> + Header="Construction (Parametric)" + Visibility="Collapsed"> Date: Sat, 8 Aug 2026 16:10:27 -0500 Subject: [PATCH 08/11] Add per-shape measurement label toggles to overlay Each line and circle now has a ShowMeasurement property, persisted in DTOs and project files, allowing users to toggle measurement labels via context menu. Added global ShowShapeMeasurement toggle for derived shape readout, with UI and persistence. Updated rendering and event handlers to respect these toggles. Serialization logic ensures backward compatibility with older projects. --- .../Controls/ConstructionOverlayControl.xaml | 9 +- .../ConstructionOverlayControl.xaml.cs | 194 +++++++++++++++++- .../Construction/ConstructionGeometry.cs | 23 ++- .../ConstructionGeometryDto.cs | 17 ++ 4 files changed, 232 insertions(+), 11 deletions(-) diff --git a/MagickCrop/Controls/ConstructionOverlayControl.xaml b/MagickCrop/Controls/ConstructionOverlayControl.xaml index cff55ef..6c1108a 100644 --- a/MagickCrop/Controls/ConstructionOverlayControl.xaml +++ b/MagickCrop/Controls/ConstructionOverlayControl.xaml @@ -37,7 +37,14 @@ CornerRadius="3" ToolTip="Construction measurement options"> - + + 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 = []; @@ -85,6 +91,8 @@ public partial class ConstructionOverlayControl : UserControl private string? transientHint; + private bool showShapeMeasurement = true; + private int pointDraggingIndex = -1; private bool areDragGizmosVisible = true; private bool areEndpointCapsVisible; @@ -265,6 +273,48 @@ public void SetLineExtended(Guid lineId, bool 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 @@ -467,7 +517,8 @@ public ConstructionGeometryDto CaptureGeometry() Id = line.Id, StartPointId = line.StartPointId, EndPointId = line.EndPointId, - IsExtended = line.IsExtended + IsExtended = line.IsExtended, + ShowMeasurement = line.ShowMeasurement }); } @@ -478,7 +529,8 @@ public ConstructionGeometryDto CaptureGeometry() Id = circle.Id, PointAId = circle.PointAId, PointBId = circle.PointBId, - PointCId = circle.PointCId + PointCId = circle.PointCId, + ShowMeasurement = circle.ShowMeasurement }); } @@ -512,10 +564,10 @@ public void RestoreGeometry(ConstructionGeometryDto snapshot) 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); + 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); + geometry.AddCircle(circle.Id, circle.PointAId, circle.PointBId, circle.PointCId, circle.ShowMeasurement); Refresh(); } @@ -542,7 +594,8 @@ private static bool GeometryEquals(ConstructionGeometryDto a, ConstructionGeomet 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].IsExtended != b.Lines[i].IsExtended || + a.Lines[i].ShowMeasurement != b.Lines[i].ShowMeasurement) return false; } @@ -551,7 +604,8 @@ private static bool GeometryEquals(ConstructionGeometryDto a, ConstructionGeomet 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].PointCId != b.Circles[i].PointCId || + a.Circles[i].ShowMeasurement != b.Circles[i].ShowMeasurement) return false; } @@ -816,6 +870,7 @@ public void Refresh() RenderDerivedCandidates(); RenderGhostLine(); RenderShape(); + RenderMeasurementLabels(); UpdateDisplay(); ConstructionChanged?.Invoke(this, EventArgs.Empty); } @@ -1075,6 +1130,16 @@ 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", @@ -1083,6 +1148,7 @@ private ContextMenu BuildCircleContextMenu(Guid circleId) }; delete.Click += CircleDeleteMenuItem_Click; + menu.Items.Add(showMeasurement); menu.Items.Add(delete); return menu; } @@ -1101,6 +1167,16 @@ private ContextMenu BuildLineContextMenu(ConstructionLine line) }; 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", @@ -1110,6 +1186,7 @@ private ContextMenu BuildLineContextMenu(ConstructionLine line) delete.Click += LineDeleteMenuItem_Click; menu.Items.Add(extend); + menu.Items.Add(showMeasurement); menu.Items.Add(delete); return menu; } @@ -1431,6 +1508,7 @@ public void UpdateVisualScale(double inverseScale) RenderCircles(); RenderDerivedCandidates(); RenderGhostLine(); + RenderMeasurementLabels(); ApplyBoundaryCandidateAppearance(); MeasurementText.RenderTransformOrigin = new Point(0.5, 0.5); @@ -1454,6 +1532,79 @@ private void RenderShape() ShapePath.Data = pathGeometry; } + /// + /// 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 @@ -1481,6 +1632,11 @@ private string BuildPrimaryText() 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); @@ -1697,6 +1853,20 @@ private void LineExtendMenuItem_Click(object sender, RoutedEventArgs e) 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; @@ -1719,6 +1889,16 @@ private void PointDeleteMenuItem_Click(object sender, RoutedEventArgs e) 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; @@ -1743,6 +1923,7 @@ public ConstructionGeometryDto ToDto() ConstructionGeometryDto dto = CaptureGeometry(); dto.ScaleFactor = ScaleFactor; dto.Units = Units; + dto.ShowShapeMeasurement = showShapeMeasurement; return dto; } @@ -1752,6 +1933,7 @@ public void FromDto(ConstructionGeometryDto dto) // transaction machinery. scaleFactor = dto.ScaleFactor; units = dto.Units; + showShapeMeasurement = dto.ShowShapeMeasurement; RestoreGeometry(dto); } diff --git a/MagickCrop/Models/Construction/ConstructionGeometry.cs b/MagickCrop/Models/Construction/ConstructionGeometry.cs index 57dc66f..354b3df 100644 --- a/MagickCrop/Models/Construction/ConstructionGeometry.cs +++ b/MagickCrop/Models/Construction/ConstructionGeometry.cs @@ -75,6 +75,13 @@ public class ConstructionLine /// 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; } } /// @@ -89,6 +96,12 @@ public class ConstructionCircle 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 @@ -170,14 +183,15 @@ public Guid AddLine(Guid startPointId, Guid endPointId, bool isExtended = true) return line.Id; } - public void AddLine(Guid id, Guid startPointId, Guid endPointId, bool isExtended) + 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 + IsExtended = isExtended, + ShowMeasurement = showMeasurement }); } @@ -193,14 +207,15 @@ public Guid AddCircle(Guid pointAId, Guid pointBId, Guid pointCId) return circle.Id; } - public void AddCircle(Guid id, Guid pointAId, Guid pointBId, Guid pointCId) + 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 + PointCId = pointCId, + ShowMeasurement = showMeasurement }); } diff --git a/MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs b/MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs index 385b962..4d8ac0d 100644 --- a/MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs +++ b/MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs @@ -29,6 +29,12 @@ public ConstructionGeometryDto() /// 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; } /// @@ -65,6 +71,12 @@ public class ConstructionLineDto /// 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; } } /// @@ -77,4 +89,9 @@ public class ConstructionCircleDto 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; } } From fbce64ded6031d75803da660e55d52f1ced9c4e6 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 8 Aug 2026 17:50:10 -0500 Subject: [PATCH 09/11] Add face selection tool for construction overlays Introduced a "Face" selection tool allowing users to select and merge bounded regions formed by construction lines. Added `ConstructionFaceSolver` to compute and merge faces, and a `ConstructionFace` record to represent each cell. Updated `ConstructionOverlayControl` to manage face states, hit-testing, and visual feedback. Enhanced `MainWindow` with UI for selecting shapes and merging faces into polygons, including button state logic. Updated XAML to support the new tool and help text. --- .../ConstructionOverlayControl.xaml.cs | 199 ++++++++ MagickCrop/Helpers/ConstructionFaceSolver.cs | 481 ++++++++++++++++++ MagickCrop/MainWindow.Construction.cs | 92 +++- MagickCrop/MainWindow.xaml | 27 +- .../Models/Construction/ConstructionFace.cs | 16 + 5 files changed, 811 insertions(+), 4 deletions(-) create mode 100644 MagickCrop/Helpers/ConstructionFaceSolver.cs create mode 100644 MagickCrop/Models/Construction/ConstructionFace.cs diff --git a/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs b/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs index 13cdf4c..b217301 100644 --- a/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs +++ b/MagickCrop/Controls/ConstructionOverlayControl.xaml.cs @@ -34,8 +34,15 @@ public partial class ConstructionOverlayControl : UserControl private static readonly 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; @@ -101,6 +108,15 @@ public partial class ConstructionOverlayControl : UserControl 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(); @@ -418,6 +434,61 @@ public bool TryGetQuadrilateral(out QuadrilateralDetector.DetectedQuadrilateral #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. @@ -864,12 +935,14 @@ public void Refresh() geometry.RefreshDerivedPoints(); Solve(); + SolveFaces(); RenderLines(); RenderCircles(); RenderPoints(); RenderDerivedCandidates(); RenderGhostLine(); RenderShape(); + RenderFaces(); RenderMeasurementLabels(); UpdateDisplay(); ConstructionChanged?.Invoke(this, EventArgs.Empty); @@ -885,6 +958,44 @@ private void Solve() 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. @@ -1508,6 +1619,7 @@ public void UpdateVisualScale(double inverseScale) RenderCircles(); RenderDerivedCandidates(); RenderGhostLine(); + RenderFaces(); RenderMeasurementLabels(); ApplyBoundaryCandidateAppearance(); @@ -1532,6 +1644,93 @@ private void RenderShape() 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, 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/MainWindow.Construction.cs b/MagickCrop/MainWindow.Construction.cs index e1ac14d..f7e9d50 100644 --- a/MagickCrop/MainWindow.Construction.cs +++ b/MagickCrop/MainWindow.Construction.cs @@ -1,5 +1,6 @@ using MagickCrop.Controls; using MagickCrop.Helpers; +using MagickCrop.Models.MeasurementControls; using System.Collections.ObjectModel; using System.Windows; using System.Windows.Controls; @@ -22,13 +23,15 @@ public enum ConstructionTool Edge, Point, Line, - Boundary + 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; @@ -70,6 +73,7 @@ private ConstructionTool ActiveConstructionTool case ConstructionPointTag: return ConstructionTool.Point; case ConstructionLineTag: return ConstructionTool.Line; case ConstructionBoundaryTag: return ConstructionTool.Boundary; + case ConstructionFaceTag: return ConstructionTool.Face; } } @@ -123,6 +127,7 @@ private void WireConstructionOverlay(ConstructionOverlayControl overlay) overlay.RemoveControlRequested += ConstructionControl_RemoveControlRequested; overlay.ConstructionChanged += ConstructionOverlay_Changed; overlay.GeometryEdited += ConstructionOverlay_GeometryEdited; + overlay.FaceSelectionChanged += ConstructionOverlay_Changed; } private void UnwireConstructionOverlay(ConstructionOverlayControl overlay) @@ -131,6 +136,7 @@ private void UnwireConstructionOverlay(ConstructionOverlayControl overlay) overlay.RemoveControlRequested -= ConstructionControl_RemoveControlRequested; overlay.ConstructionChanged -= ConstructionOverlay_Changed; overlay.GeometryEdited -= ConstructionOverlay_GeometryEdited; + overlay.FaceSelectionChanged -= ConstructionOverlay_Changed; } /// @@ -267,6 +273,12 @@ private bool HandleConstructionMouseDown(Point canvasPoint, MouseButtonEventArgs 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; } @@ -680,9 +692,13 @@ 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 @@ -758,9 +774,31 @@ private void PrependConstructedQuadrilateral(List 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 enabled = HasConstructedQuadrilateral; + bool quadrilateralEnabled = HasConstructedQuadrilateral; foreach (Button? button in new[] { @@ -771,8 +809,11 @@ private void UpdateConstructionApplyButtons() }) { if (button is not null) - button.IsEnabled = enabled; + button.IsEnabled = quadrilateralEnabled; } + + if (ApplyConstructionToPolygonButton is not null) + ApplyConstructionToPolygonButton.IsEnabled = HasSelectedConstructionFaces || HasConstructedRing; } private void ApplyConstructionToTransform_Click(object sender, RoutedEventArgs e) @@ -802,6 +843,51 @@ private void ApplyConstructionToUnWarp_Click(object sender, RoutedEventArgs e) 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); diff --git a/MagickCrop/MainWindow.xaml b/MagickCrop/MainWindow.xaml index 23042fa..8e2e05e 100644 --- a/MagickCrop/MainWindow.xaml +++ b/MagickCrop/MainWindow.xaml @@ -1298,6 +1298,22 @@ + + + + + + + + @@ -1346,6 +1362,15 @@ Content="Use for Un-Warp" IsEnabled="False" ToolTip="Move the un-warp markers to the constructed corners" /> + +/// 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); From b53b5a4320c443f66fca3c2075312c4174947010 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 8 Aug 2026 23:27:10 -0500 Subject: [PATCH 10/11] Add MagickCrop.Tests project with core unit tests Added MagickCrop.Tests (.NET 10.0, MSTest, Moq, WPF) to solution with UndoRedo, GeometryMathHelper, and MeasurementFormattingHelper tests. Enabled parallel test execution. Updated solution and settings for test integration. --- .claude/settings.local.json | 3 +- .../Helpers/GeometryMathHelperTests.cs | 114 ++++++++++++++++++ .../MeasurementFormattingHelperTests.cs | 39 ++++++ MagickCrop.Tests/MSTestSettings.cs | 1 + MagickCrop.Tests/MagickCrop.Tests.csproj | 40 ++++++ MagickCrop.Tests/UndoRedoTests.cs | 111 +++++++++++++++++ MagickCrop.sln | 22 ++++ 7 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 MagickCrop.Tests/Helpers/GeometryMathHelperTests.cs create mode 100644 MagickCrop.Tests/Helpers/MeasurementFormattingHelperTests.cs create mode 100644 MagickCrop.Tests/MSTestSettings.cs create mode 100644 MagickCrop.Tests/MagickCrop.Tests.csproj create mode 100644 MagickCrop.Tests/UndoRedoTests.cs 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 From e3e473e793ffc6021114f4c51d9daa7ec5cc3641 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 9 Aug 2026 00:06:15 -0500 Subject: [PATCH 11/11] Per-shape color picker & persistence for measurements Added "Change Color" context menu to all measurement, markup, and construction controls, enabling per-instance color selection via a new ColorPickerDialog and ColorSwatchPicker control. Colors are now serialized in project DTOs and restored on load, with backward compatibility for older files. Refactored guide lines to use the new picker. Centralized swatch colors in ColorPalette helper. Visuals update immediately on color change. --- .../Controls/AngleMeasurementControl.xaml | 4 + .../Controls/AngleMeasurementControl.xaml.cs | 42 ++++++- .../Controls/CircleMeasurementControl.xaml | 4 + .../Controls/CircleMeasurementControl.xaml.cs | 40 ++++++- MagickCrop/Controls/ColorSwatchPicker.xaml | 37 +++++++ MagickCrop/Controls/ColorSwatchPicker.xaml.cs | 104 ++++++++++++++++++ .../Controls/ConstructionOverlayControl.xaml | 4 + .../ConstructionOverlayControl.xaml.cs | 48 +++++++- .../Controls/DistanceMeasurementControl.xaml | 4 + .../DistanceMeasurementControl.xaml.cs | 39 ++++++- .../Controls/HorizontalLineControl.xaml | 5 +- .../Controls/HorizontalLineControl.xaml.cs | 28 +---- MagickCrop/Controls/MarkupShapeControl.xaml | 1 + .../Controls/MarkupShapeControl.xaml.cs | 11 ++ MagickCrop/Controls/MarkupTextControl.xaml | 1 + MagickCrop/Controls/MarkupTextControl.xaml.cs | 11 ++ .../Controls/PolygonMeasurementControl.xaml | 4 + .../PolygonMeasurementControl.xaml.cs | 48 +++++++- .../Controls/RectangleMeasurementControl.xaml | 4 + .../RectangleMeasurementControl.xaml.cs | 40 ++++++- MagickCrop/Controls/StrokeLengthDisplay.xaml | 1 + .../Controls/StrokeLengthDisplay.xaml.cs | 18 ++- MagickCrop/Controls/VerticalLineControl.xaml | 5 +- .../Controls/VerticalLineControl.xaml.cs | 30 ++--- MagickCrop/Helpers/ColorPalette.cs | 24 ++++ MagickCrop/Helpers/ColorPickerDialog.cs | 30 +++++ .../AngleMeasurementControlDto.cs | 5 + .../CircleMeasurementControlDto.cs | 5 + .../ConstructionGeometryDto.cs | 7 ++ .../DistanceMeasurementControlDto.cs | 5 + .../PolygonMeasurementControlDto.cs | 5 + .../RectangleMeasurementControlDto.cs | 1 + 32 files changed, 558 insertions(+), 57 deletions(-) create mode 100644 MagickCrop/Controls/ColorSwatchPicker.xaml create mode 100644 MagickCrop/Controls/ColorSwatchPicker.xaml.cs create mode 100644 MagickCrop/Helpers/ColorPalette.cs create mode 100644 MagickCrop/Helpers/ColorPickerDialog.cs 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 index 6c1108a..68aa9bd 100644 --- a/MagickCrop/Controls/ConstructionOverlayControl.xaml +++ b/MagickCrop/Controls/ConstructionOverlayControl.xaml @@ -49,6 +49,10 @@ Click="CopyMeasurementMenuItem_Click" Header="Copy Measurement" ToolTip="Copy the perimeter and area values" /> + How much bigger a selected point handle draws than an idle one. private const double SelectedPointScale = 1.5; - private static readonly Brush PointBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0066FF")); - private static readonly Brush LineBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0066FF")); + // 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 @@ -152,6 +155,27 @@ public string Units } } + 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; @@ -2111,6 +2135,16 @@ private void MeasurementButton_Click(object sender, RoutedEventArgs e) 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 @@ -2123,6 +2157,7 @@ public ConstructionGeometryDto ToDto() dto.ScaleFactor = ScaleFactor; dto.Units = Units; dto.ShowShapeMeasurement = showShapeMeasurement; + dto.StrokeColor = strokeColor.ToString(); return dto; } @@ -2134,6 +2169,15 @@ public void FromDto(ConstructionGeometryDto dto) 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); } 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/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"> - + +/// 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/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 index 4d8ac0d..cf55957 100644 --- a/MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs +++ b/MagickCrop/Models/MeasurementControls/ConstructionGeometryDto.cs @@ -35,6 +35,13 @@ public ConstructionGeometryDto() /// 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; } } /// 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/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"; }