From 5c66fb3b721116542e7dfdd3486fcc503aee6316 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 1 Apr 2026 08:42:13 -0400 Subject: [PATCH] feat: add snap-to-endpoint/midpoint for lead-in placement Priority-based snapping: when the cursor is within 10px of an entity endpoint or midpoint, snaps to it instead of the nearest contour point. Diamond marker (endpoint) or triangle marker (midpoint) replaces the lime dot to indicate active snap. Also refactors OnPaint into focused helper methods and adds Arc.MidPoint(). Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Geometry/Arc.cs | 11 ++ OpenNest/Actions/ActionLeadIn.cs | 213 ++++++++++++++++++++++--------- 2 files changed, 164 insertions(+), 60 deletions(-) diff --git a/OpenNest.Core/Geometry/Arc.cs b/OpenNest.Core/Geometry/Arc.cs index 002cd09..1982bce 100644 --- a/OpenNest.Core/Geometry/Arc.cs +++ b/OpenNest.Core/Geometry/Arc.cs @@ -155,6 +155,17 @@ namespace OpenNest.Geometry Center.Y + Radius * System.Math.Sin(EndAngle)); } + /// + /// Mid point of the arc (point at the angle midway between start and end). + /// + public Vector MidPoint() + { + var midAngle = StartAngle + (IsReversed ? -SweepAngle() / 2 : SweepAngle() / 2); + return new Vector( + Center.X + Radius * System.Math.Cos(midAngle), + Center.Y + Radius * System.Math.Sin(midAngle)); + } + /// /// Splits the arc at the given point, returning two sub-arcs. /// Either half may be null if the split point coincides with an endpoint. diff --git a/OpenNest/Actions/ActionLeadIn.cs b/OpenNest/Actions/ActionLeadIn.cs index 6d5addc..b23864b 100644 --- a/OpenNest/Actions/ActionLeadIn.cs +++ b/OpenNest/Actions/ActionLeadIn.cs @@ -14,6 +14,10 @@ namespace OpenNest.Actions [DisplayName("Place Lead-in")] public class ActionLeadIn : Action { + private enum SnapType { None, Endpoint, Midpoint } + + private const double SnapCapturePixels = 10.0; + private LayoutPart selectedLayoutPart; private Part selectedPart; private ShapeProfile profile; @@ -23,6 +27,7 @@ namespace OpenNest.Actions private ContourType snapContourType; private double snapNormal; private bool hasSnap; + private SnapType activeSnapType; private ShapeInfo hoveredContour; private ContextMenuStrip contextMenu; private static readonly Brush grayOverlay = new SolidBrush(Color.FromArgb(160, 180, 180, 180)); @@ -57,6 +62,7 @@ namespace OpenNest.Actions profile = null; contours = null; hasSnap = false; + activeSnapType = SnapType.None; hoveredContour = null; plateView.Invalidate(); } @@ -81,6 +87,7 @@ namespace OpenNest.Actions // Find closest contour and point var bestDist = double.MaxValue; hasSnap = false; + activeSnapType = SnapType.None; hoveredContour = null; foreach (var info in contours) @@ -100,6 +107,10 @@ namespace OpenNest.Actions } } + // Check endpoint/midpoint snaps on the hovered contour + if (hoveredContour != null) + TrySnapToEntityPoints(localPt); + plateView.Invalidate(); } @@ -142,33 +153,39 @@ namespace OpenNest.Actions { var g = e.Graphics; - // Gray overlay on all parts except the selected one + DrawOverlay(g); + DrawHoveredContour(g); + DrawLeadInPreview(g); + } + + private void DrawOverlay(Graphics g) + { foreach (var lp in plateView.LayoutParts) { - if (lp == selectedLayoutPart) - continue; - - if (lp.Path != null) + if (lp != selectedLayoutPart && lp.Path != null) g.FillPath(grayOverlay, lp.Path); } + } - // Highlight the hovered contour - if (hoveredContour != null && selectedPart != null) - { - using var contourPath = hoveredContour.Shape.GetGraphicsPath(); + private void DrawHoveredContour(Graphics g) + { + if (hoveredContour == null || selectedPart == null) + return; - // Translate from local part space to world space, then apply view transform - using var contourMatrix = new Matrix(); - contourMatrix.Translate((float)selectedPart.Location.X, (float)selectedPart.Location.Y); - contourMatrix.Multiply(plateView.Matrix, MatrixOrder.Append); - contourPath.Transform(contourMatrix); + using var contourPath = hoveredContour.Shape.GetGraphicsPath(); + using var contourMatrix = new Matrix(); + contourMatrix.Translate((float)selectedPart.Location.X, (float)selectedPart.Location.Y); + contourMatrix.Multiply(plateView.Matrix, MatrixOrder.Append); + contourPath.Transform(contourMatrix); - var prevSmooth = g.SmoothingMode; - g.SmoothingMode = SmoothingMode.AntiAlias; - g.DrawPath(highlightPen, contourPath); - g.SmoothingMode = prevSmooth; - } + var prevSmooth = g.SmoothingMode; + g.SmoothingMode = SmoothingMode.AntiAlias; + g.DrawPath(highlightPen, contourPath); + g.SmoothingMode = prevSmooth; + } + private void DrawLeadInPreview(Graphics g) + { if (!hasSnap || selectedPart == null) return; @@ -176,67 +193,113 @@ namespace OpenNest.Actions if (parameters == null) return; - // Transform snap point from local part space to world space - var worldSnap = TransformToWorld(snapPoint); - - // Get the appropriate lead-in for this contour type var leadIn = SelectLeadIn(parameters, snapContourType); if (leadIn == null) return; - // Clamp lead-in for circle contours so it stays inside the hole - if (snapContourType == ContourType.ArcCircle && snapEntity is Circle snapCircle - && parameters.PierceClearance > 0) - { - var pierceCheck = leadIn.GetPiercePoint(snapPoint, snapNormal); - var distFromCenter = pierceCheck.DistanceTo(snapCircle.Center); - var maxRadius = snapCircle.Radius - parameters.PierceClearance; - if (maxRadius > 0 && distFromCenter > maxRadius) - { - var currentDist = snapPoint.DistanceTo(pierceCheck); - if (currentDist > Tolerance.Epsilon) - { - var dx = (pierceCheck.X - snapPoint.X) / currentDist; - var dy = (pierceCheck.Y - snapPoint.Y) / currentDist; - var vx = snapPoint.X - snapCircle.Center.X; - var vy = snapPoint.Y - snapCircle.Center.Y; - var b = 2.0 * (vx * dx + vy * dy); - var c = vx * vx + vy * vy - maxRadius * maxRadius; - var disc = b * b - 4.0 * c; - if (disc >= 0) - { - var t = (-b + System.Math.Sqrt(disc)) / 2.0; - if (t > 0 && t < currentDist) - leadIn = leadIn.Scale(t / currentDist); - } - } - } - } + leadIn = ClampLeadInForCircle(leadIn, parameters); - // Get the pierce point (in local space) var piercePoint = leadIn.GetPiercePoint(snapPoint, snapNormal); - var worldPierce = TransformToWorld(piercePoint); + var pt1 = plateView.PointWorldToGraph(TransformToWorld(piercePoint)); + var pt2 = plateView.PointWorldToGraph(TransformToWorld(snapPoint)); var oldSmooth = g.SmoothingMode; g.SmoothingMode = SmoothingMode.AntiAlias; - // Draw the lead-in preview as a line from pierce point to contour point - var pt1 = plateView.PointWorldToGraph(worldPierce); - var pt2 = plateView.PointWorldToGraph(worldSnap); - using var previewPen = new Pen(Color.Magenta, 2.0f / plateView.ViewScale); g.DrawLine(previewPen, pt1, pt2); - // Draw a small circle at the pierce point var radius = 3.0f / plateView.ViewScale; g.FillEllipse(Brushes.Magenta, pt1.X - radius, pt1.Y - radius, radius * 2, radius * 2); - // Draw a small circle at the contour start point - g.FillEllipse(Brushes.Lime, pt2.X - radius, pt2.Y - radius, radius * 2, radius * 2); + if (activeSnapType != SnapType.None) + DrawSnapMarker(g, pt2, activeSnapType); + else + g.FillEllipse(Brushes.Lime, pt2.X - radius, pt2.Y - radius, radius * 2, radius * 2); g.SmoothingMode = oldSmooth; } + private LeadIn ClampLeadInForCircle(LeadIn leadIn, CuttingParameters parameters) + { + if (snapContourType != ContourType.ArcCircle + || !(snapEntity is Circle snapCircle) + || parameters.PierceClearance <= 0) + return leadIn; + + var pierceCheck = leadIn.GetPiercePoint(snapPoint, snapNormal); + var maxRadius = snapCircle.Radius - parameters.PierceClearance; + + if (maxRadius <= 0 || pierceCheck.DistanceTo(snapCircle.Center) <= maxRadius) + return leadIn; + + var currentDist = snapPoint.DistanceTo(pierceCheck); + if (currentDist <= Tolerance.Epsilon) + return leadIn; + + var dx = (pierceCheck.X - snapPoint.X) / currentDist; + var dy = (pierceCheck.Y - snapPoint.Y) / currentDist; + var vx = snapPoint.X - snapCircle.Center.X; + var vy = snapPoint.Y - snapCircle.Center.Y; + var b = 2.0 * (vx * dx + vy * dy); + var c = vx * vx + vy * vy - maxRadius * maxRadius; + var disc = b * b - 4.0 * c; + + if (disc < 0) + return leadIn; + + var t = (-b + System.Math.Sqrt(disc)) / 2.0; + return (t > 0 && t < currentDist) ? leadIn.Scale(t / currentDist) : leadIn; + } + + private void TrySnapToEntityPoints(Vector localPt) + { + var captureRadius = SnapCapturePixels / plateView.ViewScale; + var bestDist = captureRadius; + var bestPoint = default(Vector); + var bestEntity = default(Entity); + var bestType = SnapType.None; + + foreach (var entity in hoveredContour.Shape.Entities) + { + switch (entity) + { + case Line line: + TryCandidate(line.StartPoint, line, SnapType.Endpoint); + TryCandidate(line.EndPoint, line, SnapType.Endpoint); + TryCandidate(line.MidPoint, line, SnapType.Midpoint); + break; + case Arc arc: + TryCandidate(arc.StartPoint(), arc, SnapType.Endpoint); + TryCandidate(arc.EndPoint(), arc, SnapType.Endpoint); + TryCandidate(arc.MidPoint(), arc, SnapType.Midpoint); + break; + } + } + + if (bestType != SnapType.None) + { + snapPoint = bestPoint; + snapEntity = bestEntity; + snapNormal = ContourCuttingStrategy.ComputeNormal(bestPoint, bestEntity, snapContourType); + activeSnapType = bestType; + } + + return; + + void TryCandidate(Vector pt, Entity ent, SnapType type) + { + var dist = pt.DistanceTo(localPt); + if (dist < bestDist) + { + bestDist = dist; + bestPoint = pt; + bestEntity = ent; + bestType = type; + } + } + } + private void SelectPartAtCursor() { var layoutPart = plateView.GetPartAtPoint(plateView.CurrentPoint); @@ -342,6 +405,7 @@ namespace OpenNest.Actions profile = null; contours = null; hasSnap = false; + activeSnapType = SnapType.None; hoveredContour = null; plateView.Invalidate(); } @@ -365,6 +429,35 @@ namespace OpenNest.Actions contextMenu.Show(plateView, location); } + private void DrawSnapMarker(Graphics g, PointF pt, SnapType type) + { + var size = 5f; + + if (type == SnapType.Endpoint) + { + // Diamond + var points = new[] + { + new PointF(pt.X, pt.Y - size), + new PointF(pt.X + size, pt.Y), + new PointF(pt.X, pt.Y + size), + new PointF(pt.X - size, pt.Y) + }; + g.FillPolygon(Brushes.Red, points); + } + else if (type == SnapType.Midpoint) + { + // Triangle + var points = new[] + { + new PointF(pt.X, pt.Y - size), + new PointF(pt.X + size, pt.Y + size), + new PointF(pt.X - size, pt.Y + size) + }; + g.FillPolygon(Brushes.Red, points); + } + } + private Vector TransformToWorld(Vector localPt) { // The contours are already in rotated local space (we rotated the program