From 5307c5c85a6380bf6f02feb97bd896544aeae44f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 30 Mar 2026 13:47:22 -0400 Subject: [PATCH] feat: add ActionLeadIn for manual lead-in placement on part contours Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CuttingStrategy/ContourCuttingStrategy.cs | 6 +- OpenNest/Actions/ActionLeadIn.cs | 330 ++++++++++++++++++ OpenNest/Forms/EditNestForm.Designer.cs | 14 +- OpenNest/Forms/EditNestForm.cs | 20 ++ 4 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 OpenNest/Actions/ActionLeadIn.cs diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index f056d41..08cb2bf 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -102,7 +102,7 @@ namespace OpenNest.CNC.CuttingStrategy return ordered; } - internal static ContourType DetectContourType(Shape cutout) + public static ContourType DetectContourType(Shape cutout) { if (cutout.Entities.Count == 1 && cutout.Entities[0] is Circle) return ContourType.ArcCircle; @@ -110,7 +110,7 @@ namespace OpenNest.CNC.CuttingStrategy return ContourType.Internal; } - internal static double ComputeNormal(Vector point, Entity entity, ContourType contourType) + public static double ComputeNormal(Vector point, Entity entity, ContourType contourType) { double normal; @@ -141,7 +141,7 @@ namespace OpenNest.CNC.CuttingStrategy return Math.Angle.NormalizeRad(normal); } - internal static RotationType DetermineWinding(Shape shape) + public static RotationType DetermineWinding(Shape shape) { // Use signed area: positive = CCW, negative = CW var area = shape.Area(); diff --git a/OpenNest/Actions/ActionLeadIn.cs b/OpenNest/Actions/ActionLeadIn.cs new file mode 100644 index 0000000..87084af --- /dev/null +++ b/OpenNest/Actions/ActionLeadIn.cs @@ -0,0 +1,330 @@ +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Controls; +using OpenNest.Converters; +using OpenNest.Geometry; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms; + +namespace OpenNest.Actions +{ + [DisplayName("Place Lead-in")] + public class ActionLeadIn : Action + { + private LayoutPart selectedLayoutPart; + private Part selectedPart; + private ShapeProfile profile; + private List contours; + private Vector snapPoint; + private Entity snapEntity; + private ContourType snapContourType; + private double snapNormal; + private bool hasSnap; + private ContextMenuStrip contextMenu; + + public ActionLeadIn(PlateView plateView) + : base(plateView) + { + ConnectEvents(); + } + + public override void ConnectEvents() + { + plateView.MouseMove += OnMouseMove; + plateView.MouseDown += OnMouseDown; + plateView.KeyDown += OnKeyDown; + plateView.Paint += OnPaint; + } + + public override void DisconnectEvents() + { + plateView.MouseMove -= OnMouseMove; + plateView.MouseDown -= OnMouseDown; + plateView.KeyDown -= OnKeyDown; + plateView.Paint -= OnPaint; + + contextMenu?.Dispose(); + contextMenu = null; + + selectedLayoutPart = null; + selectedPart = null; + profile = null; + contours = null; + hasSnap = false; + plateView.Invalidate(); + } + + public override void CancelAction() { } + + public override bool IsBusy() => selectedPart != null; + + 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; + + foreach (var info in contours) + { + 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; + snapNormal = ContourCuttingStrategy.ComputeNormal(closest, entity, info.ContourType); + hasSnap = true; + } + } + + 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 (hasSnap) + { + // Second click: commit lead-in at snap point + CommitLeadIn(); + } + } + else if (e.Button == MouseButtons.Right) + { + if (selectedPart != null && selectedPart.HasManualLeadIns) + ShowContextMenu(e.Location); + else + DeselectPart(); + } + } + + private void OnKeyDown(object sender, KeyEventArgs e) + { + if (e.KeyCode == Keys.Escape) + { + if (selectedPart != null) + DeselectPart(); + else + plateView.SetAction(typeof(ActionSelect)); + } + } + + private void OnPaint(object sender, PaintEventArgs e) + { + if (!hasSnap || selectedPart == null) + return; + + var parameters = plateView.Plate?.CuttingParameters; + 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; + + // Get the pierce point (in local space) + var piercePoint = leadIn.GetPiercePoint(snapPoint, snapNormal); + var worldPierce = TransformToWorld(piercePoint); + + var g = e.Graphics; + 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 pen = new Pen(Color.Yellow, 2.0f / plateView.ViewScale); + g.DrawLine(pen, pt1, pt2); + + // Draw a small circle at the pierce point + var radius = 3.0f / plateView.ViewScale; + g.FillEllipse(Brushes.Yellow, 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() + { + 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; + + // If part already has locked lead-ins, don't allow re-placement + if (part.LeadInsLocked) + 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); + profile = new ShapeProfile(entities); + + contours = new List(); + + // Perimeter is always External + if (profile.Perimeter != null) + { + contours.Add(new ShapeInfo + { + Shape = profile.Perimeter, + ContourType = ContourType.External + }); + } + + // Cutouts + foreach (var cutout in profile.Cutouts) + { + contours.Add(new ShapeInfo + { + Shape = cutout, + ContourType = ContourCuttingStrategy.DetectContourType(cutout) + }); + } + } + + private void CommitLeadIn() + { + var parameters = plateView.Plate?.CuttingParameters; + if (parameters == null) + return; + + // Remove any existing lead-ins first + if (selectedPart.HasManualLeadIns) + selectedPart.RemoveLeadIns(); + + // Apply lead-ins using the snap point as the approach point. + // snapPoint is in the program's local coordinate space (rotated, not offset), + // which is what Part.ApplyLeadIns expects. + selectedPart.ApplyLeadIns(parameters, snapPoint); + selectedPart.LeadInsLocked = true; + + // Rebuild the layout part's graphics + selectedLayoutPart.IsDirty = true; + selectedLayoutPart.Update(); + + // Deselect and reset + DeselectPart(); + plateView.Invalidate(); + } + + private void DeselectPart() + { + if (selectedLayoutPart != null) + { + selectedLayoutPart.IsSelected = false; + selectedLayoutPart = null; + } + + selectedPart = null; + profile = null; + contours = null; + hasSnap = false; + 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 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; } + } + } +} diff --git a/OpenNest/Forms/EditNestForm.Designer.cs b/OpenNest/Forms/EditNestForm.Designer.cs index 456b9b8..69924e1 100644 --- a/OpenNest/Forms/EditNestForm.Designer.cs +++ b/OpenNest/Forms/EditNestForm.Designer.cs @@ -39,6 +39,7 @@ this.toolStrip1 = new System.Windows.Forms.ToolStrip(); this.toolStripButton1 = new System.Windows.Forms.ToolStripButton(); this.btnAssignLeadIns = new System.Windows.Forms.ToolStripButton(); + this.btnPlaceLeadIn = new System.Windows.Forms.ToolStripButton(); this.tabPage2 = new System.Windows.Forms.TabPage(); this.drawingListBox1 = new OpenNest.Controls.DrawingListBox(); this.toolStrip2 = new System.Windows.Forms.ToolStrip(); @@ -135,7 +136,8 @@ this.toolStrip1.ImageScalingSize = new System.Drawing.Size(20, 20); this.toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { this.toolStripButton1, - this.btnAssignLeadIns}); + this.btnAssignLeadIns, + this.btnPlaceLeadIn}); this.toolStrip1.Location = new System.Drawing.Point(3, 3); this.toolStrip1.Name = "toolStrip1"; this.toolStrip1.Size = new System.Drawing.Size(227, 31); @@ -163,6 +165,15 @@ this.btnAssignLeadIns.Text = "Assign Lead-ins"; this.btnAssignLeadIns.Click += new System.EventHandler(this.AssignLeadIns_Click); // + // btnPlaceLeadIn + // + this.btnPlaceLeadIn.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnPlaceLeadIn.ImageTransparentColor = System.Drawing.Color.Magenta; + this.btnPlaceLeadIn.Name = "btnPlaceLeadIn"; + this.btnPlaceLeadIn.Size = new System.Drawing.Size(90, 28); + this.btnPlaceLeadIn.Text = "Place Lead-in"; + this.btnPlaceLeadIn.Click += new System.EventHandler(this.PlaceLeadIn_Click); + // // tabPage2 // this.tabPage2.Controls.Add(this.drawingListBox1); @@ -278,5 +289,6 @@ private System.Windows.Forms.ToolStripSeparator toolStripSeparator1; private System.Windows.Forms.ToolStripButton toolStripButton3; private System.Windows.Forms.ToolStripButton btnAssignLeadIns; + private System.Windows.Forms.ToolStripButton btnPlaceLeadIn; } } \ No newline at end of file diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 0ad53d0..082fd1f 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -738,6 +738,26 @@ namespace OpenNest.Forms PlateView.Invalidate(); } + private void PlaceLeadIn_Click(object sender, EventArgs e) + { + if (PlateView?.Plate == null) + return; + + var plate = PlateView.Plate; + + // Ensure cutting parameters are configured + if (plate.CuttingParameters == null) + { + using var form = new CuttingParametersForm(); + if (form.ShowDialog(this) != DialogResult.OK) + return; + + plate.CuttingParameters = form.BuildParameters(); + } + + PlateView.SetAction(typeof(Actions.ActionLeadIn)); + } + private void ImportDrawings_Click(object sender, EventArgs e) { Import();