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:
@@ -155,6 +155,17 @@ namespace OpenNest.Geometry
|
||||
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>
|
||||
/// Splits the arc at the given point, returning two sub-arcs.
|
||||
/// Either half may be null if the split point coincides with an endpoint.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user