From 662d3daf8cb4da280f3d0606ea8c71560e522dfc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 28 Oct 2026 22:16:36 -0400 Subject: [PATCH] feat(desktop): draw and edit a plate's sheet outline The plate view fills the outline, hatches obstacles and draws the usable area; Edit Plate imports an outline from a CAD file or clears it, with the size read-only while the sheet has one. Co-Authored-By: Claude Fable 5.1 --- .../Controls/OutlinedPlateDisplayTests.cs | 45 +++++++++++++ .../Forms/EditPlateFormOutlineTests.cs | 61 ++++++++++++++++++ OpenNest/Controls/PlateRenderer.cs | 50 +++++++++++++++ OpenNest/Forms/EditPlateForm.Designer.cs | 39 ++++++++++++ OpenNest/Forms/EditPlateForm.cs | 63 ++++++++++++++++++- 5 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 OpenNest.WinForms.Tests/Controls/OutlinedPlateDisplayTests.cs create mode 100644 OpenNest.WinForms.Tests/Forms/EditPlateFormOutlineTests.cs diff --git a/OpenNest.WinForms.Tests/Controls/OutlinedPlateDisplayTests.cs b/OpenNest.WinForms.Tests/Controls/OutlinedPlateDisplayTests.cs new file mode 100644 index 00000000..cc410570 --- /dev/null +++ b/OpenNest.WinForms.Tests/Controls/OutlinedPlateDisplayTests.cs @@ -0,0 +1,45 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using OpenNest.Controls; +using OpenNest.Geometry; + +namespace OpenNest.WinForms.Tests.Controls; + +/// The plate view draws an outlined sheet as its outline: the missing corner shows the +/// background and an obstacle is hatched, in the plate's quadrant. +public class OutlinedPlateDisplayTests +{ + // A 100 x 100 quadrant-4 sheet missing its upper-right 50 x 50 corner, with a 10 x 10 burned + // patch at (10..20, 70..80). PointWorldToGraph flips Y, so plate (x, y) lands on graph (x, 100 - y). + private static PlateView BuildView() + { + var view = new PlateView(ColorSchemeRegistry.Get("Workshop")) + { + Size = new System.Drawing.Size(100, 100), + }; + view.Matrix = new Matrix(); + view.Plate.Quadrant = 4; + view.Plate.Outline = new SheetOutline( + new[] { new Vector(0, 0), new Vector(100, 0), new Vector(100, 50), new Vector(50, 50), new Vector(50, 100), new Vector(0, 100) }, + new[] { new[] { new Vector(10, 70), new Vector(20, 70), new Vector(20, 80), new Vector(10, 80) } }); + return view; + } + + [Fact] + public void MissingCornerShowsTheBackgroundAndTheRestIsFilled() => StaTestThread.Run(() => + { + using var view = BuildView(); + using var image = new Bitmap(100, 100); + using var g = Graphics.FromImage(image); + g.Clear(Color.White); + + view.Renderer.DrawPlate(g); + + var fill = ((SolidBrush)view.ColorScheme.LayoutFillBrush).Color; + Assert.Equal(Color.White.ToArgb(), image.GetPixel(75, 25).ToArgb()); // the missing corner + Assert.Equal(fill.ToArgb(), image.GetPixel(25, 75).ToArgb()); // the sheet + Assert.Equal(fill.ToArgb(), image.GetPixel(75, 75).ToArgb()); // the arm + Assert.NotEqual(fill.ToArgb(), image.GetPixel(15, 25).ToArgb()); // the hatched obstacle + Assert.NotEqual(Color.White.ToArgb(), image.GetPixel(15, 25).ToArgb()); + }, TimeSpan.FromMinutes(1), "Outlined plate display test timed out."); +} diff --git a/OpenNest.WinForms.Tests/Forms/EditPlateFormOutlineTests.cs b/OpenNest.WinForms.Tests/Forms/EditPlateFormOutlineTests.cs new file mode 100644 index 00000000..893db2af --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/EditPlateFormOutlineTests.cs @@ -0,0 +1,61 @@ +using System.Windows.Forms; +using OpenNest.Forms; +using OpenNest.Geometry; + +namespace OpenNest.WinForms.Tests.Forms; + +/// Edit Plate with a sheet outline: the size follows the outline and is read-only +/// while there is one, Done applies the outline, and Clear makes the sheet a rectangle again. +public class EditPlateFormOutlineTests +{ + private static SheetOutline Ell() => new(new[] + { + new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(48, 24), new Vector(48, 48), new Vector(0, 48), + }); + + [Fact] + public void OutlineSetsTheSizeAndDoneAppliesIt() => RunSta(() => + { + var plate = new Plate(60, 120); + using var form = new EditPlateForm(plate); + var size = (TextBox)form.Controls.Find("textBoxSize", true)[0]; + var clear = (Button)form.Controls.Find("buttonClearOutline", true)[0]; + var done = (Button)form.Controls.Find("applyButton", true)[0]; + Assert.False(size.ReadOnly); + Assert.False(clear.Enabled); + + form.Outline = Ell(); + + Assert.True(size.ReadOnly); + Assert.True(clear.Enabled); + Assert.Equal(new Size(48, 96).ToString(), form.SizeString); + Assert.Contains("6 vertices", ((Label)form.Controls.Find("labelOutline", true)[0]).Text); + Assert.True(done.Enabled); + + done.PerformClick(); + Assert.Equal(Ell(), plate.Outline); + Assert.Equal(48, plate.Size.Width); + Assert.Equal(96, plate.Size.Length); + }); + + [Fact] + public void ClearMakesTheSheetARectangleOfTheSameSize() => RunSta(() => + { + var plate = new Plate { Outline = Ell(), Quadrant = 2 }; + using var form = new EditPlateForm(plate); + var size = (TextBox)form.Controls.Find("textBoxSize", true)[0]; + Assert.True(size.ReadOnly); + + ((Button)form.Controls.Find("buttonClearOutline", true)[0]).PerformClick(); + Assert.False(size.ReadOnly); + ((Button)form.Controls.Find("applyButton", true)[0]).PerformClick(); + + Assert.Null(plate.Outline); + Assert.Equal(48, plate.Size.Width); + Assert.Equal(96, plate.Size.Length); + Assert.Equal(2, plate.Quadrant); + }); + + private static void RunSta(Action action) => + StaTestThread.Run(action, TimeSpan.FromSeconds(30), "Edit Plate outline test timed out."); +} diff --git a/OpenNest/Controls/PlateRenderer.cs b/OpenNest/Controls/PlateRenderer.cs index 25a127bd..7d566cf2 100644 --- a/OpenNest/Controls/PlateRenderer.cs +++ b/OpenNest/Controls/PlateRenderer.cs @@ -25,6 +25,12 @@ namespace OpenNest.Controls public void DrawPlate(Graphics g) { var plate = view.DisplayPlate; + if (plate.Outline != null) + { + DrawOutlinedPlate(g, plate); + return; + } + var plateRect = new RectangleF { Width = view.LengthWorldToGui(plate.Size.Length), @@ -235,6 +241,50 @@ namespace OpenNest.Controls } } + private static readonly Color ObstacleHatchColor = Color.FromArgb(170, 120, 60, 60); + private static readonly Color ObstacleBackColor = Color.FromArgb(60, 200, 80, 80); + + /// + /// An irregular sheet: its outline filled as the sheet, obstacles hatched, and the + /// usable area (the outline eroded by the largest edge spacing) in the edge-spacing pen, + /// all placed in the plate's quadrant like the rectangle. + /// + private void DrawOutlinedPlate(Graphics g, Plate plate) + { + var outline = plate.Outline; + var origin = outline.PlacementOrigin(plate.Quadrant); + + using var sheet = new GraphicsPath(FillMode.Alternate); + sheet.AddPolygon(ToGraph(outline.Points, origin)); + foreach (var obstacle in outline.Obstacles) + sheet.AddPolygon(ToGraph(obstacle, origin)); + g.FillPath(view.ColorScheme.LayoutFillBrush, sheet); + + if (outline.Obstacles.Count > 0) + { + using var hatch = new HatchBrush(HatchStyle.WideUpwardDiagonal, ObstacleHatchColor, ObstacleBackColor); + using var pen = new Pen(ObstacleHatchColor, 1.5f); + foreach (var obstacle in outline.Obstacles) + { + var points = ToGraph(obstacle, origin); + g.FillPolygon(hatch, points); + g.DrawPolygon(pen, points); + } + } + + var edge = System.Math.Max(System.Math.Max(plate.EdgeSpacing.Left, plate.EdgeSpacing.Right), + System.Math.Max(plate.EdgeSpacing.Top, plate.EdgeSpacing.Bottom)); + if (edge > 0) + foreach (var path in outline.Usable(edge, origin)) + g.DrawPolygon(view.ColorScheme.EdgeSpacingPen, + path.Select(p => view.PointWorldToGraph(p.x, p.y)).ToArray()); + + g.DrawPath(view.ColorScheme.LayoutOutlinePen, sheet); + } + + private PointF[] ToGraph(IReadOnlyList points, Vector origin) => + points.Select(p => view.PointWorldToGraph(p.X + origin.X, p.Y + origin.Y)).ToArray(); + // D8: a clamp taken off this sheet, by Auto Nest as a last resort or by hand (right-click). // Distinct from an active clamp - a // dashed grey outline and a small "remove" marker, still display + exclusion only diff --git a/OpenNest/Forms/EditPlateForm.Designer.cs b/OpenNest/Forms/EditPlateForm.Designer.cs index 596a080e..f315c5c5 100644 --- a/OpenNest/Forms/EditPlateForm.Designer.cs +++ b/OpenNest/Forms/EditPlateForm.Designer.cs @@ -44,6 +44,9 @@ this.cancelButton = new System.Windows.Forms.Button(); this.bottomPanel1 = new OpenNest.Controls.BottomPanel(); this.quadrantSelect1 = new OpenNest.Controls.QuadrantSelect(); + this.labelOutline = new System.Windows.Forms.Label(); + this.buttonImportOutline = new System.Windows.Forms.Button(); + this.buttonClearOutline = new System.Windows.Forms.Button(); this.numericUpDownEdgeSpacingLeft = new OpenNest.Controls.NumericUpDown(); this.numericUpDownEdgeSpacingTop = new OpenNest.Controls.NumericUpDown(); this.numericUpDownEdgeSpacingRight = new OpenNest.Controls.NumericUpDown(); @@ -251,6 +254,36 @@ this.bottomPanel1.Size = new System.Drawing.Size(550, 50); this.bottomPanel1.TabIndex = 7; // + // labelOutline + // + this.labelOutline.AutoSize = true; + this.labelOutline.Location = new System.Drawing.Point(21, 131); + this.labelOutline.Name = "labelOutline"; + this.labelOutline.Size = new System.Drawing.Size(103, 16); + this.labelOutline.TabIndex = 8; + this.labelOutline.Text = "Rectangular sheet"; + // + // buttonImportOutline + // + this.buttonImportOutline.Location = new System.Drawing.Point(130, 125); + this.buttonImportOutline.Name = "buttonImportOutline"; + this.buttonImportOutline.Size = new System.Drawing.Size(70, 28); + this.buttonImportOutline.TabIndex = 9; + this.buttonImportOutline.Text = "Outline..."; + this.buttonImportOutline.UseVisualStyleBackColor = true; + this.buttonImportOutline.Click += new System.EventHandler(this.buttonImportOutline_Click); + // + // buttonClearOutline + // + this.buttonClearOutline.Enabled = false; + this.buttonClearOutline.Location = new System.Drawing.Point(204, 125); + this.buttonClearOutline.Name = "buttonClearOutline"; + this.buttonClearOutline.Size = new System.Drawing.Size(56, 28); + this.buttonClearOutline.TabIndex = 10; + this.buttonClearOutline.Text = "Clear"; + this.buttonClearOutline.UseVisualStyleBackColor = true; + this.buttonClearOutline.Click += new System.EventHandler(this.buttonClearOutline_Click); + // // quadrantSelect1 // this.quadrantSelect1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) @@ -361,6 +394,9 @@ this.Controls.Add(this.groupBox2); this.Controls.Add(this.groupBox1); this.Controls.Add(this.tableLayoutPanel1); + this.Controls.Add(this.labelOutline); + this.Controls.Add(this.buttonImportOutline); + this.Controls.Add(this.buttonClearOutline); this.Font = new System.Drawing.Font("Microsoft Sans Serif", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; this.MaximizeBox = false; @@ -392,6 +428,9 @@ private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1; private System.Windows.Forms.Label labelSize; private System.Windows.Forms.Button applyButton; + private System.Windows.Forms.Label labelOutline; + private System.Windows.Forms.Button buttonImportOutline; + private System.Windows.Forms.Button buttonClearOutline; private System.Windows.Forms.TextBox textBoxSize; private System.Windows.Forms.GroupBox groupBox1; private System.Windows.Forms.TableLayoutPanel tableLayoutPanel2; diff --git a/OpenNest/Forms/EditPlateForm.cs b/OpenNest/Forms/EditPlateForm.cs index e65618d5..6e997190 100644 --- a/OpenNest/Forms/EditPlateForm.cs +++ b/OpenNest/Forms/EditPlateForm.cs @@ -1,5 +1,7 @@ using System; +using System.IO; using System.Windows.Forms; +using OpenNest.IO; using Timer = System.Windows.Forms.Timer; namespace OpenNest.Forms @@ -10,6 +12,7 @@ namespace OpenNest.Forms private readonly Timer timer; private Units units; + private SheetOutline outline; public EditPlateForm(Plate plate) { @@ -123,6 +126,28 @@ namespace OpenNest.Forms set { quadrantSelect1.Quadrant = value; } } + /// + /// The sheet outline the plate gets when the dialog is accepted: the plate's own until + /// one is imported or cleared. An outlined sheet's size is its outline's bounds, so the + /// size box is read-only while there is one. + /// + public SheetOutline Outline + { + get { return outline; } + set + { + outline = value; + textBoxSize.ReadOnly = value != null; + buttonClearOutline.Enabled = value != null; + if (value != null) + textBoxSize.Text = value.Size.ToString(); + labelOutline.Text = value == null + ? "Rectangular sheet" + : $"{value.Points.Count} vertices" + (value.Obstacles.Count == 0 ? "" : $", {value.Obstacles.Count} obstacle(s)"); + EnableCheck(); + } + } + public void EnableCheck() { OpenNest.Geometry.Size size; @@ -158,11 +183,18 @@ namespace OpenNest.Forms PartSpacing = plate.PartSpacing; Quantity = plate.Quantity; Quadrant = plate.Quadrant; + Outline = plate.Outline; } private void Save() { - plate.Size = OpenNest.Geometry.Size.Parse(SizeString); + // The outline carries its own size; a parsed size would drop it. + if (Outline != null) + plate.Outline = Outline; + else + plate.Size = OpenNest.Geometry.Size.Parse(SizeString); + if (Outline == null) + plate.Outline = null; plate.EdgeSpacing.Left = LeftSpacing; plate.EdgeSpacing.Top = TopSpacing; plate.EdgeSpacing.Right = RightSpacing; @@ -187,5 +219,34 @@ namespace OpenNest.Forms { EnableCheck(); } + + /// + /// Reads a sheet outline from a CAD file: the largest closed contour is the sheet and + /// every other closed contour an obstacle, in the file's units (as drawings import). + /// + private void buttonImportOutline_Click(object sender, EventArgs e) + { + using var dialog = new OpenFileDialog + { + Title = "Import Sheet Outline", + Filter = "CAD Files (*.dxf;*.dwg)|*.dxf;*.dwg|DXF Files (*.dxf)|*.dxf|DWG Files (*.dwg)|*.dwg", + }; + if (dialog.ShowDialog(this) != DialogResult.OK) + return; + try + { + Outline = SheetOutline.FromGeometry(CadImporter.Import(dialog.FileName).Entities); + } + catch (Exception exception) when (exception is ArgumentException or IOException or InvalidOperationException) + { + MessageBox.Show(this, $"The file does not hold a sheet outline: {exception.Message}", + "Import Sheet Outline", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void buttonClearOutline_Click(object sender, EventArgs e) + { + Outline = null; + } } }