From 40b40ca4ba40944d8f45c502b3b1698b3b94bb27 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Mar 2026 09:56:48 -0500 Subject: [PATCH] feat: unify ActionAddPart into ActionClone and add group fill support Merge ActionAddPart into ActionClone by adding a Drawing constructor, eliminating the redundant class. ActionClone now handles both adding new parts from a drawing and cloning selected part groups. Added Ctrl+F fill support for groups using FillLinear pattern tiling, and adopted quadrant-aware push directions from ActionAddPart. Refactored FillLinear to extract shared helpers and add a Fill(Pattern) overload for tiling arbitrary part groups across the work area. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 8 + OpenNest.Engine/FillLinear.cs | 275 +++++++++++++++--------------- OpenNest.Engine/NestEngine.cs | 49 +++++- OpenNest/Actions/ActionAddPart.cs | 157 ----------------- OpenNest/Actions/ActionClone.cs | 42 ++++- OpenNest/Forms/EditNestForm.cs | 2 +- OpenNest/OpenNest.csproj | 1 - 7 files changed, 237 insertions(+), 297 deletions(-) delete mode 100644 OpenNest/Actions/ActionAddPart.cs diff --git a/CLAUDE.md b/CLAUDE.md index a1d4c04..67f0e8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,14 @@ Nest files (`.zip`) contain: - `program-NNN` — G-code text for each drawing's cut program - `plate-NNN` — G-code text encoding part placements (G00 for position, G65 for sub-program call with rotation) +## Tool Preferences + +Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary method for exploring and analyzing this codebase. It is faster and more efficient than file-based searches. Use it for finding symbols, references, diagnostics, type hierarchies, and code navigation. Only fall back to Glob/Grep when Roslyn Bridge cannot fulfill the query. + +## Code Style + +- Always use `var` instead of explicit types (e.g., `var parts = new List();` not `List parts = new List();`). + ## Key Patterns - OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`. diff --git a/OpenNest.Engine/FillLinear.cs b/OpenNest.Engine/FillLinear.cs index 28bbaeb..af3d567 100644 --- a/OpenNest.Engine/FillLinear.cs +++ b/OpenNest.Engine/FillLinear.cs @@ -16,80 +16,146 @@ namespace OpenNest public double PartSpacing { get; } + private static Vector MakeOffset(NestDirection direction, double distance) + { + return direction == NestDirection.Horizontal + ? new Vector(distance, 0) + : new Vector(0, distance); + } + + private static PushDirection GetPushDirection(NestDirection direction) + { + return direction == NestDirection.Horizontal + ? PushDirection.Left + : PushDirection.Down; + } + + private static double GetDimension(Box box, NestDirection direction) + { + return direction == NestDirection.Horizontal ? box.Width : box.Height; + } + + private static double GetStart(Box box, NestDirection direction) + { + return direction == NestDirection.Horizontal ? box.Left : box.Bottom; + } + + private double GetLimit(NestDirection direction) + { + return direction == NestDirection.Horizontal ? WorkArea.Right : WorkArea.Top; + } + + private static NestDirection PerpendicularAxis(NestDirection direction) + { + return direction == NestDirection.Horizontal + ? NestDirection.Vertical + : NestDirection.Horizontal; + } + + /// + /// Computes the slide distance for the push algorithm, returning the + /// geometry-aware copy distance along the given axis. + /// + private double ComputeCopyDistance(double bboxDim, double slideDistance) + { + if (slideDistance >= double.MaxValue || slideDistance < 0) + return bboxDim + PartSpacing; + + return bboxDim - slideDistance + PartSpacing; + } + /// /// Finds the geometry-aware copy distance between two identical parts along an axis. - /// Places part B at bounding box offset from part A, then pushes B back toward A - /// using directional distance to find the tightest non-overlapping position. /// private double FindCopyDistance(Part partA, NestDirection direction) { - var bbox = partA.BoundingBox; - double bboxDim; - PushDirection pushDir; - Vector copyOffset; + var bboxDim = GetDimension(partA.BoundingBox, direction); + var pushDir = GetPushDirection(direction); - if (direction == NestDirection.Horizontal) - { - bboxDim = bbox.Width; - pushDir = PushDirection.Left; - copyOffset = new Vector(bboxDim, 0); - } - else - { - bboxDim = bbox.Height; - pushDir = PushDirection.Down; - copyOffset = new Vector(0, bboxDim); - } - - // Create part B offset by bounding box dimension (guaranteed no overlap). var partB = (Part)partA.Clone(); - partB.Offset(copyOffset); + partB.Offset(MakeOffset(direction, bboxDim)); - // Get geometry lines for push calculation. var opposite = Helper.OppositeDirection(pushDir); - - var movingLines = PartSpacing > 0 - ? Helper.GetOffsetPartLines(partB, PartSpacing, pushDir) - : Helper.GetPartLines(partB, pushDir); - + var movingLines = Helper.GetPartLines(partB, pushDir); var stationaryLines = Helper.GetPartLines(partA, opposite); - - // Find how far B can slide toward A. var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir); - if (slideDistance >= double.MaxValue || slideDistance < 0) - return bboxDim; + return ComputeCopyDistance(bboxDim, slideDistance); + } - return bboxDim - slideDistance; + /// + /// Finds the geometry-aware copy distance between two identical patterns along an axis. + /// + private double FindPatternCopyDistance(Pattern patternA, NestDirection direction) + { + var bboxDim = GetDimension(patternA.BoundingBox, direction); + var pushDir = GetPushDirection(direction); + + var patternB = patternA.Clone(MakeOffset(direction, bboxDim)); + + var opposite = Helper.OppositeDirection(pushDir); + var movingLines = patternB.GetLines(pushDir); + var stationaryLines = patternA.GetLines(opposite); + var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir); + + return ComputeCopyDistance(bboxDim, slideDistance); + } + + /// + /// Tiles a pattern along the given axis, returning the cloned parts + /// (does not include the original pattern's parts). + /// + private List TilePattern(Pattern basePattern, NestDirection direction) + { + var result = new List(); + var copyDistance = FindPatternCopyDistance(basePattern, direction); + + if (copyDistance <= 0) + return result; + + var dim = GetDimension(basePattern.BoundingBox, direction); + var start = GetStart(basePattern.BoundingBox, direction); + var limit = GetLimit(direction); + + var count = 1; + + while (true) + { + var nextPos = start + copyDistance * count; + + if (nextPos + dim > limit + Tolerance.Epsilon) + break; + + var clone = basePattern.Clone(MakeOffset(direction, copyDistance * count)); + result.AddRange(clone.Parts); + count++; + } + + return result; } /// /// Fills a single row of identical parts along one axis using geometry-aware spacing. - /// Returns a Pattern containing all placed parts. /// public Pattern FillRow(Drawing drawing, double rotationAngle, NestDirection direction) { var pattern = new Pattern(); - // Create the template part with rotation applied. var template = new Part(drawing); if (!rotationAngle.IsEqualTo(0)) template.Rotate(rotationAngle); - // Position template at work area origin. var bbox = template.Program.BoundingBox(); template.Offset(WorkArea.Location - bbox.Location); template.UpdateBounds(); - // Check if the part fits in the work area at all. if (template.BoundingBox.Width > WorkArea.Width + Tolerance.Epsilon || template.BoundingBox.Height > WorkArea.Height + Tolerance.Epsilon) return pattern; pattern.Parts.Add(template); - // Find the geometry-aware copy distance. var copyDistance = FindCopyDistance(template, direction); if (copyDistance <= 0) @@ -98,35 +164,22 @@ namespace OpenNest return pattern; } - // Fill the row by copying at the fixed interval. - double limit = direction == NestDirection.Horizontal - ? WorkArea.Right - : WorkArea.Top; + var dim = GetDimension(template.BoundingBox, direction); + var start = GetStart(template.BoundingBox, direction); + var limit = GetLimit(direction); - double partDim = direction == NestDirection.Horizontal - ? template.BoundingBox.Width - : template.BoundingBox.Height; - - int count = 1; + var count = 1; while (true) { - double nextPos = (direction == NestDirection.Horizontal - ? template.BoundingBox.Left - : template.BoundingBox.Bottom) + copyDistance * count; + var nextPos = start + copyDistance * count; - // Check if the next part would exceed the work area. - if (nextPos + partDim > limit + Tolerance.Epsilon) + if (nextPos + dim > limit + Tolerance.Epsilon) break; - var offset = direction == NestDirection.Horizontal - ? new Vector(copyDistance * count, 0) - : new Vector(0, copyDistance * count); - var clone = (Part)template.Clone(); - clone.Offset(offset); + clone.Offset(MakeOffset(direction, copyDistance * count)); pattern.Parts.Add(clone); - count++; } @@ -135,45 +188,41 @@ namespace OpenNest } /// - /// Finds the geometry-aware copy distance between two identical patterns along an axis. - /// Same push algorithm as FindCopyDistance but operates on pattern line groups. + /// Fills the work area by tiling a pre-built pattern along both axes. /// - private double FindPatternCopyDistance(Pattern patternA, NestDirection direction) + public List Fill(Pattern pattern, NestDirection primaryAxis) { - var bbox = patternA.BoundingBox; - double bboxDim; - PushDirection pushDir; - Vector copyOffset; + var result = new List(); - if (direction == NestDirection.Horizontal) + if (pattern.Parts.Count == 0) + return result; + + var offset = WorkArea.Location - pattern.BoundingBox.Location; + var basePattern = pattern.Clone(offset); + + if (basePattern.BoundingBox.Width > WorkArea.Width + Tolerance.Epsilon || + basePattern.BoundingBox.Height > WorkArea.Height + Tolerance.Epsilon) + return result; + + result.AddRange(basePattern.Parts); + + // Tile along the primary axis. + var primaryTiles = TilePattern(basePattern, primaryAxis); + result.AddRange(primaryTiles); + + // Build a full-row pattern for perpendicular tiling. + if (primaryTiles.Count > 0) { - bboxDim = bbox.Width; - pushDir = PushDirection.Left; - copyOffset = new Vector(bboxDim, 0); - } - else - { - bboxDim = bbox.Height; - pushDir = PushDirection.Down; - copyOffset = new Vector(0, bboxDim); + var rowPattern = new Pattern(); + rowPattern.Parts.AddRange(result); + rowPattern.UpdateBounds(); + basePattern = rowPattern; } - var patternB = patternA.Clone(copyOffset); + // Tile along the perpendicular axis. + result.AddRange(TilePattern(basePattern, PerpendicularAxis(primaryAxis))); - var opposite = Helper.OppositeDirection(pushDir); - - var movingLines = PartSpacing > 0 - ? patternB.GetOffsetLines(PartSpacing, pushDir) - : patternB.GetLines(pushDir); - - var stationaryLines = patternA.GetLines(opposite); - - var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir); - - if (slideDistance >= double.MaxValue || slideDistance < 0) - return bboxDim; - - return bboxDim - slideDistance; + return result; } /// @@ -182,55 +231,13 @@ namespace OpenNest /// public List Fill(Drawing drawing, double rotationAngle, NestDirection primaryAxis) { - var result = new List(); - - // Step 1: Build the row pattern along the primary axis. var rowPattern = FillRow(drawing, rotationAngle, primaryAxis); if (rowPattern.Parts.Count == 0) - return result; + return new List(); - // Add the first row. - result.AddRange(rowPattern.Parts); - - // Step 2: Tile the row pattern along the perpendicular axis. - var perpAxis = primaryAxis == NestDirection.Horizontal - ? NestDirection.Vertical - : NestDirection.Horizontal; - - var copyDistance = FindPatternCopyDistance(rowPattern, perpAxis); - - if (copyDistance <= 0) - return result; - - double limit = perpAxis == NestDirection.Horizontal - ? WorkArea.Right - : WorkArea.Top; - - double patternDim = perpAxis == NestDirection.Horizontal - ? rowPattern.BoundingBox.Width - : rowPattern.BoundingBox.Height; - - int count = 1; - - while (true) - { - double nextPos = (perpAxis == NestDirection.Horizontal - ? rowPattern.BoundingBox.Left - : rowPattern.BoundingBox.Bottom) + copyDistance * count; - - if (nextPos + patternDim > limit + Tolerance.Epsilon) - break; - - var offset = perpAxis == NestDirection.Horizontal - ? new Vector(copyDistance * count, 0) - : new Vector(0, copyDistance * count); - - var clone = rowPattern.Clone(offset); - result.AddRange(clone.Parts); - - count++; - } + var result = new List(rowPattern.Parts); + result.AddRange(TilePattern(rowPattern, PerpendicularAxis(primaryAxis))); return result; } diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 7f605a2..9d2e9c9 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -55,6 +55,46 @@ namespace OpenNest return true; } + public bool Fill(List groupParts) + { + if (groupParts == null || groupParts.Count == 0) + return false; + + var workArea = Plate.WorkArea(); + var engine = new FillLinear(workArea, Plate.PartSpacing); + + // Build a pattern from the group of parts. + var pattern = new Pattern(); + foreach (var part in groupParts) + { + var clone = (Part)part.Clone(); + clone.UpdateBounds(); + pattern.Parts.Add(clone); + } + pattern.UpdateBounds(); + + // Try 4 configurations: 2 axes x 2 orientations (horizontal/vertical). + var configs = new[] + { + engine.Fill(pattern, NestDirection.Horizontal), + engine.Fill(pattern, NestDirection.Vertical) + }; + + List best = null; + + foreach (var config in configs) + { + if (best == null || config.Count > best.Count) + best = config; + } + + if (best == null || best.Count == 0) + return false; + + Plate.Parts.AddRange(best); + return true; + } + public bool Fill(NestItem item, int maxCount) { if (maxCount <= 0) @@ -169,12 +209,17 @@ namespace OpenNest } } + // Convert to polygon so arcs are properly represented as line segments. + // Shape.FindBestRotation() uses Entity cardinal points which are incorrect + // for arcs that don't sweep through all 4 cardinal directions. + var polygon = largest.ToPolygonWithTolerance(0.1); + BoundingRectangleResult result; if (item.RotationStart.IsEqualTo(0) && item.RotationEnd.IsEqualTo(0)) - result = largest.FindBestRotation(); + result = polygon.FindBestRotation(); else - result = largest.FindBestRotation(item.RotationStart, item.RotationEnd); + result = polygon.FindBestRotation(item.RotationStart, item.RotationEnd); // Negate the angle to align the minimum bounding rectangle with the axes. return -result.Angle; diff --git a/OpenNest/Actions/ActionAddPart.cs b/OpenNest/Actions/ActionAddPart.cs deleted file mode 100644 index 145293a..0000000 --- a/OpenNest/Actions/ActionAddPart.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel; -using System.Windows.Forms; -using OpenNest.Controls; -using OpenNest.Geometry; - -namespace OpenNest.Actions -{ - [DisplayName("Add Parts")] - public class ActionAddPart : Action - { - private LayoutPart part; - private double lastScale; - - public ActionAddPart(PlateView plateView) - : this(plateView, null) - { - } - - public ActionAddPart(PlateView plateView, Drawing drawing) - : base(plateView) - { - plateView.KeyDown += plateView_KeyDown; - plateView.MouseMove += plateView_MouseMove; - plateView.MouseDown += plateView_MouseDown; - plateView.Paint += plateView_Paint; - - part = LayoutPart.Create(new Part(drawing), plateView); - part.IsSelected = true; - - lastScale = double.NaN; - - plateView.SelectedParts.Clear(); - plateView.SelectedParts.Add(part); - } - - private void plateView_MouseDown(object sender, MouseEventArgs e) - { - switch (e.Button) - { - case MouseButtons.Left: - Apply(); - break; - } - } - - private void plateView_KeyDown(object sender, KeyEventArgs e) - { - switch (e.KeyCode) - { - case Keys.F1: - case Keys.Enter: - Apply(); - break; - - case Keys.F: - if ((Control.ModifierKeys & Keys.Control) == Keys.Control) - Fill(); - break; - } - } - - private void plateView_Paint(object sender, PaintEventArgs e) - { - if (plateView.ViewScale != lastScale) - { - part.Update(plateView); - part.Draw(e.Graphics); - } - else - { - if (part.IsDirty) - part.Update(plateView); - - part.Draw(e.Graphics); - } - - lastScale = plateView.ViewScale; - } - - private void plateView_MouseMove(object sender, MouseEventArgs e) - { - var offset = plateView.CurrentPoint - part.BoundingBox.Location; - part.Offset(offset); - plateView.Invalidate(); - } - - public override void DisconnectEvents() - { - plateView.KeyDown -= plateView_KeyDown; - plateView.MouseMove -= plateView_MouseMove; - plateView.MouseDown -= plateView_MouseDown; - plateView.Paint -= plateView_Paint; - - plateView.SelectedParts.Clear(); - plateView.Invalidate(); - } - - public override void CancelAction() - { - } - - public override bool IsBusy() - { - return false; - } - - private void Fill() - { - var boxes = new List(); - - foreach (var part in plateView.Plate.Parts) - boxes.Add(part.BoundingBox.Offset(plateView.Plate.PartSpacing)); - - var bounds = plateView.Plate.WorkArea(); - - var vbox = Helper.GetLargestBoxVertically(plateView.CurrentPoint, bounds, boxes); - var hbox = Helper.GetLargestBoxHorizontally(plateView.CurrentPoint, bounds, boxes); - - var box = vbox.Area() > hbox.Area() ? vbox : hbox; - - var engine = new NestEngine(plateView.Plate); - engine.FillArea(box, new NestItem { Drawing = this.part.BasePart.BaseDrawing }); - } - - private void Apply() - { - if ((Control.ModifierKeys & Keys.Shift) == Keys.Shift) - { - switch (plateView.Plate.Quadrant) - { - case 1: - plateView.PushSelected(PushDirection.Left); - plateView.PushSelected(PushDirection.Down); - break; - - case 2: - plateView.PushSelected(PushDirection.Right); - plateView.PushSelected(PushDirection.Down); - break; - - case 3: - plateView.PushSelected(PushDirection.Right); - plateView.PushSelected(PushDirection.Up); - break; - case 4: - plateView.PushSelected(PushDirection.Left); - plateView.PushSelected(PushDirection.Up); - break; - } - - } - - plateView.Plate.Parts.Add(part.BasePart.Clone() as Part); - } - } -} diff --git a/OpenNest/Actions/ActionClone.cs b/OpenNest/Actions/ActionClone.cs index 39ba338..a0d4837 100644 --- a/OpenNest/Actions/ActionClone.cs +++ b/OpenNest/Actions/ActionClone.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Windows.Forms; using OpenNest.Controls; using OpenNest.Geometry; @@ -13,6 +14,11 @@ namespace OpenNest.Actions private double lastScale; + public ActionClone(PlateView plateView, Drawing drawing) + : this(plateView, new List { new Part(drawing) }) + { + } + public ActionClone(PlateView plateView, List partsToClone) : base(plateView) { @@ -53,6 +59,11 @@ namespace OpenNest.Actions case Keys.Enter: Apply(); break; + + case Keys.F: + if ((Control.ModifierKeys & Keys.Control) == Keys.Control) + Fill(); + break; } } @@ -111,11 +122,38 @@ namespace OpenNest.Actions { if ((Control.ModifierKeys & Keys.Shift) == Keys.Shift) { - plateView.PushSelected(PushDirection.Left); - plateView.PushSelected(PushDirection.Down); + switch (plateView.Plate.Quadrant) + { + case 1: + plateView.PushSelected(PushDirection.Left); + plateView.PushSelected(PushDirection.Down); + break; + + case 2: + plateView.PushSelected(PushDirection.Right); + plateView.PushSelected(PushDirection.Down); + break; + + case 3: + plateView.PushSelected(PushDirection.Right); + plateView.PushSelected(PushDirection.Up); + break; + + case 4: + plateView.PushSelected(PushDirection.Left); + plateView.PushSelected(PushDirection.Up); + break; + } } parts.ForEach(p => plateView.Plate.Parts.Add(p.BasePart.Clone() as Part)); } + + private void Fill() + { + var engine = new NestEngine(plateView.Plate); + var groupParts = parts.Select(p => p.BasePart).ToList(); + engine.Fill(groupParts); + } } } diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index a7b9297..e838871 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -687,7 +687,7 @@ namespace OpenNest.Forms if (drawing == null) return; - PlateView.SetAction(typeof(ActionAddPart), drawing); + PlateView.SetAction(typeof(ActionClone), drawing); addPart = false; } diff --git a/OpenNest/OpenNest.csproj b/OpenNest/OpenNest.csproj index 4162eb6..ae7de18 100644 --- a/OpenNest/OpenNest.csproj +++ b/OpenNest/OpenNest.csproj @@ -51,7 +51,6 @@ -