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));
|
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.
|
||||||
|
|||||||
@@ -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,33 +153,39 @@ namespace OpenNest.Actions
|
|||||||
{
|
{
|
||||||
var g = e.Graphics;
|
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)
|
foreach (var lp in plateView.LayoutParts)
|
||||||
{
|
{
|
||||||
if (lp == selectedLayoutPart)
|
if (lp != selectedLayoutPart && lp.Path != null)
|
||||||
continue;
|
|
||||||
|
|
||||||
if (lp.Path != null)
|
|
||||||
g.FillPath(grayOverlay, lp.Path);
|
g.FillPath(grayOverlay, lp.Path);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Highlight the hovered contour
|
private void DrawHoveredContour(Graphics g)
|
||||||
if (hoveredContour != null && selectedPart != null)
|
{
|
||||||
{
|
if (hoveredContour == null || selectedPart == null)
|
||||||
using var contourPath = hoveredContour.Shape.GetGraphicsPath();
|
return;
|
||||||
|
|
||||||
// Translate from local part space to world space, then apply view transform
|
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);
|
||||||
contourPath.Transform(contourMatrix);
|
contourPath.Transform(contourMatrix);
|
||||||
|
|
||||||
var prevSmooth = g.SmoothingMode;
|
var prevSmooth = g.SmoothingMode;
|
||||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
g.DrawPath(highlightPen, contourPath);
|
g.DrawPath(highlightPen, contourPath);
|
||||||
g.SmoothingMode = prevSmooth;
|
g.SmoothingMode = prevSmooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DrawLeadInPreview(Graphics g)
|
||||||
|
{
|
||||||
if (!hasSnap || selectedPart == null)
|
if (!hasSnap || selectedPart == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -176,67 +193,113 @@ 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 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the pierce point (in local space)
|
|
||||||
var piercePoint = leadIn.GetPiercePoint(snapPoint, snapNormal);
|
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;
|
var oldSmooth = g.SmoothingMode;
|
||||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
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);
|
using var previewPen = new Pen(Color.Magenta, 2.0f / plateView.ViewScale);
|
||||||
g.DrawLine(previewPen, pt1, pt2);
|
g.DrawLine(previewPen, pt1, pt2);
|
||||||
|
|
||||||
// Draw a small circle at the pierce point
|
|
||||||
var radius = 3.0f / plateView.ViewScale;
|
var radius = 3.0f / plateView.ViewScale;
|
||||||
g.FillEllipse(Brushes.Magenta, pt1.X - radius, pt1.Y - radius, radius * 2, radius * 2);
|
g.FillEllipse(Brushes.Magenta, pt1.X - radius, pt1.Y - radius, radius * 2, radius * 2);
|
||||||
|
|
||||||
// Draw a small circle at the contour start point
|
if (activeSnapType != SnapType.None)
|
||||||
g.FillEllipse(Brushes.Lime, pt2.X - radius, pt2.Y - radius, radius * 2, radius * 2);
|
DrawSnapMarker(g, pt2, activeSnapType);
|
||||||
|
else
|
||||||
|
g.FillEllipse(Brushes.Lime, pt2.X - radius, pt2.Y - radius, radius * 2, radius * 2);
|
||||||
|
|
||||||
g.SmoothingMode = oldSmooth;
|
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()
|
private void SelectPartAtCursor()
|
||||||
{
|
{
|
||||||
var layoutPart = plateView.GetPartAtPoint(plateView.CurrentPoint);
|
var layoutPart = plateView.GetPartAtPoint(plateView.CurrentPoint);
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user