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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Fable 5.1 committed 2026-10-28 22:16:36 -04:00
1 parent 360c7136dd
commit 662d3daf8c
5 files changed
+257 -1

No files matched your search

@@ -0,0 +1,45 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using OpenNest.Controls;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Controls;
/// <summary>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.</summary>
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.");
}
@@ -0,0 +1,61 @@
using System.Windows.Forms;
using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Forms;
/// <summary>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.</summary>
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.");
}
+50
View File
@@ -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);
/// <summary>
/// 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.
/// </summary>
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<Vector> 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
+39
View File
@@ -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;
+62 -1
View File
@@ -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; }
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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).
/// </summary>
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;
}
}
}