feat(cad-converter): add a Hole Table window that counts and colors holes by size

This commit is contained in:
aj committed 2026-10-31 05:50:22 -04:00
1 parent 45ab837c8c
commit ad5f2a9041
9 files changed
+566 -1

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Dimensions
{
/// <summary>One hole diameter in a <see cref="HoleTable"/> and every hole cut at that size.</summary>
public sealed class HoleSize
{
public HoleSize(double diameter, IReadOnlyList<Vector> centers, IReadOnlyList<Guid> entityIds)
{
Diameter = diameter;
Centers = centers;
EntityIds = entityIds;
}
/// <summary>The diameter, rounded to <see cref="HoleTable.Decimals"/> places.</summary>
public double Diameter { get; }
public int Quantity => Centers.Count;
public IReadOnlyList<Vector> Centers { get; }
/// <summary>The IDs of every entity that makes up a hole of this size.</summary>
public IReadOnlyList<Guid> EntityIds { get; }
public string DiameterText => "\u00D8" + Diameter.ToString("0.0000", System.Globalization.CultureInfo.InvariantCulture);
}
/// <summary>
/// Counts the round holes in a drawing by diameter. A round hole is a cut circle, or a closed
/// contour made only of arcs on one center and radius, that lies inside another cut contour, so
/// the outline of a round part is not a hole. Marks (scribe, etch, rapids, break cuts) are not
/// holes. Pass the entities as they will be cut (visible, with roles applied).
/// </summary>
public static class HoleTable
{
/// <summary>Holes whose diameters agree to this many decimal places share a row.</summary>
public const int Decimals = 4;
public static IReadOnlyList<HoleSize> Build(IEnumerable<Entity> entities)
{
var cut = entities
.Where(e => SpecialLayers.IsMaterial(e.Layer) && !ScribeLayerRepair.IsMarkLayer(e.Layer))
.ToList();
if (cut.Count == 0)
return Array.Empty<HoleSize>();
var shapes = ShapeBuilder.GetShapes(cut);
var holes = new List<(double Diameter, Vector Center, List<Guid> Ids)>();
foreach (var shape in shapes)
{
if (!TryRound(shape, out var center, out var radius))
continue;
var box = shape.BoundingBox;
var area = box.Area();
if (!shapes.Any(other => !ReferenceEquals(other, shape)
&& other.BoundingBox.Area() > area + Tolerance.Epsilon
&& other.BoundingBox.Contains(box)))
continue;
holes.Add((System.Math.Round(radius * 2, Decimals), center, shape.Entities.Select(e => e.Id).ToList()));
}
return holes
.GroupBy(h => h.Diameter)
.OrderBy(g => g.Key)
.Select(g => new HoleSize(
g.Key,
g.Select(h => h.Center).ToList(),
g.SelectMany(h => h.Ids).ToList()))
.ToList();
}
private static bool TryRound(Shape shape, out Vector center, out double radius)
{
center = default;
radius = 0;
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
{
center = circle.Center;
radius = circle.Radius;
return radius > 0;
}
if (shape.Entities.Count == 0 || !shape.Entities.All(e => e is Arc) || !shape.IsClosed())
return false;
var arcs = shape.Entities.Cast<Arc>().ToList();
var first = arcs[0];
var tolerance = Tolerance.ChainTolerance;
if (arcs.Any(a => a.Center.DistanceTo(first.Center) > tolerance
|| System.Math.Abs(a.Radius - first.Radius) > tolerance))
return false;
if (System.Math.Abs(arcs.Sum(a => a.SweepAngle()) - Angle.TwoPI) > 1e-6)
return false;
center = first.Center;
radius = first.Radius;
return radius > 0;
}
}
}
+101
View File
@@ -0,0 +1,101 @@
using OpenNest.Dimensions;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests.Dimensions;
public class HoleTableTests
{
private static readonly Layer Cut = new("0") { IsVisible = true };
private static List<Entity> Rectangle(double width, double height) =>
[
new Line(0, 0, width, 0) { Layer = Cut },
new Line(width, 0, width, height) { Layer = Cut },
new Line(width, height, 0, height) { Layer = Cut },
new Line(0, height, 0, 0) { Layer = Cut },
];
private static Circle Hole(double x, double y, double diameter, Layer layer = null) =>
new(x, y, diameter / 2) { Layer = layer ?? Cut };
[Fact]
public void Build_GroupsHolesByDiameter_SmallestFirst_WithEveryEntityId()
{
var a = Hole(1, 1, 0.5);
var b = Hole(3, 1, 0.5);
var c = Hole(5, 1, 0.25);
var entities = Rectangle(10, 4);
entities.AddRange([a, b, c]);
var table = HoleTable.Build(entities);
Assert.Equal(2, table.Count);
Assert.Equal(0.25, table[0].Diameter, 12);
Assert.Equal(1, table[0].Quantity);
Assert.Equal([c.Id], table[0].EntityIds);
Assert.Equal(0.5, table[1].Diameter, 12);
Assert.Equal(2, table[1].Quantity);
Assert.Equal(new[] { a.Id, b.Id }.Order(), table[1].EntityIds.Order());
Assert.Equal("\u00D80.5000", table[1].DiameterText);
}
[Fact]
public void Build_CountsAHoleDrawnAsTwoArcs_AsOneHole()
{
var top = new Arc(5, 2, 0.375, 0, Angle.ToRadians(180)) { Layer = Cut };
var bottom = new Arc(5, 2, 0.375, Angle.ToRadians(180), Angle.TwoPI) { Layer = Cut };
var entities = Rectangle(10, 4);
entities.AddRange([top, bottom]);
var table = HoleTable.Build(entities);
var size = Assert.Single(table);
Assert.Equal(0.75, size.Diameter, 12);
Assert.Equal(1, size.Quantity);
Assert.Equal(new[] { top.Id, bottom.Id }.Order(), size.EntityIds.Order());
}
[Fact]
public void Build_SharesARow_WhenDiametersAgreeToFourPlaces()
{
var entities = Rectangle(10, 4);
entities.AddRange([Hole(1, 1, 0.25001), Hole(3, 1, 0.24999), Hole(5, 1, 0.2502)]);
var table = HoleTable.Build(entities);
Assert.Equal([2, 1], table.Select(s => s.Quantity));
Assert.Equal(0.25, table[0].Diameter, 12);
Assert.Equal(0.2502, table[1].Diameter, 12);
}
[Fact]
public void Build_DoesNotCountTheOutlineOfARoundPart_OrMarks_OrSlots()
{
var outline = Hole(0, 0, 6);
var hole = Hole(0, 0, 1);
var etched = Hole(1.5, 0, 0.5, SpecialLayers.Scribe);
var slot = new List<Entity>
{
new Arc(-2, 1, 0.25, Angle.ToRadians(90), Angle.ToRadians(270)) { Layer = Cut },
new Line(-2, 0.75, -1, 0.75) { Layer = Cut },
new Arc(-1, 1, 0.25, Angle.ToRadians(270), Angle.ToRadians(90)) { Layer = Cut },
new Line(-1, 1.25, -2, 1.25) { Layer = Cut },
};
var entities = new List<Entity> { outline, hole, etched };
entities.AddRange(slot);
var table = HoleTable.Build(entities);
var size = Assert.Single(table);
Assert.Equal(1, size.Diameter, 12);
Assert.Equal([hole.Id], size.EntityIds);
}
[Fact]
public void Build_IsEmpty_WithNoGeometry()
{
Assert.Empty(HoleTable.Build([]));
Assert.Empty(HoleTable.Build(Rectangle(4, 4)));
}
}
@@ -0,0 +1,86 @@
using System.Drawing;
using System.Reflection;
using System.Windows.Forms;
using OpenNest.Bending;
using OpenNest.Controls;
using OpenNest.Forms;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.WinForms.Tests.Forms;
/// <summary>
/// The CAD Converter's Hole Table on a 10 x 6 plate with two 0.5 holes and one 0.25 hole.
/// </summary>
public class CadConverterHoleTableTests
{
[Fact]
public void Holes_ListsEachSizeWithItsQuantity_AndColorsItsHolesOnTheView() => ArchUnitsTests.RunSta(() =>
{
using var form = new CadConverterForm();
Show(form);
form.LoadDrawings([Part()]);
var view = Field<EntityView>(form, "entityView1");
var item = Field<FileListControl>(form, "fileList").SelectedItem!;
var circles = item.Entities.OfType<Circle>().ToList();
var large = circles.Where(c => c.Radius > 0.2).Select(c => c.Id).ToList();
var small = circles.Single(c => c.Radius < 0.2).Id;
Assert.Null(view.HoleColors);
Field<Button>(form, "btnHoleTable").PerformClick();
var table = Assert.Single(form.OwnedForms.OfType<HoleTableForm>());
Assert.True(table.Visible);
var grid = table.Controls.OfType<DataGridView>().Single();
Assert.Equal(
new[] { ("\u00D80.2500", "1"), ("\u00D80.5000", "2") },
grid.Rows.Cast<DataGridViewRow>().Select(r => (r.Cells[1].Value!.ToString()!, r.Cells[2].Value!.ToString()!)));
// Each size's holes take its row's swatch color; the outline keeps its own.
Assert.Equal(3, view.HoleColors!.Count);
Assert.Equal(grid.Rows[0].Cells[0].Style.BackColor, view.HoleColors[small]);
Assert.All(large, id => Assert.Equal(grid.Rows[1].Cells[0].Style.BackColor, view.HoleColors[id]));
Assert.NotEqual(view.HoleColors[small], view.HoleColors[large[0]]);
// A hole set to Display is no longer cut, so it leaves the table.
Invoke(form, "OnEntityActionRequested", null,
new EntityActionEventArgs(EntityAction.Display, [circles.First(c => c.Id == large[0])]));
Assert.Equal("1", grid.Rows[1].Cells[2].Value!.ToString());
Assert.False(view.HoleColors.ContainsKey(large[0]));
// Closing the table restores the view's normal colors.
table.Close();
Application.DoEvents();
Assert.Null(view.HoleColors);
form.Close();
});
private static Drawing Part()
{
var entities = new List<Entity>
{
new Line(0, 0, 10, 0), new Line(10, 0, 10, 6), new Line(10, 6, 0, 6), new Line(0, 6, 0, 0),
new Circle(2, 3, 0.25), new Circle(5, 3, 0.25), new Circle(8, 3, 0.125),
};
var layer = new Layer("0") { IsVisible = true };
foreach (var e in entities)
e.Layer = layer;
return CadImporter.BuildDrawing(new CadImportResult { Entities = entities, Name = "holes" }, entities,
new List<Bend>(), 1, null, null);
}
private static void Show(Form form)
{
form.WindowState = FormWindowState.Normal;
form.StartPosition = FormStartPosition.Manual;
form.Location = new Point(0, 0);
form.Size = new System.Drawing.Size(1200, 800);
form.Show();
}
private static T Field<T>(object owner, string name) =>
(T)typeof(CadConverterForm).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!;
private static void Invoke(CadConverterForm form, string name, params object?[] args) =>
typeof(CadConverterForm).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(form, args);
}
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using OpenNest.Geometry;
namespace OpenNest.Controls
{
public partial class EntityView
{
private const float HolePenWidth = 2.5f;
private readonly Dictionary<int, Pen> holePenCache = new Dictionary<int, Pen>();
private IReadOnlyDictionary<Guid, Color> holeColors;
/// <summary>
/// While the Hole Table is open, the color of each hole's entities by entity ID: holes are
/// drawn in their size's color with a heavier line. Null draws every entity normally.
/// </summary>
public IReadOnlyDictionary<Guid, Color> HoleColors
{
get => holeColors;
set
{
holeColors = value;
Invalidate();
}
}
private Pen HolePen(Entity entity)
{
if (holeColors == null || !holeColors.TryGetValue(entity.Id, out var color))
return null;
var argb = color.ToArgb();
if (!holePenCache.TryGetValue(argb, out var pen))
{
pen = new Pen(color, HolePenWidth);
holePenCache[argb] = pen;
}
return pen;
}
private void DisposeHolePens()
{
foreach (var pen in holePenCache.Values)
pen.Dispose();
holePenCache.Clear();
}
}
}
+2 -1
View File
@@ -142,7 +142,7 @@ namespace OpenNest.Controls
simplifierHighlightSet != null && simplifierHighlightSet.Contains(entity);
var pen = isHighlighted
? GetEntityPen(Color.FromArgb(60, entity.Color))
: GetEntityPen(entity.Color);
: HolePen(entity) ?? GetEntityPen(entity.Color);
DrawEntity(e.Graphics, entity, pen);
}
@@ -514,6 +514,7 @@ namespace OpenNest.Controls
if (disposing)
{
ClearPenCache();
DisposeHolePens();
gridPen.Dispose();
labelFont.Dispose();
dimensionFont.Dispose();
+16
View File
@@ -32,6 +32,7 @@ namespace OpenNest.Forms
btnSplit = new System.Windows.Forms.Button();
btnSimplify = new System.Windows.Forms.Button();
btnExportDxf = new System.Windows.Forms.Button();
btnHoleTable = new System.Windows.Forms.Button();
chkShowOriginal = new System.Windows.Forms.CheckBox();
chkLabels = new System.Windows.Forms.CheckBox();
lblDetect = new System.Windows.Forms.Label();
@@ -171,6 +172,7 @@ namespace OpenNest.Forms
detailBar.Controls.Add(btnSplit);
detailBar.Controls.Add(btnSimplify);
detailBar.Controls.Add(btnExportDxf);
detailBar.Controls.Add(btnHoleTable);
detailBar.Controls.Add(chkShowOriginal);
detailBar.Controls.Add(chkLabels);
detailBar.Controls.Add(lblDetect);
@@ -286,6 +288,19 @@ namespace OpenNest.Forms
btnExportDxf.Text = "Export DXF";
btnExportDxf.Click += OnExportDxfClick;
//
// btnHoleTable
//
btnHoleTable.AutoSize = true;
btnHoleTable.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
btnHoleTable.Font = new System.Drawing.Font("Segoe UI", 9F);
btnHoleTable.Location = new System.Drawing.Point(530, 6);
btnHoleTable.Margin = new System.Windows.Forms.Padding(0, 0, 10, 0);
btnHoleTable.Name = "btnHoleTable";
btnHoleTable.Size = new System.Drawing.Size(60, 27);
btnHoleTable.TabIndex = 8;
btnHoleTable.Text = "Holes...";
btnHoleTable.Click += OnHoleTableClick;
//
// chkShowOriginal
//
chkShowOriginal.AutoSize = true;
@@ -431,6 +446,7 @@ namespace OpenNest.Forms
private System.Windows.Forms.Button btnSplit;
private System.Windows.Forms.Button btnSimplify;
private System.Windows.Forms.Button btnExportDxf;
private System.Windows.Forms.Button btnHoleTable;
private System.Windows.Forms.CheckBox chkShowOriginal;
private System.Windows.Forms.CheckBox chkLabels;
private System.Windows.Forms.ComboBox cboBendDetector;
+39
View File
@@ -263,6 +263,7 @@ namespace OpenNest.Forms
entityView1.ZoomToFit();
CheckSimplifiable(item);
RefreshHoleTable();
}
private void CheckSimplifiable(FileListItem item)
@@ -323,6 +324,7 @@ namespace OpenNest.Forms
entityView1.Entities.Clear();
entityView1.DimensionLayouts = Array.Empty<DimensionLayout>();
entityView1.Invalidate();
RefreshHoleTable();
}
private void OnFilterChanged(object sender, EventArgs e)
@@ -337,6 +339,7 @@ namespace OpenNest.Forms
SyncSuppressedState(item);
RefreshBreakoffs(item);
RefreshTabbedCutouts(item);
RefreshHoleTable();
staleProgram = true;
}
@@ -1287,6 +1290,42 @@ namespace OpenNest.Forms
lblEntityCount.Text = $"{entities.Count} entities";
ResetSimplifyButton();
RefreshHoleTable();
}
private HoleTableForm holeTable;
private void OnHoleTableClick(object sender, EventArgs e)
{
if (holeTable == null || holeTable.IsDisposed)
{
holeTable = new HoleTableForm();
var corner = entityView1.PointToScreen(new Point(entityView1.Width, 0));
holeTable.Location = new Point(corner.X - holeTable.Width - 12, corner.Y + 12);
holeTable.FormClosed += (_, _) =>
{
holeTable = null;
entityView1.HoleColors = null;
};
holeTable.Show(this);
}
else
{
holeTable.Activate();
}
RefreshHoleTable();
}
// Lists the current drawing's round holes as they will be cut and colors them on the view.
private void RefreshHoleTable()
{
if (holeTable == null || holeTable.IsDisposed)
return;
var item = CurrentItem;
var sizes = item == null ? Array.Empty<HoleSize>() : HoleTable.Build(VisibleEntities(item));
entityView1.HoleColors = holeTable.ShowSizes(sizes, item?.Name);
}
private void OnShowOriginalChanged(object sender, EventArgs e)
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Dimensions;
namespace OpenNest.Forms
{
/// <summary>
/// A small tool window listing the current drawing's round holes by diameter, with the
/// quantity of each and the color its holes are drawn in on the CAD view.
/// </summary>
public class HoleTableForm : Form
{
// Bright on the CAD view's dark background and clear of the reserved colors: magenta
// (etch), cyan (display), yellow (bends) and white (ordinary cut entities).
private static readonly Color[] Palette =
{
Color.FromArgb(255, 85, 85),
Color.FromArgb(90, 220, 90),
Color.FromArgb(80, 160, 255),
Color.FromArgb(255, 170, 40),
Color.FromArgb(0, 205, 175),
Color.FromArgb(190, 140, 255),
Color.FromArgb(255, 140, 190),
Color.FromArgb(175, 215, 60),
Color.FromArgb(205, 135, 85),
};
private readonly DataGridView grid;
private readonly Label summary;
public HoleTableForm()
{
Text = "Hole Table";
FormBorderStyle = FormBorderStyle.SizableToolWindow;
ShowInTaskbar = false;
StartPosition = FormStartPosition.Manual;
Font = new Font("Segoe UI", 9F);
ClientSize = new Size(240, 260);
MinimumSize = new Size(200, 150);
grid = new DataGridView
{
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
AllowUserToResizeRows = false,
RowHeadersVisible = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
BackgroundColor = SystemColors.Window,
BorderStyle = BorderStyle.None,
TabStop = false,
};
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Color",
HeaderText = "",
FillWeight = 20,
SortMode = DataGridViewColumnSortMode.NotSortable,
});
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Size",
HeaderText = "Diameter",
FillWeight = 50,
SortMode = DataGridViewColumnSortMode.NotSortable,
});
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Quantity",
HeaderText = "Qty",
FillWeight = 30,
SortMode = DataGridViewColumnSortMode.NotSortable,
DefaultCellStyle = { Alignment = DataGridViewContentAlignment.MiddleRight },
});
summary = new Label
{
Dock = DockStyle.Bottom,
Height = 40,
Padding = new Padding(4, 2, 4, 2),
ForeColor = SystemColors.GrayText,
};
Controls.Add(grid);
Controls.Add(summary);
}
/// <summary>The color of the hole size at <paramref name="index"/> in the table.</summary>
public static Color ColorFor(int index)
{
if (index < Palette.Length)
return Palette[index];
// Past the palette, step the hue by the golden angle so neighbors stay apart.
var hue = (index * 137.508) % 360;
return FromHsv(hue, 0.6, 1.0);
}
/// <summary>
/// Shows <paramref name="sizes"/> and returns each hole entity's color for the CAD view.
/// </summary>
public IReadOnlyDictionary<Guid, Color> ShowSizes(IReadOnlyList<HoleSize> sizes, string drawingName)
{
var colors = new Dictionary<Guid, Color>();
grid.Rows.Clear();
for (var i = 0; i < sizes.Count; i++)
{
var color = ColorFor(i);
var row = grid.Rows[grid.Rows.Add("", sizes[i].DiameterText, sizes[i].Quantity)];
var swatch = row.Cells[0].Style;
swatch.BackColor = color;
swatch.SelectionBackColor = color;
foreach (var id in sizes[i].EntityIds)
colors[id] = color;
}
grid.ClearSelection();
var total = sizes.Sum(s => s.Quantity);
Text = string.IsNullOrEmpty(drawingName) ? "Hole Table" : $"Hole Table - {drawingName}";
summary.Text = sizes.Count == 0
? "No round holes. Slots and other shapes are not listed."
: $"{total} hole{(total == 1 ? "" : "s")}, {sizes.Count} size{(sizes.Count == 1 ? "" : "s")}. "
+ "Round holes only, in drawing units.";
return colors;
}
private static Color FromHsv(double hue, double saturation, double value)
{
var c = value * saturation;
var x = c * (1 - System.Math.Abs((hue / 60) % 2 - 1));
var m = value - c;
var (r, g, b) = (int)(hue / 60) switch
{
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
return Color.FromArgb((int)((r + m) * 255), (int)((g + m) * 255), (int)((b + m) * 255));
}
}
}
+19
View File
@@ -50,4 +50,23 @@ they do not appear on plates, in PDFs or in DXF export.
with an `R` prefix and diameter labels with `Ø`.
- Split pieces start with no dimensions, like breakoffs and tabbed cutouts.
## Hole table
**Holes...** in the CAD Converter's bottom bar opens a small Hole Table window beside the
view. It lists the drawing's round holes by diameter, smallest first, with the quantity of
each and a color swatch. While it is open, every hole on the view is drawn in its size's color
with a heavier line; closing the window restores the normal colors. The table follows the
selected file and updates when entities are cut, etched, set to display, excluded or
simplified.
- A round hole is a cut circle, or a closed contour made only of arcs on one center and
radius, that lies inside another cut contour. The outline of a round part is not a hole.
Slots and other shapes are not listed.
- Holes are read from the entities as they will be cut (visible, with roles applied), before
breakoffs and tabbed cutouts. Etched, display-only, excluded and hidden circles are not
listed.
- Diameters are rounded to four decimals, in the drawing's units; holes that agree to four
decimals share a row. `HoleTable` (`OpenNest.Core/Dimensions`) builds the rows.
- The table is a view only: it stores nothing in the drawing and never affects the program.
File format: see [drawing dimensions](nest-file-format.md#drawing-dimensions).