From 2216b8553fac3938fdb6b920d1d02ae1e368b3c1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 07:24:45 -0400 Subject: [PATCH] docs: add implementation plans for best-fit viewer, pair finding, and .NET 8 migration Co-Authored-By: Claude Opus 4.6 --- .../2026-03-07-best-fit-viewer-design.md | 378 ++++++ docs/plans/2026-03-07-bestfit-pair-finding.md | 963 ++++++++++++++++ docs/plans/2026-03-07-net8-migration.md | 1024 +++++++++++++++++ 3 files changed, 2365 insertions(+) create mode 100644 docs/plans/2026-03-07-best-fit-viewer-design.md create mode 100644 docs/plans/2026-03-07-bestfit-pair-finding.md create mode 100644 docs/plans/2026-03-07-net8-migration.md diff --git a/docs/plans/2026-03-07-best-fit-viewer-design.md b/docs/plans/2026-03-07-best-fit-viewer-design.md new file mode 100644 index 0000000..7f27cdb --- /dev/null +++ b/docs/plans/2026-03-07-best-fit-viewer-design.md @@ -0,0 +1,378 @@ +# Best-Fit Viewer Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a BestFitViewerForm that shows all pair candidates in a dense 5-column grid with metadata overlay, similar to PEP's best-fit viewer. + +**Architecture:** A modal `Form` with a scrollable `TableLayoutPanel` (5 columns). Each cell is a read-only `PlateView` with the pair's two parts placed on it. Metadata is painted as overlay text on each cell. Dropped candidates use a different background color. Invoked from Tools menu when a drawing and plate are available. + +**Tech Stack:** WinForms, `BestFitFinder` from `OpenNest.Engine.BestFit`, `PlateView` control. + +--- + +### Task 1: Extract BuildPairParts to a static helper + +`NestEngine.BuildPairParts` is private and contains the pair-building logic we need. Extract it to a public static method so both `NestEngine` and the new form can use it. + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` + +**Step 1: Make BuildPairParts internal static** + +In `OpenNest.Engine/NestEngine.cs`, change the method signature from private instance to internal static. It doesn't use any instance state — only `BestFitResult` and `Drawing` parameters. + +Change: +```csharp +private List BuildPairParts(BestFitResult bestFit, Drawing drawing) +``` +To: +```csharp +internal static List BuildPairParts(BestFitResult bestFit, Drawing drawing) +``` + +**Step 2: Build and verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds with no errors. + +**Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "refactor: make BuildPairParts internal static for reuse" +``` + +--- + +### Task 2: Create BestFitViewerForm + +**Files:** +- Create: `OpenNest/Forms/BestFitViewerForm.cs` +- Create: `OpenNest/Forms/BestFitViewerForm.Designer.cs` + +**Step 1: Create the Designer file** + +Create `OpenNest/Forms/BestFitViewerForm.Designer.cs` with a `TableLayoutPanel` (5 columns, auto-scroll, dock-fill) inside the form. Form should be sizable, start centered on parent, ~1200x800 default size, title "Best-Fit Viewer". + +```csharp +namespace OpenNest.Forms +{ + partial class BestFitViewerForm + { + private System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + components.Dispose(); + base.Dispose(disposing); + } + + private void InitializeComponent() + { + this.gridPanel = new System.Windows.Forms.TableLayoutPanel(); + this.SuspendLayout(); + // + // gridPanel + // + this.gridPanel.AutoScroll = true; + this.gridPanel.ColumnCount = 5; + this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); + this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); + this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); + this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); + this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); + this.gridPanel.Dock = System.Windows.Forms.DockStyle.Fill; + this.gridPanel.Location = new System.Drawing.Point(0, 0); + this.gridPanel.Name = "gridPanel"; + this.gridPanel.RowCount = 1; + this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.gridPanel.Size = new System.Drawing.Size(1200, 800); + this.gridPanel.TabIndex = 0; + // + // BestFitViewerForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(1200, 800); + this.Controls.Add(this.gridPanel); + this.KeyPreview = true; + this.Name = "BestFitViewerForm"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Best-Fit Viewer"; + this.ResumeLayout(false); + } + + private System.Windows.Forms.TableLayoutPanel gridPanel; + } +} +``` + +**Step 2: Create the code-behind file** + +Create `OpenNest/Forms/BestFitViewerForm.cs`. The constructor takes a `Drawing` and a `Plate`. It calls `BestFitFinder.FindBestFits()` to get all candidates, then for each result: +1. Creates a `PlateView` configured read-only (no pan/zoom/select/origin, no plate outline) +2. Sizes the PlateView's plate to the pair bounding box +3. Builds pair parts via `NestEngine.BuildPairParts()` and adds them to the plate +4. Sets background color based on `Keep` (kept = default, dropped = maroon) +5. Subscribes to `Paint` to overlay metadata text + +```csharp +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Windows.Forms; +using OpenNest.Controls; +using OpenNest.Engine.BestFit; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Forms +{ + public partial class BestFitViewerForm : Form + { + private static readonly Color KeptColor = Color.FromArgb(0, 0, 100); + private static readonly Color DroppedColor = Color.FromArgb(100, 0, 0); + + public BestFitViewerForm(Drawing drawing, Plate plate) + { + InitializeComponent(); + PopulateGrid(drawing, plate); + } + + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + if (keyData == Keys.Escape) + { + Close(); + return true; + } + return base.ProcessCmdKey(ref msg, keyData); + } + + private void PopulateGrid(Drawing drawing, Plate plate) + { + var finder = new BestFitFinder(plate.Size.Width, plate.Size.Height); + var results = finder.FindBestFits(drawing, plate.PartSpacing); + + var rows = (int)System.Math.Ceiling(results.Count / 5.0); + gridPanel.RowCount = rows; + gridPanel.RowStyles.Clear(); + + for (var i = 0; i < rows; i++) + gridPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 200)); + + for (var i = 0; i < results.Count; i++) + { + var result = results[i]; + var view = CreateCellView(result, drawing); + gridPanel.Controls.Add(view, i % 5, i / 5); + } + } + + private PlateView CreateCellView(BestFitResult result, Drawing drawing) + { + var bgColor = result.Keep ? KeptColor : DroppedColor; + + var colorScheme = new ColorScheme + { + BackgroundColor = bgColor, + LayoutOutlineColor = bgColor, + LayoutFillColor = bgColor, + BoundingBoxColor = bgColor, + RapidColor = Color.DodgerBlue, + OriginColor = bgColor, + EdgeSpacingColor = bgColor + }; + + var view = new PlateView(colorScheme); + view.DrawOrigin = false; + view.DrawBounds = false; + view.AllowPan = false; + view.AllowSelect = false; + view.AllowZoom = false; + view.AllowDrop = false; + view.Dock = DockStyle.Fill; + view.Plate.Size = new Geometry.Size( + result.BoundingWidth, + result.BoundingHeight); + + var parts = NestEngine.BuildPairParts(result, drawing); + + foreach (var part in parts) + view.Plate.Parts.Add(part); + + view.Paint += (sender, e) => + { + PaintMetadata(e.Graphics, view, result); + }; + + view.HandleCreated += (sender, e) => + { + view.ZoomToFit(true); + }; + + return view; + } + + private void PaintMetadata(Graphics g, PlateView view, BestFitResult result) + { + var font = view.Font; + var brush = Brushes.White; + var y = 2f; + var lineHeight = font.GetHeight(g) + 1; + + var lines = new[] + { + string.Format("RotatedArea={0:F4}", result.RotatedArea), + string.Format("{0:F4}x{1:F4}={2:F4}", + result.BoundingWidth, result.BoundingHeight, result.RotatedArea), + string.Format("Why={0}", result.Keep ? "0" : result.Reason), + string.Format("Type={0} Test={1} Spacing={2}", + result.Candidate.StrategyType, + result.Candidate.TestNumber, + result.Candidate.Spacing), + string.Format("Util={0:P0} Rot={1:F1}°", + result.Utilization, + Angle.ToDegrees(result.OptimalRotation)) + }; + + foreach (var line in lines) + { + g.DrawString(line, font, brush, 2, y); + y += lineHeight; + } + } + } +} +``` + +**Step 3: Build and verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds. + +**Step 4: Commit** + +```bash +git add OpenNest/Forms/BestFitViewerForm.cs OpenNest/Forms/BestFitViewerForm.Designer.cs +git commit -m "feat: add BestFitViewerForm with pair candidate grid" +``` + +--- + +### Task 3: Add menu item to MainForm + +**Files:** +- Modify: `OpenNest/Forms/MainForm.Designer.cs` +- Modify: `OpenNest/Forms/MainForm.cs` + +**Step 1: Add the menu item field and wire it up in Designer** + +In `MainForm.Designer.cs`: + +1. Add field declaration near the other `mnuTools*` fields (~line 1198): +```csharp +private System.Windows.Forms.ToolStripMenuItem mnuToolsBestFitViewer; +``` + +2. Add instantiation in `InitializeComponent()` near other mnuTools instantiations (~line 62): +```csharp +this.mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem(); +``` + +3. Add to the Tools menu `DropDownItems` array (after `mnuToolsMeasureArea`, ~line 413-420). Insert `mnuToolsBestFitViewer` before the `toolStripMenuItem14` separator: +```csharp +this.mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { +this.mnuToolsMeasureArea, +this.mnuToolsBestFitViewer, +this.mnuToolsAlign, +this.toolStripMenuItem14, +this.mnuSetOffsetIncrement, +this.mnuSetRotationIncrement, +this.toolStripMenuItem15, +this.mnuToolsOptions}); +``` + +4. Add menu item configuration after the `mnuToolsMeasureArea` block (~line 431): +```csharp +// +// mnuToolsBestFitViewer +// +this.mnuToolsBestFitViewer.Name = "mnuToolsBestFitViewer"; +this.mnuToolsBestFitViewer.Size = new System.Drawing.Size(214, 22); +this.mnuToolsBestFitViewer.Text = "Best-Fit Viewer"; +this.mnuToolsBestFitViewer.Click += new System.EventHandler(this.BestFitViewer_Click); +``` + +**Step 2: Add the click handler in MainForm.cs** + +Add a method to `MainForm.cs` that opens the form. It needs the active `EditNestForm` to get the current plate and a selected drawing. If no drawing is available from the selected plate's parts, show a message. + +```csharp +private void BestFitViewer_Click(object sender, EventArgs e) +{ + if (activeForm == null) + return; + + var plate = activeForm.PlateView.Plate; + var drawing = activeForm.Nest.Drawings.Count > 0 + ? activeForm.Nest.Drawings[0] + : null; + + if (drawing == null) + { + MessageBox.Show("No drawings available.", "Best-Fit Viewer", + MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + using (var form = new BestFitViewerForm(drawing, plate)) + { + form.ShowDialog(this); + } +} +``` + +**Step 3: Build and verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds. + +**Step 4: Commit** + +```bash +git add OpenNest/Forms/MainForm.Designer.cs OpenNest/Forms/MainForm.cs +git commit -m "feat: add Best-Fit Viewer menu item under Tools" +``` + +--- + +### Task 4: Manual smoke test + +**Step 1: Run the application** + +Run: `dotnet run --project OpenNest` + +**Step 2: Test the flow** + +1. Open or create a nest file +2. Import a DXF drawing +3. Go to Tools > Best-Fit Viewer +4. Verify the grid appears with pair candidates +5. Verify kept candidates have dark blue background +6. Verify dropped candidates have dark red/maroon background +7. Verify metadata text is readable on each cell +8. Verify ESC closes the dialog +9. Verify scroll works when many results exist + +**Step 3: Fix any visual issues** + +Adjust cell heights, font sizes, or zoom-to-fit timing if needed. + +**Step 4: Final commit** + +```bash +git add -A +git commit -m "fix: polish BestFitViewerForm layout and appearance" +``` diff --git a/docs/plans/2026-03-07-bestfit-pair-finding.md b/docs/plans/2026-03-07-bestfit-pair-finding.md new file mode 100644 index 0000000..e897620 --- /dev/null +++ b/docs/plans/2026-03-07-bestfit-pair-finding.md @@ -0,0 +1,963 @@ +# Best-Fit Pair Finding Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a pair-finding engine that arranges two copies of a part in the tightest configuration, then tiles that pair across a plate. + +**Architecture:** Strategy pattern where `RotationSlideStrategy` instances (parameterized by angle) generate candidate pair configurations by sliding one part against another using existing raycast collision. A `PairEvaluator` scores candidates by bounding area, a `BestFitFilter` prunes bad fits, and a `TileEvaluator` simulates tiling the best pairs onto a plate. + +**Tech Stack:** .NET Framework 4.8, C# 7.3, OpenNest.Engine (class library referencing OpenNest.Core) + +--- + +## Important Context + +### Codebase Conventions +- **All angles are in radians** — use `Angle.ToRadians()`, `Angle.HalfPI`, `Angle.TwoPI` +- **Always use `var`** instead of explicit types +- **`OpenNest.Math` shadows `System.Math`** — use `System.Math` fully qualified +- **Legacy `.csproj`** — every new `.cs` file must be added to `OpenNest.Engine.csproj` `` items +- **No test project exists** — skip TDD steps, verify by building + +### Key Existing Types +- `Vector` (struct, `OpenNest.Geometry`) — 2D point, has `Rotate()`, `Offset()`, `DistanceTo()`, operators +- `Box` (class, `OpenNest.Geometry`) — AABB with `Left/Right/Top/Bottom/Width/Height`, `Contains()`, `Intersects()` +- `Part` (class, `OpenNest`) — wraps `Drawing` + `Program`, has `Location`, `Rotation`, `Rotate()`, `Offset()`, `Clone()`, `BoundingBox` +- `Drawing` (class, `OpenNest`) — has `Program`, `Area`, `Name` +- `Program` (class, `OpenNest.CNC`) — G-code program, has `BoundingBox()`, `Rotate()`, `Clone()` +- `Plate` (class, `OpenNest`) — has `Size` (Width/Height), `EdgeSpacing`, `PartSpacing`, `WorkArea()` +- `Shape` (class, `OpenNest.Geometry`) — closed contour, has `Intersects(Shape)`, `Area()`, `ToPolygon()`, `OffsetEntity()` +- `Polygon` (class, `OpenNest.Geometry`) — vertex list, has `FindBestRotation()`, `Rotate()`, `Offset()` +- `ConvexHull.Compute(IList)` — returns closed `Polygon` +- `BoundingRectangleResult` — `Angle`, `Width`, `Height`, `Area` from rotating calipers + +### Key Existing Methods (in `Helper`) +- `Helper.GetShapes(IEnumerable)` — builds `Shape` list from geometry entities +- `Helper.GetPartLines(Part, PushDirection)` — gets polygon edges facing a direction (uses chord tolerance 0.01) +- `Helper.DirectionalDistance(movingLines, stationaryLines, PushDirection)` — raycasts to find minimum contact distance +- `Helper.OppositeDirection(PushDirection)` — flips direction +- `ConvertProgram.ToGeometry(Program)` — converts CNC program to geometry entities + +### How Existing Push/Contact Works (in `FillLinear`) +``` +1. Create partA at position +2. Clone to partB, offset by bounding box dimension along axis +3. Get facing lines: movingLines = GetPartLines(partB, pushDir) +4. Get facing lines: stationaryLines = GetPartLines(partA, oppositeDir) +5. slideDistance = DirectionalDistance(movingLines, stationaryLines, pushDir) +6. copyDistance = bboxDim - slideDistance + spacing +``` +The best-fit system adapts this: part2 is rotated, offset perpendicular to the push axis, then pushed toward part1. + +### Hull Edge Angles (existing pattern in `NestEngine`) +``` +1. Convert part to polygon via ConvertProgram.ToGeometry → GetShapes → ToPolygonWithTolerance +2. Compute convex hull via ConvexHull.Compute(vertices) +3. Extract edge angles: atan2(dy, dx) for each hull edge +4. Deduplicate angles (within Tolerance.Epsilon) +``` + +--- + +## Task 1: PairCandidate Data Class + +**Files:** +- Create: `OpenNest.Engine/BestFit/PairCandidate.cs` +- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` (add Compile entry) + +**Step 1: Create directory and file** + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.Engine.BestFit +{ + public class PairCandidate + { + public Drawing Drawing { get; set; } + public double Part1Rotation { get; set; } + public double Part2Rotation { get; set; } + public Vector Part2Offset { get; set; } + public int StrategyType { get; set; } + public int TestNumber { get; set; } + public double Spacing { get; set; } + } +} +``` + +**Step 2: Add to .csproj** + +Add inside the `` that contains `` entries, before ``: +```xml + +``` + +**Step 3: Build to verify** + +Run: `msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q` +Expected: Build succeeded + +**Step 4: Commit** + +``` +feat: add PairCandidate data class for best-fit pair finding +``` + +--- + +## Task 2: BestFitResult Data Class + +**Files:** +- Create: `OpenNest.Engine/BestFit/BestFitResult.cs` +- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` + +**Step 1: Create file** + +```csharp +namespace OpenNest.Engine.BestFit +{ + public class BestFitResult + { + public PairCandidate Candidate { get; set; } + public double RotatedArea { get; set; } + public double BoundingWidth { get; set; } + public double BoundingHeight { get; set; } + public double OptimalRotation { get; set; } + public bool Keep { get; set; } + public string Reason { get; set; } + public double TrueArea { get; set; } + + public double Utilization + { + get { return RotatedArea > 0 ? TrueArea / RotatedArea : 0; } + } + + public double LongestSide + { + get { return System.Math.Max(BoundingWidth, BoundingHeight); } + } + + public double ShortestSide + { + get { return System.Math.Min(BoundingWidth, BoundingHeight); } + } + } + + public enum BestFitSortField + { + Area, + LongestSide, + ShortestSide, + Type, + OriginalSequence, + Keep, + WhyKeepDrop + } +} +``` + +**Step 2: Add to .csproj** + +```xml + +``` + +**Step 3: Build to verify** + +Run: `msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q` + +**Step 4: Commit** + +``` +feat: add BestFitResult data class and BestFitSortField enum +``` + +--- + +## Task 3: IBestFitStrategy Interface + +**Files:** +- Create: `OpenNest.Engine/BestFit/IBestFitStrategy.cs` +- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` + +**Step 1: Create file** + +```csharp +using System.Collections.Generic; + +namespace OpenNest.Engine.BestFit +{ + public interface IBestFitStrategy + { + int Type { get; } + string Description { get; } + List GenerateCandidates(Drawing drawing, double spacing, double stepSize); + } +} +``` + +**Step 2: Add to .csproj** + +```xml + +``` + +**Step 3: Build to verify** + +**Step 4: Commit** + +``` +feat: add IBestFitStrategy interface +``` + +--- + +## Task 4: RotationSlideStrategy + +This is the core algorithm. It generates pair candidates by: +1. Creating part1 at origin +2. Creating part2 with a specific rotation +3. For each push direction (Left, Down): + - For each perpendicular offset (stepping across the part): + - Place part2 far away along the push axis + - Use `DirectionalDistance` to find contact + - Record position as a candidate + +**Files:** +- Create: `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` +- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` + +**Step 1: Create file** + +```csharp +using System; +using System.Collections.Generic; +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.BestFit +{ + public class RotationSlideStrategy : IBestFitStrategy + { + private const double ChordTolerance = 0.01; + + public RotationSlideStrategy(double part2Rotation, int type, string description) + { + Part2Rotation = part2Rotation; + Type = type; + Description = description; + } + + public double Part2Rotation { get; } + public int Type { get; } + public string Description { get; } + + public List GenerateCandidates(Drawing drawing, double spacing, double stepSize) + { + var candidates = new List(); + + var part1 = new Part(drawing); + var bbox1 = part1.Program.BoundingBox(); + part1.Offset(-bbox1.Location.X, -bbox1.Location.Y); + part1.UpdateBounds(); + + var part2Template = new Part(drawing); + if (!Part2Rotation.IsEqualTo(0)) + part2Template.Rotate(Part2Rotation); + var bbox2 = part2Template.Program.BoundingBox(); + part2Template.Offset(-bbox2.Location.X, -bbox2.Location.Y); + part2Template.UpdateBounds(); + + var testNumber = 0; + + // Slide along horizontal axis (push left toward part1) + GenerateCandidatesForAxis( + part1, part2Template, drawing, spacing, stepSize, + PushDirection.Left, candidates, ref testNumber); + + // Slide along vertical axis (push down toward part1) + GenerateCandidatesForAxis( + part1, part2Template, drawing, spacing, stepSize, + PushDirection.Down, candidates, ref testNumber); + + return candidates; + } + + private void GenerateCandidatesForAxis( + Part part1, Part part2Template, Drawing drawing, + double spacing, double stepSize, PushDirection pushDir, + List candidates, ref int testNumber) + { + var bbox1 = part1.BoundingBox; + var bbox2 = part2Template.BoundingBox; + + // Determine perpendicular range based on push direction + double perpMin, perpMax, pushStartOffset; + bool isHorizontalPush = (pushDir == PushDirection.Left || pushDir == PushDirection.Right); + + if (isHorizontalPush) + { + // Pushing horizontally: perpendicular axis is Y + perpMin = -(bbox2.Height + spacing); + perpMax = bbox1.Height + bbox2.Height + spacing; + pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2; + } + else + { + // Pushing vertically: perpendicular axis is X + perpMin = -(bbox2.Width + spacing); + perpMax = bbox1.Width + bbox2.Width + spacing; + pushStartOffset = bbox1.Height + bbox2.Height + spacing * 2; + } + + var part1Lines = Helper.GetOffsetPartLines(part1, spacing / 2); + var opposite = Helper.OppositeDirection(pushDir); + + for (var offset = perpMin; offset <= perpMax; offset += stepSize) + { + var part2 = (Part)part2Template.Clone(); + + if (isHorizontalPush) + part2.Offset(pushStartOffset, offset); + else + part2.Offset(offset, pushStartOffset); + + var movingLines = Helper.GetOffsetPartLines(part2, spacing / 2); + var slideDist = Helper.DirectionalDistance(movingLines, part1Lines, pushDir); + + if (slideDist >= double.MaxValue || slideDist < 0) + continue; + + // Move part2 to contact position + var contactOffset = GetPushVector(pushDir, slideDist); + var finalPosition = part2.Location + contactOffset; + + candidates.Add(new PairCandidate + { + Drawing = drawing, + Part1Rotation = 0, + Part2Rotation = Part2Rotation, + Part2Offset = finalPosition, + StrategyType = Type, + TestNumber = testNumber++, + Spacing = spacing + }); + } + } + + private static Vector GetPushVector(PushDirection direction, double distance) + { + switch (direction) + { + case PushDirection.Left: return new Vector(-distance, 0); + case PushDirection.Right: return new Vector(distance, 0); + case PushDirection.Down: return new Vector(0, -distance); + case PushDirection.Up: return new Vector(0, distance); + default: return Vector.Zero; + } + } + } +} +``` + +**Step 2: Add to .csproj** + +```xml + +``` + +**Step 3: Build to verify** + +Run: `msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q` + +**Step 4: Commit** + +``` +feat: add RotationSlideStrategy with directional push contact algorithm +``` + +--- + +## Task 5: PairEvaluator + +Scores each candidate by computing the combined bounding box, finding the optimal rotation (via rotating calipers on the convex hull), and checking for overlaps. + +**Files:** +- Create: `OpenNest.Engine/BestFit/PairEvaluator.cs` +- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` + +**Step 1: Create file** + +```csharp +using System.Collections.Generic; +using System.Linq; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.BestFit +{ + public class PairEvaluator + { + private const double ChordTolerance = 0.01; + + public BestFitResult Evaluate(PairCandidate candidate) + { + var drawing = candidate.Drawing; + + // Build part1 at origin + var part1 = new Part(drawing); + var bbox1 = part1.Program.BoundingBox(); + part1.Offset(-bbox1.Location.X, -bbox1.Location.Y); + part1.UpdateBounds(); + + // Build part2 with rotation and offset + var part2 = new Part(drawing); + if (!candidate.Part2Rotation.IsEqualTo(0)) + part2.Rotate(candidate.Part2Rotation); + var bbox2 = part2.Program.BoundingBox(); + part2.Offset(-bbox2.Location.X, -bbox2.Location.Y); + part2.Location = candidate.Part2Offset; + part2.UpdateBounds(); + + // Check overlap via shape intersection + var overlaps = CheckOverlap(part1, part2, candidate.Spacing); + + // Collect all polygon vertices for convex hull / optimal rotation + var allPoints = GetPartVertices(part1); + allPoints.AddRange(GetPartVertices(part2)); + + // Find optimal bounding rectangle via rotating calipers + double bestArea, bestWidth, bestHeight, bestRotation; + + if (allPoints.Count >= 3) + { + var hull = ConvexHull.Compute(allPoints); + var result = RotatingCalipers.MinimumBoundingRectangle(hull); + bestArea = result.Area; + bestWidth = result.Width; + bestHeight = result.Height; + bestRotation = result.Angle; + } + else + { + var combinedBox = ((IEnumerable)new[] { part1, part2 }).GetBoundingBox(); + bestArea = combinedBox.Area(); + bestWidth = combinedBox.Width; + bestHeight = combinedBox.Height; + bestRotation = 0; + } + + var trueArea = drawing.Area * 2; + + return new BestFitResult + { + Candidate = candidate, + RotatedArea = bestArea, + BoundingWidth = bestWidth, + BoundingHeight = bestHeight, + OptimalRotation = bestRotation, + TrueArea = trueArea, + Keep = !overlaps, + Reason = overlaps ? "Overlap detected" : "Valid" + }; + } + + private bool CheckOverlap(Part part1, Part part2, double spacing) + { + var shapes1 = GetPartShapes(part1); + var shapes2 = GetPartShapes(part2); + + for (var i = 0; i < shapes1.Count; i++) + { + for (var j = 0; j < shapes2.Count; j++) + { + List pts; + + if (shapes1[i].Intersects(shapes2[j], out pts)) + return true; + } + } + + return false; + } + + private List GetPartShapes(Part part) + { + var entities = ConvertProgram.ToGeometry(part.Program) + .Where(e => e.Layer != SpecialLayers.Rapid); + var shapes = Helper.GetShapes(entities); + shapes.ForEach(s => s.Offset(part.Location)); + return shapes; + } + + private List GetPartVertices(Part part) + { + var entities = ConvertProgram.ToGeometry(part.Program) + .Where(e => e.Layer != SpecialLayers.Rapid); + var shapes = Helper.GetShapes(entities); + var points = new List(); + + foreach (var shape in shapes) + { + var polygon = shape.ToPolygonWithTolerance(ChordTolerance); + polygon.Offset(part.Location); + + foreach (var vertex in polygon.Vertices) + points.Add(vertex); + } + + return points; + } + } +} +``` + +**Step 2: Add to .csproj** + +```xml + +``` + +**Step 3: Build to verify** + +**Step 4: Commit** + +``` +feat: add PairEvaluator with overlap detection and optimal rotation +``` + +--- + +## Task 6: BestFitFilter + +**Files:** +- Create: `OpenNest.Engine/BestFit/BestFitFilter.cs` +- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` + +**Step 1: Create file** + +```csharp +using System.Collections.Generic; + +namespace OpenNest.Engine.BestFit +{ + public class BestFitFilter + { + public double MaxPlateWidth { get; set; } + public double MaxPlateHeight { get; set; } + public double MaxAspectRatio { get; set; } = 5.0; + public double MinUtilization { get; set; } = 0.3; + + public void Apply(List results) + { + foreach (var result in results) + { + if (!result.Keep) + continue; + + if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight)) + { + result.Keep = false; + result.Reason = "Exceeds plate dimensions"; + continue; + } + + var aspect = result.LongestSide / result.ShortestSide; + + if (aspect > MaxAspectRatio) + { + result.Keep = false; + result.Reason = string.Format("Aspect ratio {0:F1} exceeds max {1}", aspect, MaxAspectRatio); + continue; + } + + if (result.Utilization < MinUtilization) + { + result.Keep = false; + result.Reason = string.Format("Utilization {0:P0} below minimum", result.Utilization); + continue; + } + + result.Reason = "Valid"; + } + } + } +} +``` + +**Step 2: Add to .csproj** + +```xml + +``` + +**Step 3: Build to verify** + +**Step 4: Commit** + +``` +feat: add BestFitFilter with plate size, aspect ratio, and utilization rules +``` + +--- + +## Task 7: TileResult and TileEvaluator + +**Files:** +- Create: `OpenNest.Engine/BestFit/Tiling/TileResult.cs` +- Create: `OpenNest.Engine/BestFit/Tiling/TileEvaluator.cs` +- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` + +**Step 1: Create TileResult.cs** + +```csharp +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.BestFit.Tiling +{ + public class TileResult + { + public BestFitResult BestFit { get; set; } + public int PairsNested { get; set; } + public int PartsNested { get; set; } + public int Rows { get; set; } + public int Columns { get; set; } + public double Utilization { get; set; } + public List Placements { get; set; } + public bool PairRotated { get; set; } + } + + public class PairPlacement + { + public Vector Position { get; set; } + public double PairRotation { get; set; } + } +} +``` + +**Step 2: Create TileEvaluator.cs** + +```csharp +using System; +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.BestFit.Tiling +{ + public class TileEvaluator + { + public TileResult Evaluate(BestFitResult bestFit, Plate plate) + { + var plateWidth = plate.Size.Width - plate.EdgeSpacing.Left - plate.EdgeSpacing.Right; + var plateHeight = plate.Size.Height - plate.EdgeSpacing.Top - plate.EdgeSpacing.Bottom; + + var result1 = TryTile(bestFit, plateWidth, plateHeight, false); + var result2 = TryTile(bestFit, plateWidth, plateHeight, true); + return result1.PartsNested >= result2.PartsNested ? result1 : result2; + } + + private TileResult TryTile(BestFitResult bestFit, double plateWidth, double plateHeight, bool rotatePair) + { + var pairWidth = rotatePair ? bestFit.BoundingHeight : bestFit.BoundingWidth; + var pairHeight = rotatePair ? bestFit.BoundingWidth : bestFit.BoundingHeight; + var spacing = bestFit.Candidate.Spacing; + + var cols = (int)System.Math.Floor((plateWidth + spacing) / (pairWidth + spacing)); + var rows = (int)System.Math.Floor((plateHeight + spacing) / (pairHeight + spacing)); + var pairsNested = cols * rows; + var partsNested = pairsNested * 2; + + var usedArea = partsNested * (bestFit.TrueArea / 2); + var plateArea = plateWidth * plateHeight; + + var placements = new List(); + + for (var row = 0; row < rows; row++) + { + for (var col = 0; col < cols; col++) + { + placements.Add(new PairPlacement + { + Position = new Vector( + col * (pairWidth + spacing), + row * (pairHeight + spacing)), + PairRotation = rotatePair ? Angle.HalfPI : 0 + }); + } + } + + return new TileResult + { + BestFit = bestFit, + PairsNested = pairsNested, + PartsNested = partsNested, + Rows = rows, + Columns = cols, + Utilization = plateArea > 0 ? usedArea / plateArea : 0, + Placements = placements, + PairRotated = rotatePair + }; + } + } +} +``` + +**Step 3: Add to .csproj** + +```xml + + +``` + +**Step 4: Build to verify** + +**Step 5: Commit** + +``` +feat: add TileEvaluator and TileResult for pair tiling on plates +``` + +--- + +## Task 8: BestFitFinder (Orchestrator) + +Computes hull edge angles from the drawing, builds `RotationSlideStrategy` instances for each angle in `{0, pi/2, pi, 3pi/2} + hull edges + hull edges + pi`, runs all strategies, evaluates, filters, and sorts. + +**Files:** +- Create: `OpenNest.Engine/BestFit/BestFitFinder.cs` +- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` + +**Step 1: Create file** + +```csharp +using System.Collections.Generic; +using System.Linq; +using OpenNest.Converters; +using OpenNest.Engine.BestFit.Tiling; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.BestFit +{ + public class BestFitFinder + { + private readonly PairEvaluator _evaluator; + private readonly BestFitFilter _filter; + + public BestFitFinder(double maxPlateWidth, double maxPlateHeight) + { + _evaluator = new PairEvaluator(); + _filter = new BestFitFilter + { + MaxPlateWidth = maxPlateWidth, + MaxPlateHeight = maxPlateHeight + }; + } + + public List FindBestFits( + Drawing drawing, + double spacing = 0.25, + double stepSize = 0.25, + BestFitSortField sortBy = BestFitSortField.Area) + { + var strategies = BuildStrategies(drawing); + + var allCandidates = new List(); + + foreach (var strategy in strategies) + allCandidates.AddRange(strategy.GenerateCandidates(drawing, spacing, stepSize)); + + var results = allCandidates.Select(c => _evaluator.Evaluate(c)).ToList(); + + _filter.Apply(results); + + results = SortResults(results, sortBy); + + for (var i = 0; i < results.Count; i++) + results[i].Candidate.TestNumber = i; + + return results; + } + + public List FindAndTile( + Drawing drawing, Plate plate, + double spacing = 0.25, double stepSize = 0.25, int topN = 10) + { + var bestFits = FindBestFits(drawing, spacing, stepSize); + var tileEvaluator = new TileEvaluator(); + + return bestFits + .Where(r => r.Keep) + .Take(topN) + .Select(r => tileEvaluator.Evaluate(r, plate)) + .OrderByDescending(t => t.PartsNested) + .ThenByDescending(t => t.Utilization) + .ToList(); + } + + private List BuildStrategies(Drawing drawing) + { + var angles = GetRotationAngles(drawing); + var strategies = new List(); + var type = 1; + + foreach (var angle in angles) + { + var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle)); + strategies.Add(new RotationSlideStrategy(angle, type++, desc)); + } + + return strategies; + } + + private List GetRotationAngles(Drawing drawing) + { + var angles = new List + { + 0, + Angle.HalfPI, + System.Math.PI, + Angle.HalfPI * 3 + }; + + // Add hull edge angles + var hullAngles = GetHullEdgeAngles(drawing); + + foreach (var hullAngle in hullAngles) + { + AddUniqueAngle(angles, hullAngle); + AddUniqueAngle(angles, Angle.NormalizeRad(hullAngle + System.Math.PI)); + } + + return angles; + } + + private List GetHullEdgeAngles(Drawing drawing) + { + var entities = ConvertProgram.ToGeometry(drawing.Program) + .Where(e => e.Layer != SpecialLayers.Rapid); + var shapes = Helper.GetShapes(entities); + + var points = new List(); + + foreach (var shape in shapes) + { + var polygon = shape.ToPolygonWithTolerance(0.1); + points.AddRange(polygon.Vertices); + } + + if (points.Count < 3) + return new List(); + + var hull = ConvexHull.Compute(points); + var vertices = hull.Vertices; + var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count; + var hullAngles = new List(); + + for (var i = 0; i < n; i++) + { + var next = (i + 1) % n; + var dx = vertices[next].X - vertices[i].X; + var dy = vertices[next].Y - vertices[i].Y; + + if (dx * dx + dy * dy < Tolerance.Epsilon) + continue; + + var angle = Angle.NormalizeRad(System.Math.Atan2(dy, dx)); + AddUniqueAngle(hullAngles, angle); + } + + return hullAngles; + } + + private static void AddUniqueAngle(List angles, double angle) + { + angle = Angle.NormalizeRad(angle); + + foreach (var existing in angles) + { + if (existing.IsEqualTo(angle)) + return; + } + + angles.Add(angle); + } + + private List SortResults(List results, BestFitSortField sortBy) + { + switch (sortBy) + { + case BestFitSortField.Area: + return results.OrderBy(r => r.RotatedArea).ToList(); + case BestFitSortField.LongestSide: + return results.OrderBy(r => r.LongestSide).ToList(); + case BestFitSortField.ShortestSide: + return results.OrderBy(r => r.ShortestSide).ToList(); + case BestFitSortField.Type: + return results.OrderBy(r => r.Candidate.StrategyType) + .ThenBy(r => r.Candidate.TestNumber).ToList(); + case BestFitSortField.OriginalSequence: + return results.OrderBy(r => r.Candidate.TestNumber).ToList(); + case BestFitSortField.Keep: + return results.OrderByDescending(r => r.Keep) + .ThenBy(r => r.RotatedArea).ToList(); + case BestFitSortField.WhyKeepDrop: + return results.OrderBy(r => r.Reason) + .ThenBy(r => r.RotatedArea).ToList(); + default: + return results; + } + } + } +} +``` + +**Step 2: Add to .csproj** + +```xml + +``` + +**Step 3: Build full solution to verify all references resolve** + +Run: `msbuild OpenNest.sln /p:Configuration=Debug /v:q` + +**Step 4: Commit** + +``` +feat: add BestFitFinder orchestrator with hull edge angle strategies +``` + +--- + +## Task 9: Final Integration Build and Smoke Test + +**Step 1: Clean build of entire solution** + +Run: `msbuild OpenNest.sln /t:Rebuild /p:Configuration=Debug /v:q` +Expected: Build succeeded, 0 errors + +**Step 2: Verify all new files are included** + +Check that all 8 new files appear in the build output by reviewing the .csproj has these entries: +```xml + + + + + + + + + +``` + +**Step 3: Final commit** + +If any build fixes were needed, commit them: +``` +fix: resolve build issues in best-fit pair finding engine +``` diff --git a/docs/plans/2026-03-07-net8-migration.md b/docs/plans/2026-03-07-net8-migration.md new file mode 100644 index 0000000..ad2a3da --- /dev/null +++ b/docs/plans/2026-03-07-net8-migration.md @@ -0,0 +1,1024 @@ +# .NET 8 Migration Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Convert OpenNest from .NET Framework 4.8 to .NET 8, replacing netDxf with ACadSharp. + +**Architecture:** Three projects (OpenNest.Core, OpenNest.Engine, OpenNest) converted from legacy csproj to SDK-style targeting `net8.0-windows`. The netDxf NuGet dependency is replaced with ACadSharp 3.1.32 in the WinForms project. The 3 IO files (DxfImporter, DxfExporter, Extensions) are rewritten against the ACadSharp API. + +**Tech Stack:** .NET 8, WinForms, ACadSharp 3.1.32, System.Drawing.Common, CSMath + +--- + +## Reference Material + +- ACadSharp usage examples: `C:\Users\aisaacs\Desktop\Projects\Geometry\Geometry.Dxf\Extensions.cs` and `DxfImport.cs` +- ACadSharp key types: + - Reading: `ACadSharp.IO.DxfReader` → `.Read()` returns `CadDocument` + - Writing: `ACadSharp.IO.DxfWriter` + - Entities: `ACadSharp.Entities.{Line, Arc, Circle, Polyline, LwPolyline, Spline, Ellipse}` + - Vectors: `CSMath.XY`, `CSMath.XYZ` + - Layers: `ACadSharp.Tables.Layer` + - Colors: `ACadSharp.Color` (ACI index-based) + - Document: `ACadSharp.CadDocument` +- netDxf → ACadSharp type mapping: + - `netDxf.Vector2` → `CSMath.XY` + - `netDxf.Vector3` → `CSMath.XYZ` + - `netDxf.DxfDocument` → `ACadSharp.CadDocument` + - `netDxf.DxfDocument.Load(path)` → `new DxfReader(path).Read()` + - `netDxf.DxfDocument.Save(stream)` → `new DxfWriter(stream, doc).Write()` + - `netDxf.Tables.Layer` → `ACadSharp.Tables.Layer` + - `netDxf.AciColor` → `ACadSharp.Color` + - `netDxf.Linetype` → `ACadSharp.Tables.LineType` + - `doc.Entities.Lines` → `doc.Entities.OfType()` + - `doc.Entities.Arcs` → `doc.Entities.OfType()` + - `spline.PolygonalVertexes(n)` → manual control point interpolation + - `polyline.Vertexes` → `polyline.Vertices` + - `ellipse.ToPolyline2D(n)` → manual parametric calculation + +--- + +### Task 1: Convert OpenNest.Core to SDK-style .NET 8 + +**Files:** +- Replace: `OpenNest.Core/OpenNest.Core.csproj` +- Delete: `OpenNest.Core/Properties/AssemblyInfo.cs` + +**Step 1: Replace the csproj with SDK-style** + +Replace `OpenNest.Core/OpenNest.Core.csproj` with: + +```xml + + + net8.0-windows + OpenNest + OpenNest.Core + false + + + + + +``` + +Notes: +- `net8.0-windows` needed because `System.Drawing.Common` requires Windows on .NET 8 +- `GenerateAssemblyInfo` false to keep existing AssemblyInfo.cs (we'll remove it in step 2 and re-enable) +- SDK-style auto-includes all `.cs` files, so no `` items needed +- `System.Drawing.Common` NuGet replaces the Framework's built-in `System.Drawing` + +**Step 2: Delete AssemblyInfo.cs and enable auto-generated assembly info** + +Delete `OpenNest.Core/Properties/AssemblyInfo.cs`. + +Then update the csproj to remove `GenerateAssemblyInfo`: + +```xml + + + net8.0-windows + OpenNest + OpenNest.Core + + + + + +``` + +**Step 3: Build to verify** + +Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` +Expected: Build succeeds with 0 errors. + +**Step 4: Commit** + +``` +feat: convert OpenNest.Core to .NET 8 SDK-style project +``` + +--- + +### Task 2: Convert OpenNest.Engine to SDK-style .NET 8 + +**Files:** +- Replace: `OpenNest.Engine/OpenNest.Engine.csproj` +- Delete: `OpenNest.Engine/Properties/AssemblyInfo.cs` + +**Step 1: Replace the csproj with SDK-style** + +```xml + + + net8.0-windows + OpenNest + OpenNest.Engine + + + + + +``` + +**Step 2: Delete AssemblyInfo.cs** + +Delete `OpenNest.Engine/Properties/AssemblyInfo.cs`. + +**Step 3: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeds. + +**Step 4: Commit** + +``` +feat: convert OpenNest.Engine to .NET 8 SDK-style project +``` + +--- + +### Task 3: Convert OpenNest (WinForms) to SDK-style .NET 8 + +**Files:** +- Replace: `OpenNest/OpenNest.csproj` +- Delete: `OpenNest/Properties/AssemblyInfo.cs` +- Delete: `OpenNest/packages.config` +- Keep: `OpenNest/Properties/Settings.settings`, `Settings.Designer.cs`, `Resources.resx`, `Resources.Designer.cs` +- Keep: `OpenNest/app.config` + +**Step 1: Replace the csproj with SDK-style** + +```xml + + + WinExe + net8.0-windows + OpenNest + OpenNest + true + false + DpiUnaware + + + + + + + + + + True + Resources.resx + True + + + True + Settings.settings + True + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + +``` + +Notes: +- `UseWindowsForms` enables WinForms support on .NET 8 +- `ApplicationHighDpiMode` replaces the manual `SetProcessDPIAware()` P/Invoke +- ACadSharp replaces netDxf package reference +- `GenerateAssemblyInfo` false initially — we'll clean up AssemblyInfo and re-enable after + +**Step 2: Delete AssemblyInfo.cs and packages.config** + +Delete `OpenNest/Properties/AssemblyInfo.cs` and `OpenNest/packages.config`. + +Then remove `GenerateAssemblyInfo` from the csproj. + +**Step 3: Build to check what breaks** + +Run: `dotnet build OpenNest/OpenNest.csproj` +Expected: Build fails — the 3 IO files reference `netDxf` types. This is expected and will be fixed in Tasks 4-6. + +**Step 4: Commit (with build errors expected)** + +``` +feat: convert OpenNest WinForms project to .NET 8 SDK-style + +Build errors expected in IO files — netDxf references not yet migrated to ACadSharp. +``` + +--- + +### Task 4: Rewrite Extensions.cs for ACadSharp + +**Files:** +- Modify: `OpenNest/IO/Extensions.cs` +- Reference: `C:\Users\aisaacs\Desktop\Projects\Geometry\Geometry.Dxf\Extensions.cs` + +**Step 1: Rewrite Extensions.cs** + +Replace the full file. Key changes: +- `netDxf.Vector2` → `CSMath.XY` +- `netDxf.Vector3` → `CSMath.XYZ` +- `netDxf.Entities.*` → `ACadSharp.Entities.*` +- `netDxf.Tables.Layer` → `ACadSharp.Tables.Layer` +- `layer.Color.ToColor()` → `System.Drawing.Color.FromArgb(layer.Color.GetColorIndex()...)` or equivalent +- `spline.PolygonalVertexes(n)` → manual control point iteration +- `polyline.Vertexes` → `polyline.Vertices` +- `ellipse.ToPolyline2D(n)` → manual parametric calculation +- Polyline2D closed check: use `Flags` property +- `arc.StartAngle`/`arc.EndAngle` — ACadSharp stores arc angles in **radians** already (netDxf used degrees) + +```csharp +using System.Collections.Generic; +using System.Linq; +using ACadSharp.Entities; +using CSMath; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.IO +{ + internal static class Extensions + { + public static Vector ToOpenNest(this XY v) + { + return new Vector(v.X, v.Y); + } + + public static Vector ToOpenNest(this XYZ v) + { + return new Vector(v.X, v.Y); + } + + public static Arc ToOpenNest(this ACadSharp.Entities.Arc arc) + { + // ACadSharp stores angles in radians already + return new Arc( + arc.Center.X, arc.Center.Y, arc.Radius, + arc.StartAngle, + arc.EndAngle) + { + Layer = arc.Layer.ToOpenNest() + }; + } + + public static Circle ToOpenNest(this ACadSharp.Entities.Circle circle) + { + return new Circle( + circle.Center.X, circle.Center.Y, + circle.Radius) + { + Layer = circle.Layer.ToOpenNest() + }; + } + + public static Line ToOpenNest(this ACadSharp.Entities.Line line) + { + return new Line( + line.StartPoint.X, line.StartPoint.Y, + line.EndPoint.X, line.EndPoint.Y) + { + Layer = line.Layer.ToOpenNest() + }; + } + + public static List ToOpenNest(this Spline spline, int precision = 200) + { + var lines = new List(); + var controlPoints = spline.ControlPoints + .Select(p => new Vector(p.X, p.Y)).ToList(); + + if (controlPoints.Count < 2) + return lines; + + var lastPoint = controlPoints[0]; + + for (int i = 1; i < controlPoints.Count; i++) + { + var nextPoint = controlPoints[i]; + lines.Add(new Line(lastPoint, nextPoint) + { Layer = spline.Layer.ToOpenNest() }); + lastPoint = nextPoint; + } + + if (spline.IsClosed) + lines.Add(new Line(lastPoint, controlPoints[0]) + { Layer = spline.Layer.ToOpenNest() }); + + return lines; + } + + public static List ToOpenNest(this Polyline polyline) + { + var lines = new List(); + var vertices = polyline.Vertices.ToList(); + + if (vertices.Count == 0) + return lines; + + var lastPoint = vertices[0].Location.ToOpenNest(); + + for (int i = 1; i < vertices.Count; i++) + { + var nextPoint = vertices[i].Location.ToOpenNest(); + lines.Add(new Line(lastPoint, nextPoint) + { Layer = polyline.Layer.ToOpenNest() }); + lastPoint = nextPoint; + } + + if ((polyline.Flags & PolylineFlags.ClosedPolylineOrClosedPolygonMeshInM) != 0) + lines.Add(new Line(lastPoint, vertices[0].Location.ToOpenNest()) + { Layer = polyline.Layer.ToOpenNest() }); + + return lines; + } + + public static List ToOpenNest(this LwPolyline polyline) + { + var lines = new List(); + var vertices = polyline.Vertices.ToList(); + + if (vertices.Count == 0) + return lines; + + var lastPoint = new Vector(vertices[0].Location.X, vertices[0].Location.Y); + + for (int i = 1; i < vertices.Count; i++) + { + var nextPoint = new Vector(vertices[i].Location.X, vertices[i].Location.Y); + lines.Add(new Line(lastPoint, nextPoint) + { Layer = polyline.Layer.ToOpenNest() }); + lastPoint = nextPoint; + } + + if ((polyline.Flags & LwPolylineFlags.Closed) != 0) + lines.Add(new Line(lastPoint, + new Vector(vertices[0].Location.X, vertices[0].Location.Y)) + { Layer = polyline.Layer.ToOpenNest() }); + + return lines; + } + + public static List ToOpenNest(this Ellipse ellipse, int precision = 200) + { + var lines = new List(); + var points = new List(); + var startParam = ellipse.StartParameter; + var endParam = ellipse.EndParameter; + var paramRange = endParam - startParam; + var radius = System.Math.Sqrt( + ellipse.EndPoint.X * ellipse.EndPoint.X + + ellipse.EndPoint.Y * ellipse.EndPoint.Y); + + for (int i = 0; i <= precision; i++) + { + var t = i / (double)precision; + var angle = startParam + paramRange * t; + var x = ellipse.Center.X + radius * System.Math.Cos(angle); + var y = ellipse.Center.Y + radius * System.Math.Sin(angle); + points.Add(new Vector(x, y)); + } + + for (int i = 1; i < points.Count; i++) + { + lines.Add(new Line(points[i - 1], points[i]) + { Layer = ellipse.Layer.ToOpenNest() }); + } + + if (points.Count > 1 && + System.Math.Abs(paramRange - 2 * System.Math.PI) < 0.0001) + { + lines.Add(new Line(points[points.Count - 1], points[0]) + { Layer = ellipse.Layer.ToOpenNest() }); + } + + return lines; + } + + public static Layer ToOpenNest(this ACadSharp.Tables.Layer layer) + { + return new Layer(layer.Name) + { + Color = System.Drawing.Color.FromArgb( + layer.Color.R, layer.Color.G, layer.Color.B), + IsVisible = layer.IsOn + }; + } + + public static XY ToAcad(this Vector v) + { + return new XY(v.X, v.Y); + } + + public static XYZ ToAcadXYZ(this Vector v) + { + return new XYZ(v.X, v.Y, 0); + } + } +} +``` + +**Step 2: Commit** + +``` +refactor: rewrite IO Extensions for ACadSharp +``` + +--- + +### Task 5: Rewrite DxfImporter.cs for ACadSharp + +**Files:** +- Modify: `OpenNest/IO/DxfImporter.cs` + +**Step 1: Rewrite DxfImporter.cs** + +Key changes: +- `DxfDocument.Load(path)` → `new DxfReader(path).Read()` returning `CadDocument` +- `doc.Entities.Lines` → `doc.Entities.OfType()` +- Same pattern for Arcs, Circles, Splines, etc. +- `doc.Entities.Polylines2D` → `doc.Entities.OfType()` + `doc.Entities.OfType()` +- `doc.Entities.Polylines3D` → handled via `Polyline` type + +```csharp +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using ACadSharp; +using ACadSharp.IO; +using OpenNest.Geometry; + +namespace OpenNest.IO +{ + public class DxfImporter + { + public int SplinePrecision { get; set; } + + public DxfImporter() + { + } + + private List GetGeometry(CadDocument doc) + { + var entities = new List(); + var lines = new List(); + var arcs = new List(); + + foreach (var entity in doc.Entities) + { + switch (entity) + { + case ACadSharp.Entities.Spline spline: + lines.AddRange(spline.ToOpenNest(SplinePrecision)); + break; + + case ACadSharp.Entities.LwPolyline lwPolyline: + lines.AddRange(lwPolyline.ToOpenNest()); + break; + + case ACadSharp.Entities.Polyline polyline: + lines.AddRange(polyline.ToOpenNest()); + break; + + case ACadSharp.Entities.Ellipse ellipse: + lines.AddRange(ellipse.ToOpenNest(SplinePrecision)); + break; + + case ACadSharp.Entities.Line line: + lines.Add(line.ToOpenNest()); + break; + + case ACadSharp.Entities.Arc arc: + arcs.Add(arc.ToOpenNest()); + break; + + case ACadSharp.Entities.Circle circle: + entities.Add(circle.ToOpenNest()); + break; + } + } + + Helper.Optimize(lines); + Helper.Optimize(arcs); + + entities.AddRange(lines); + entities.AddRange(arcs); + + return entities; + } + + public bool GetGeometry(Stream stream, out List geometry) + { + bool success = false; + + try + { + using var reader = new DxfReader(stream); + var doc = reader.Read(); + geometry = GetGeometry(doc); + success = true; + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + geometry = new List(); + } + + return success; + } + + public bool GetGeometry(string path, out List geometry) + { + bool success = false; + + try + { + using var reader = new DxfReader(path); + var doc = reader.Read(); + geometry = GetGeometry(doc); + success = true; + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + geometry = new List(); + } + + return success; + } + } +} +``` + +**Step 2: Commit** + +``` +refactor: rewrite DxfImporter for ACadSharp +``` + +--- + +### Task 6: Rewrite DxfExporter.cs for ACadSharp + +**Files:** +- Modify: `OpenNest/IO/DxfExporter.cs` + +**Step 1: Rewrite DxfExporter.cs** + +Key changes: +- `new DxfDocument()` → `new CadDocument()` +- `doc.Entities.Add(entity)` → `doc.Entities.Add(entity)` (same pattern) +- `doc.Save(stream)` → `using var writer = new DxfWriter(stream); writer.Write(doc);` +- `new netDxf.Entities.Line(pt1, pt2)` → `new ACadSharp.Entities.Line { StartPoint = xyz1, EndPoint = xyz2 }` +- `new netDxf.Vector2(x, y)` → `new XYZ(x, y, 0)` (ACadSharp Lines use XYZ) +- `AciColor.Red` → `new Color(1)` (ACI color index 1 = red) +- `AciColor.Blue` → `new Color(5)` (ACI color index 5 = blue) +- `AciColor.Cyan` → `new Color(4)` (ACI color index 4 = cyan) +- `Linetype.Dashed` → look up or create dashed linetype +- `Vector2 curpos` → `XYZ curpos` (track as 3D) +- Arc angles: netDxf used degrees, ACadSharp uses radians — remove degree conversions + +```csharp +using System; +using System.Diagnostics; +using System.IO; +using ACadSharp; +using ACadSharp.Entities; +using ACadSharp.IO; +using ACadSharp.Tables; +using CSMath; +using OpenNest.CNC; +using OpenNest.Math; + +namespace OpenNest.IO +{ + using AcadLine = ACadSharp.Entities.Line; + using AcadArc = ACadSharp.Entities.Arc; + using AcadCircle = ACadSharp.Entities.Circle; + + public class DxfExporter + { + private CadDocument doc; + private XYZ curpos; + private Mode mode; + private readonly Layer cutLayer; + private readonly Layer rapidLayer; + private readonly Layer plateLayer; + + public DxfExporter() + { + doc = new CadDocument(); + + cutLayer = new Layer("Cut"); + cutLayer.Color = new Color(1); // Red + + rapidLayer = new Layer("Rapid"); + rapidLayer.Color = new Color(5); // Blue + + plateLayer = new Layer("Plate"); + plateLayer.Color = new Color(4); // Cyan + } + + public void ExportProgram(Program program, Stream stream) + { + doc = new CadDocument(); + EnsureLayers(); + AddProgram(program); + + using var writer = new DxfWriter(stream); + writer.Write(doc); + } + + public bool ExportProgram(Program program, string path) + { + Stream stream = null; + bool success = false; + + try + { + stream = File.Create(path); + ExportProgram(program, stream); + success = true; + } + catch + { + Debug.Fail("DxfExporter.ExportProgram failed to write program to file: " + path); + } + finally + { + if (stream != null) + stream.Close(); + } + + return success; + } + + public void ExportPlate(Plate plate, Stream stream) + { + doc = new CadDocument(); + EnsureLayers(); + AddPlateOutline(plate); + + foreach (var part in plate.Parts) + { + var endpt = part.Location.ToAcadXYZ(); + var line = new AcadLine(); + line.StartPoint = curpos; + line.EndPoint = endpt; + line.Layer = rapidLayer; + doc.Entities.Add(line); + curpos = endpt; + AddProgram(part.Program); + } + + using var writer = new DxfWriter(stream); + writer.Write(doc); + } + + public bool ExportPlate(Plate plate, string path) + { + Stream stream = null; + bool success = false; + + try + { + stream = File.Create(path); + ExportPlate(plate, stream); + success = true; + } + catch + { + Debug.Fail("DxfExporter.ExportPlate failed to write plate to file: " + path); + } + finally + { + if (stream != null) + stream.Close(); + } + + return success; + } + + private void EnsureLayers() + { + if (!doc.Layers.Contains(cutLayer.Name)) + doc.Layers.Add(cutLayer); + if (!doc.Layers.Contains(rapidLayer.Name)) + doc.Layers.Add(rapidLayer); + if (!doc.Layers.Contains(plateLayer.Name)) + doc.Layers.Add(plateLayer); + } + + private void AddPlateOutline(Plate plate) + { + XYZ pt1, pt2, pt3, pt4; + + switch (plate.Quadrant) + { + case 1: + pt1 = new XYZ(0, 0, 0); + pt2 = new XYZ(0, plate.Size.Height, 0); + pt3 = new XYZ(plate.Size.Width, plate.Size.Height, 0); + pt4 = new XYZ(plate.Size.Width, 0, 0); + break; + + case 2: + pt1 = new XYZ(0, 0, 0); + pt2 = new XYZ(0, plate.Size.Height, 0); + pt3 = new XYZ(-plate.Size.Width, plate.Size.Height, 0); + pt4 = new XYZ(-plate.Size.Width, 0, 0); + break; + + case 3: + pt1 = new XYZ(0, 0, 0); + pt2 = new XYZ(0, -plate.Size.Height, 0); + pt3 = new XYZ(-plate.Size.Width, -plate.Size.Height, 0); + pt4 = new XYZ(-plate.Size.Width, 0, 0); + break; + + case 4: + pt1 = new XYZ(0, 0, 0); + pt2 = new XYZ(0, -plate.Size.Height, 0); + pt3 = new XYZ(plate.Size.Width, -plate.Size.Height, 0); + pt4 = new XYZ(plate.Size.Width, 0, 0); + break; + + default: + return; + } + + AddLine(pt1, pt2, plateLayer); + AddLine(pt2, pt3, plateLayer); + AddLine(pt3, pt4, plateLayer); + AddLine(pt4, pt1, plateLayer); + + // Inner margin lines + var m1 = new XYZ(pt1.X + plate.EdgeSpacing.Left, pt1.Y + plate.EdgeSpacing.Bottom, 0); + var m2 = new XYZ(m1.X, pt2.Y - plate.EdgeSpacing.Top, 0); + var m3 = new XYZ(pt3.X - plate.EdgeSpacing.Right, m2.Y, 0); + var m4 = new XYZ(m3.X, m1.Y, 0); + + AddLine(m1, m2, plateLayer); + AddLine(m2, m3, plateLayer); + AddLine(m3, m4, plateLayer); + AddLine(m4, m1, plateLayer); + } + + private void AddLine(XYZ start, XYZ end, Layer layer) + { + var line = new AcadLine(); + line.StartPoint = start; + line.EndPoint = end; + line.Layer = layer; + doc.Entities.Add(line); + } + + private void AddProgram(Program program) + { + mode = program.Mode; + + for (int i = 0; i < program.Length; ++i) + { + var code = program[i]; + + switch (code.Type) + { + case CodeType.ArcMove: + var arc = (ArcMove)code; + AddArcMove(arc); + break; + + case CodeType.LinearMove: + var line = (LinearMove)code; + AddLinearMove(line); + break; + + case CodeType.RapidMove: + var rapid = (RapidMove)code; + AddRapidMove(rapid); + break; + + case CodeType.SubProgramCall: + var tmpmode = mode; + var subpgm = (CNC.SubProgramCall)code; + AddProgram(subpgm.Program); + mode = tmpmode; + break; + } + } + } + + private void AddLinearMove(LinearMove line) + { + var pt = line.EndPoint.ToAcadXYZ(); + + if (mode == Mode.Incremental) + pt = new XYZ(pt.X + curpos.X, pt.Y + curpos.Y, 0); + + AddLine(curpos, pt, cutLayer); + curpos = pt; + } + + private void AddRapidMove(RapidMove rapid) + { + var pt = rapid.EndPoint.ToAcadXYZ(); + + if (mode == Mode.Incremental) + pt = new XYZ(pt.X + curpos.X, pt.Y + curpos.Y, 0); + + AddLine(curpos, pt, rapidLayer); + curpos = pt; + } + + private void AddArcMove(ArcMove arc) + { + var center = arc.CenterPoint.ToAcadXYZ(); + var endpt = arc.EndPoint.ToAcadXYZ(); + + if (mode == Mode.Incremental) + { + endpt = new XYZ(endpt.X + curpos.X, endpt.Y + curpos.Y, 0); + center = new XYZ(center.X + curpos.X, center.Y + curpos.Y, 0); + } + + // ACadSharp uses radians — no conversion needed + var startAngle = System.Math.Atan2( + curpos.Y - center.Y, + curpos.X - center.X); + + var endAngle = System.Math.Atan2( + endpt.Y - center.Y, + endpt.X - center.X); + + if (arc.Rotation == OpenNest.RotationType.CW) + Generic.Swap(ref startAngle, ref endAngle); + + var dx = endpt.X - center.X; + var dy = endpt.Y - center.Y; + var radius = System.Math.Sqrt(dx * dx + dy * dy); + + if (startAngle.IsEqualTo(endAngle)) + { + var circle = new AcadCircle(); + circle.Center = center; + circle.Radius = radius; + circle.Layer = cutLayer; + doc.Entities.Add(circle); + } + else + { + var arc2 = new AcadArc(); + arc2.Center = center; + arc2.Radius = radius; + arc2.StartAngle = startAngle; + arc2.EndAngle = endAngle; + arc2.Layer = cutLayer; + doc.Entities.Add(arc2); + } + + curpos = endpt; + } + } +} +``` + +**Step 2: Commit** + +``` +refactor: rewrite DxfExporter for ACadSharp +``` + +--- + +### Task 7: Update MainApp.cs for .NET 8 + +**Files:** +- Modify: `OpenNest/MainApp.cs` + +**Step 1: Simplify MainApp.cs** + +.NET 8 WinForms handles DPI awareness via the `ApplicationHighDpiMode` project property. Remove the manual `SetProcessDPIAware()` P/Invoke. + +```csharp +using System; +using System.Windows.Forms; +using OpenNest.Forms; + +namespace OpenNest +{ + internal static class MainApp + { + [STAThread] + private static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new MainForm()); + } + } +} +``` + +**Step 2: Commit** + +``` +refactor: simplify MainApp for .NET 8 DPI handling +``` + +--- + +### Task 8: Update solution file and clean up + +**Files:** +- Modify: `OpenNest.sln` +- Delete: `packages/` directory (if present on disk) +- Verify: `.gitignore` already ignores `packages/` + +**Step 1: Update solution file** + +Replace `OpenNest.sln` with a clean version. The project GUIDs stay the same but the solution format is updated: + +``` +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.0.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest", "OpenNest\OpenNest.csproj", "{1F1E40E0-5C53-474F-A258-69C9C3FAC15A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Core", "OpenNest.Core\OpenNest.Core.csproj", "{5A5FDE8D-F8DB-440E-866C-C4807E1686CF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine", "OpenNest.Engine\OpenNest.Engine.csproj", "{0083B9CC-54AD-4085-A30D-56BC6834B71A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Release|Any CPU.Build.0 = Release|Any CPU + {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Release|Any CPU.Build.0 = Release|Any CPU + {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal +``` + +**Step 2: Full solution build** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds with 0 errors. + +**Step 3: Fix any remaining build errors** + +If there are build errors, fix them. Common issues: +- `System.IO.Compression` — now built into .NET 8, no reference needed +- Missing `using` statements for new namespaces +- API differences in `System.Drawing.Common` on .NET 8 +- `LayoutViewGL.cs` exists on disk but was never in the old csproj — SDK-style will auto-include it. Exclude or delete it if it causes errors. + +**Step 4: Commit** + +``` +feat: complete .NET 8 migration — update solution and clean up +``` + +--- + +### Task 9: Update CLAUDE.md + +**Files:** +- Modify: `CLAUDE.md` + +**Step 1: Update build instructions and dependency info** + +Update the Build section to reflect `dotnet build` instead of `msbuild`, remove references to `packages/` folder and `nuget restore`, update netDxf references to ACadSharp, and note .NET 8 target. + +**Step 2: Commit** + +``` +docs: update CLAUDE.md for .NET 8 migration +```