Files
OpenNest/OpenNest/Actions/ActionLeadIn.cs
T
aj 2b8a3ce7db fix(leadin): add close button to Place Lead-in side panel
The docked side panel could only be closed with Escape, which was
unreliable: MainForm consumes Escape before ActionLeadIn's KeyDown
handler, so with a part selected ActionManager called the action's
empty CancelAction and nothing happened; with nothing selected the
panel closed but was stored as the previous action, so the next
Escape reopened it.

- EditNestForm side panel gets a header with the panel title and an
  always-visible close button that ends the action.
- ActionLeadIn.CancelAction now steps back (unlock contour, then
  deselect part), replacing the dead KeyDown handler.
- Actions can opt out of Escape-resume (ResumeOnEscape); the lead-in
  action does, so a closed panel stays closed.
2026-09-29 08:38:48 -04:00

665 lines
22 KiB
C#

using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Controls;
using OpenNest.Converters;
using OpenNest.Forms;
using OpenNest.Geometry;
using OpenNest.Math;
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;
private List<ShapeInfo> contours;
private Vector snapPoint;
private Entity snapEntity;
private ContourType snapContourType;
private double snapNormal;
private bool hasSnap;
private SnapType activeSnapType;
private ShapeInfo hoveredContour;
private ShapeInfo lockedContour;
private ContextMenuStrip contextMenu;
private CuttingPanel cuttingPanel;
private static readonly Brush grayOverlay = new SolidBrush(
Color.FromArgb(160, 180, 180, 180)
);
private static readonly Pen highlightPen = new Pen(Color.Cyan, 2.5f);
private static readonly Pen lockedPen = new Pen(Color.Yellow, 3.0f);
public ActionLeadIn(PlateView plateView)
: base(plateView)
{
ConnectEvents();
}
public override void ConnectEvents()
{
plateView.MouseMove += OnMouseMove;
plateView.MouseDown += OnMouseDown;
plateView.Paint += OnPaint;
ShowSidePanel();
}
public override void DisconnectEvents()
{
plateView.MouseMove -= OnMouseMove;
plateView.MouseDown -= OnMouseDown;
plateView.Paint -= OnPaint;
HideSidePanel();
contextMenu?.Dispose();
contextMenu = null;
DeselectPart();
}
// Escape reaches here through ActionManager.ProcessEscapeKey while IsBusy:
// step back one level (unlock the contour, then release the part). Once
// nothing is selected, Escape ends the action and closes the side panel.
public override void CancelAction()
{
if (lockedContour != null)
UnlockContour();
else
DeselectPart();
}
public override bool IsBusy() => selectedPart != null;
// Escape closes the side panel; a second Escape must not reopen it.
public override bool ResumeOnEscape => false;
private void ShowSidePanel()
{
var form = plateView.FindForm() as EditNestForm;
if (form == null)
return;
cuttingPanel = new CuttingPanel { ShowAutoAssign = true };
cuttingPanel.AutoAssignClicked += OnAutoAssignClicked;
cuttingPanel.ParametersChanged += OnToolParametersChanged;
// Load current parameters or defaults
var plate = plateView.Plate;
if (plate?.CuttingParameters != null)
cuttingPanel.LoadFromParameters(plate.CuttingParameters);
else
{
var json = Properties.Settings.Default.CuttingParametersJson;
if (!string.IsNullOrEmpty(json))
{
try
{
var saved = CuttingParametersSerializer.Deserialize(json);
cuttingPanel.LoadFromParameters(saved);
}
catch
{ /* use defaults */
}
}
}
form.ShowSidePanel(cuttingPanel, "Place Lead-in");
}
private void HideSidePanel()
{
if (cuttingPanel == null)
return;
SaveParameters();
cuttingPanel.ParametersChanged -= OnToolParametersChanged;
cuttingPanel.AutoAssignClicked -= OnAutoAssignClicked;
var form = plateView.FindForm() as EditNestForm;
form?.HideSidePanel();
cuttingPanel = null;
}
private CuttingParameters GetCurrentParameters()
{
return cuttingPanel?.BuildParameters()
?? plateView.Plate?.CuttingParameters
?? new CuttingParameters();
}
private void SaveParameters()
{
if (cuttingPanel == null)
return;
var parameters = cuttingPanel.BuildParameters();
var json = CuttingParametersSerializer.Serialize(parameters);
Properties.Settings.Default.CuttingParametersJson = json;
Properties.Settings.Default.Save();
if (plateView.Plate != null)
plateView.Plate.CuttingParameters = parameters;
}
private void OnToolParametersChanged(object sender, System.EventArgs e)
{
plateView.Invalidate();
}
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (selectedPart == null || contours == null)
return;
var worldPt = plateView.CurrentPoint;
// Transform world point into program-local space by subtracting the
// part's location. The contour shapes are already in the program's
// rotated coordinate system, so no additional un-rotation is needed.
var localPt = new Vector(
worldPt.X - selectedPart.Location.X,
worldPt.Y - selectedPart.Location.Y
);
// Find closest contour and point
var bestDist = double.MaxValue;
hasSnap = false;
activeSnapType = SnapType.None;
hoveredContour = null;
// When a contour is locked, only snap within that contour
var searchContours =
lockedContour != null ? new List<ShapeInfo> { lockedContour } : contours;
foreach (var info in searchContours)
{
var closest = info.Shape.ClosestPointTo(localPt, out var entity);
var dist = closest.DistanceTo(localPt);
if (dist < bestDist)
{
bestDist = dist;
snapPoint = closest;
snapEntity = entity;
snapContourType = info.ContourType;
hasSnap = true;
hoveredContour = info;
}
}
// Check endpoint/midpoint snaps on the hovered contour
if (hoveredContour != null)
{
TrySnapToEntityPoints(localPt);
// Auto-switch tool window tab only when no contour is locked
if (cuttingPanel != null && lockedContour == null)
cuttingPanel.ActiveContourType = snapContourType;
}
plateView.Invalidate();
}
private void OnMouseDown(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
if (selectedPart == null)
{
// First click: select a part
SelectPartAtCursor();
}
else if (lockedContour == null && hasSnap)
{
// Second click: lock the hovered contour
LockContour(hoveredContour);
}
else if (lockedContour != null && hasSnap)
{
// Third click: commit lead-in at snap point on locked contour
CommitLeadIn();
}
}
else if (e.Button == MouseButtons.Right)
{
if (lockedContour != null)
UnlockContour();
else if (selectedPart != null && selectedPart.HasManualLeadIns)
ShowContextMenu(e.Location);
else
DeselectPart();
}
}
private void OnPaint(object sender, PaintEventArgs e)
{
var g = e.Graphics;
DrawOverlay(g);
DrawHoveredContour(g);
DrawLeadInPreview(g);
}
private void LockContour(ShapeInfo contour)
{
lockedContour = contour;
// Lock the tab to this contour type
if (cuttingPanel != null)
cuttingPanel.ActiveContourType = contour.ContourType;
plateView.Invalidate();
}
private void UnlockContour()
{
lockedContour = null;
plateView.Invalidate();
}
private void DrawOverlay(Graphics g)
{
foreach (var lp in plateView.LayoutParts)
{
if (lp != selectedLayoutPart && lp.Path != null)
g.FillPath(grayOverlay, lp.Path);
}
}
private void DrawHoveredContour(Graphics g)
{
if (selectedPart == null)
return;
// Draw locked contour with distinct pen
if (lockedContour != null)
{
DrawContourHighlight(g, lockedContour.Shape, lockedPen);
return;
}
// Draw hovered contour
if (hoveredContour != null)
DrawContourHighlight(g, hoveredContour.Shape, highlightPen);
}
private void DrawContourHighlight(Graphics g, Shape shape, Pen pen)
{
using var contourPath = 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(pen, contourPath);
g.SmoothingMode = prevSmooth;
}
private void DrawLeadInPreview(Graphics g)
{
if (!hasSnap || selectedPart == null)
return;
var parameters = GetCurrentParameters();
var leadIn = SelectLeadIn(parameters, snapContourType);
if (leadIn == null)
return;
// Same resolution as program generation, so a corner previews identically
// whichever of its two edges the cursor picked.
leadIn = ContourCuttingStrategy.ResolveLeadIn(
hoveredContour.Shape,
snapPoint,
snapEntity,
snapContourType,
leadIn,
hoveredContour.Winding,
parameters.PierceClearance,
out snapNormal
);
leadIn = ClampLeadInForCircle(leadIn, parameters);
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 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;
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);
if (layoutPart == null)
return;
var part = layoutPart.BasePart;
// Don't allow lead-in placement on cut-off parts
if (part.BaseDrawing.IsCutOff)
return;
selectedLayoutPart = layoutPart;
selectedPart = part;
// Build contour info from the part's program geometry
BuildContourInfo();
// Highlight the selected part
layoutPart.IsSelected = true;
plateView.Invalidate();
}
private void BuildContourInfo()
{
// Get a clean program (no lead-ins) in the part's current rotated space.
// If the part has manual lead-ins, rebuild from base drawing + rotation.
// Otherwise the current Program is already clean and rotated.
CNC.Program cleanProgram;
if (selectedPart.HasManualLeadIns)
{
cleanProgram = selectedPart.BaseDrawing.Program.Clone() as CNC.Program;
if (!OpenNest.Math.Tolerance.IsEqualTo(selectedPart.Rotation, 0))
cleanProgram.Rotate(selectedPart.Rotation);
}
else
{
cleanProgram = selectedPart.Program;
}
var entities = ConvertProgram
.ToGeometry(cleanProgram)
.Where(e => e.Layer == SpecialLayers.Cut)
.ToList();
profile = new ShapeProfile(entities);
contours = new List<ShapeInfo>();
// Perimeter is always External
if (profile.Perimeter != null)
{
contours.Add(
new ShapeInfo
{
Shape = profile.Perimeter,
ContourType = ContourType.External,
Winding = ContourCuttingStrategy.DetermineWinding(profile.Perimeter),
}
);
}
// Cutouts
foreach (var cutout in profile.Cutouts)
{
contours.Add(
new ShapeInfo
{
Shape = cutout,
ContourType = ContourCuttingStrategy.DetectContourType(cutout),
Winding = ContourCuttingStrategy.DetermineWinding(cutout),
}
);
}
}
private void CommitLeadIn()
{
var parameters = GetCurrentParameters();
if (selectedPart.HasManualLeadIns)
selectedPart.RemoveLeadIns();
ApplyAutoTab(parameters);
selectedPart.ApplySingleLeadIn(parameters, snapPoint, snapEntity, snapContourType);
selectedPart.LeadInsLocked = true;
selectedLayoutPart.IsDirty = true;
UnlockContour();
plateView.Invalidate();
}
private void OnAutoAssignClicked(object sender, System.EventArgs e)
{
if (selectedPart == null)
return;
var parameters = GetCurrentParameters();
if (selectedPart.HasManualLeadIns)
selectedPart.RemoveLeadIns();
ApplyAutoTab(parameters);
selectedPart.ApplyLeadIns(parameters, Vector.Zero);
selectedPart.LeadInsLocked = true;
selectedLayoutPart.IsDirty = true;
UnlockContour();
plateView.Invalidate();
}
private void ApplyAutoTab(CuttingParameters parameters)
{
if (parameters.AutoTabMinSize <= 0 && parameters.AutoTabMaxSize <= 0)
return;
var bbox = selectedPart.Program.BoundingBox();
var minDim = System.Math.Min(bbox.Width, bbox.Length);
if (minDim >= parameters.AutoTabMinSize && minDim <= parameters.AutoTabMaxSize)
parameters.TabsEnabled = true;
}
private void DeselectPart()
{
if (selectedLayoutPart != null)
{
selectedLayoutPart.IsSelected = false;
selectedLayoutPart = null;
}
selectedPart = null;
profile = null;
contours = null;
lockedContour = null;
hasSnap = false;
activeSnapType = SnapType.None;
hoveredContour = null;
plateView.Invalidate();
}
private void ShowContextMenu(Point location)
{
contextMenu?.Dispose();
contextMenu = new ContextMenuStrip();
var removeItem = new ToolStripMenuItem("Remove All Lead-ins");
removeItem.Click += (s, e) =>
{
selectedPart.RemoveLeadIns();
selectedLayoutPart.IsDirty = true;
selectedLayoutPart.Update();
DeselectPart();
plateView.Invalidate();
};
contextMenu.Items.Add(removeItem);
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
// before building the profile), so just add the part location offset
return new Vector(
localPt.X + selectedPart.Location.X,
localPt.Y + selectedPart.Location.Y
);
}
private static LeadIn SelectLeadIn(CuttingParameters parameters, ContourType contourType)
{
return contourType switch
{
ContourType.ArcCircle => parameters.ArcCircleLeadIn ?? parameters.InternalLeadIn,
ContourType.Internal => parameters.InternalLeadIn,
_ => parameters.ExternalLeadIn,
};
}
private class ShapeInfo
{
public Shape Shape { get; set; }
public ContourType ContourType { get; set; }
public RotationType Winding { get; set; }
}
}
}