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;
+ }
}
}