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) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 08:42:13 -04:00
parent 5bd4c89999
commit 5c66fb3b72
2 changed files with 164 additions and 60 deletions
+11
View File
@@ -155,6 +155,17 @@ namespace OpenNest.Geometry
Center.Y + Radius * System.Math.Sin(EndAngle)); Center.Y + Radius * System.Math.Sin(EndAngle));
} }
/// <summary>
/// Mid point of the arc (point at the angle midway between start and end).
/// </summary>
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));
}
/// <summary> /// <summary>
/// Splits the arc at the given point, returning two sub-arcs. /// Splits the arc at the given point, returning two sub-arcs.
/// Either half may be null if the split point coincides with an endpoint. /// Either half may be null if the split point coincides with an endpoint.
+142 -49
View File
@@ -14,6 +14,10 @@ namespace OpenNest.Actions
[DisplayName("Place Lead-in")] [DisplayName("Place Lead-in")]
public class ActionLeadIn : Action public class ActionLeadIn : Action
{ {
private enum SnapType { None, Endpoint, Midpoint }
private const double SnapCapturePixels = 10.0;
private LayoutPart selectedLayoutPart; private LayoutPart selectedLayoutPart;
private Part selectedPart; private Part selectedPart;
private ShapeProfile profile; private ShapeProfile profile;
@@ -23,6 +27,7 @@ namespace OpenNest.Actions
private ContourType snapContourType; private ContourType snapContourType;
private double snapNormal; private double snapNormal;
private bool hasSnap; private bool hasSnap;
private SnapType activeSnapType;
private ShapeInfo hoveredContour; private ShapeInfo hoveredContour;
private ContextMenuStrip contextMenu; private ContextMenuStrip contextMenu;
private static readonly Brush grayOverlay = new SolidBrush(Color.FromArgb(160, 180, 180, 180)); private static readonly Brush grayOverlay = new SolidBrush(Color.FromArgb(160, 180, 180, 180));
@@ -57,6 +62,7 @@ namespace OpenNest.Actions
profile = null; profile = null;
contours = null; contours = null;
hasSnap = false; hasSnap = false;
activeSnapType = SnapType.None;
hoveredContour = null; hoveredContour = null;
plateView.Invalidate(); plateView.Invalidate();
} }
@@ -81,6 +87,7 @@ namespace OpenNest.Actions
// Find closest contour and point // Find closest contour and point
var bestDist = double.MaxValue; var bestDist = double.MaxValue;
hasSnap = false; hasSnap = false;
activeSnapType = SnapType.None;
hoveredContour = null; hoveredContour = null;
foreach (var info in contours) 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(); plateView.Invalidate();
} }
@@ -142,22 +153,26 @@ namespace OpenNest.Actions
{ {
var g = e.Graphics; var g = e.Graphics;
// Gray overlay on all parts except the selected one DrawOverlay(g);
foreach (var lp in plateView.LayoutParts) DrawHoveredContour(g);
{ DrawLeadInPreview(g);
if (lp == selectedLayoutPart)
continue;
if (lp.Path != null)
g.FillPath(grayOverlay, lp.Path);
} }
// Highlight the hovered contour private void DrawOverlay(Graphics g)
if (hoveredContour != null && selectedPart != null)
{ {
using var contourPath = hoveredContour.Shape.GetGraphicsPath(); foreach (var lp in plateView.LayoutParts)
{
if (lp != selectedLayoutPart && lp.Path != null)
g.FillPath(grayOverlay, lp.Path);
}
}
// Translate from local part space to world space, then apply view transform private void DrawHoveredContour(Graphics g)
{
if (hoveredContour == null || selectedPart == null)
return;
using var contourPath = hoveredContour.Shape.GetGraphicsPath();
using var contourMatrix = new Matrix(); using var contourMatrix = new Matrix();
contourMatrix.Translate((float)selectedPart.Location.X, (float)selectedPart.Location.Y); contourMatrix.Translate((float)selectedPart.Location.X, (float)selectedPart.Location.Y);
contourMatrix.Multiply(plateView.Matrix, MatrixOrder.Append); contourMatrix.Multiply(plateView.Matrix, MatrixOrder.Append);
@@ -169,6 +184,8 @@ namespace OpenNest.Actions
g.SmoothingMode = prevSmooth; g.SmoothingMode = prevSmooth;
} }
private void DrawLeadInPreview(Graphics g)
{
if (!hasSnap || selectedPart == null) if (!hasSnap || selectedPart == null)
return; return;
@@ -176,26 +193,50 @@ namespace OpenNest.Actions
if (parameters == null) if (parameters == null)
return; 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); var leadIn = SelectLeadIn(parameters, snapContourType);
if (leadIn == null) if (leadIn == null)
return; return;
// Clamp lead-in for circle contours so it stays inside the hole leadIn = ClampLeadInForCircle(leadIn, parameters);
if (snapContourType == ContourType.ArcCircle && snapEntity is Circle snapCircle
&& parameters.PierceClearance > 0) var piercePoint = leadIn.GetPiercePoint(snapPoint, snapNormal);
var pt1 = plateView.PointWorldToGraph(TransformToWorld(piercePoint));
var pt2 = plateView.PointWorldToGraph(TransformToWorld(snapPoint));
var oldSmooth = g.SmoothingMode;
g.SmoothingMode = SmoothingMode.AntiAlias;
using var previewPen = new Pen(Color.Magenta, 2.0f / plateView.ViewScale);
g.DrawLine(previewPen, pt1, pt2);
var radius = 3.0f / plateView.ViewScale;
g.FillEllipse(Brushes.Magenta, pt1.X - radius, pt1.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 pierceCheck = leadIn.GetPiercePoint(snapPoint, snapNormal);
var distFromCenter = pierceCheck.DistanceTo(snapCircle.Center);
var maxRadius = snapCircle.Radius - parameters.PierceClearance; var maxRadius = snapCircle.Radius - parameters.PierceClearance;
if (maxRadius > 0 && distFromCenter > maxRadius)
{ if (maxRadius <= 0 || pierceCheck.DistanceTo(snapCircle.Center) <= maxRadius)
return leadIn;
var currentDist = snapPoint.DistanceTo(pierceCheck); var currentDist = snapPoint.DistanceTo(pierceCheck);
if (currentDist > Tolerance.Epsilon) if (currentDist <= Tolerance.Epsilon)
{ return leadIn;
var dx = (pierceCheck.X - snapPoint.X) / currentDist; var dx = (pierceCheck.X - snapPoint.X) / currentDist;
var dy = (pierceCheck.Y - snapPoint.Y) / currentDist; var dy = (pierceCheck.Y - snapPoint.Y) / currentDist;
var vx = snapPoint.X - snapCircle.Center.X; var vx = snapPoint.X - snapCircle.Center.X;
@@ -203,38 +244,60 @@ namespace OpenNest.Actions
var b = 2.0 * (vx * dx + vy * dy); var b = 2.0 * (vx * dx + vy * dy);
var c = vx * vx + vy * vy - maxRadius * maxRadius; var c = vx * vx + vy * vy - maxRadius * maxRadius;
var disc = b * b - 4.0 * c; var disc = b * b - 4.0 * c;
if (disc >= 0)
{ if (disc < 0)
return leadIn;
var t = (-b + System.Math.Sqrt(disc)) / 2.0; var t = (-b + System.Math.Sqrt(disc)) / 2.0;
if (t > 0 && t < currentDist) return (t > 0 && t < currentDist) ? leadIn.Scale(t / currentDist) : leadIn;
leadIn = leadIn.Scale(t / currentDist);
}
} }
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;
} }
} }
// Get the pierce point (in local space) if (bestType != SnapType.None)
var piercePoint = leadIn.GetPiercePoint(snapPoint, snapNormal); {
var worldPierce = TransformToWorld(piercePoint); snapPoint = bestPoint;
snapEntity = bestEntity;
snapNormal = ContourCuttingStrategy.ComputeNormal(bestPoint, bestEntity, snapContourType);
activeSnapType = bestType;
}
var oldSmooth = g.SmoothingMode; return;
g.SmoothingMode = SmoothingMode.AntiAlias;
// Draw the lead-in preview as a line from pierce point to contour point void TryCandidate(Vector pt, Entity ent, SnapType type)
var pt1 = plateView.PointWorldToGraph(worldPierce); {
var pt2 = plateView.PointWorldToGraph(worldSnap); var dist = pt.DistanceTo(localPt);
if (dist < bestDist)
using var previewPen = new Pen(Color.Magenta, 2.0f / plateView.ViewScale); {
g.DrawLine(previewPen, pt1, pt2); bestDist = dist;
bestPoint = pt;
// Draw a small circle at the pierce point bestEntity = ent;
var radius = 3.0f / plateView.ViewScale; bestType = type;
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);
g.SmoothingMode = oldSmooth;
} }
private void SelectPartAtCursor() private void SelectPartAtCursor()
@@ -342,6 +405,7 @@ namespace OpenNest.Actions
profile = null; profile = null;
contours = null; contours = null;
hasSnap = false; hasSnap = false;
activeSnapType = SnapType.None;
hoveredContour = null; hoveredContour = null;
plateView.Invalidate(); plateView.Invalidate();
} }
@@ -365,6 +429,35 @@ namespace OpenNest.Actions
contextMenu.Show(plateView, location); 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) private Vector TransformToWorld(Vector localPt)
{ {
// The contours are already in rotated local space (we rotated the program // The contours are already in rotated local space (we rotated the program