diff --git a/OpenNest.Core/Dimensions/HoleTable.cs b/OpenNest.Core/Dimensions/HoleTable.cs
new file mode 100644
index 00000000..ff105ee3
--- /dev/null
+++ b/OpenNest.Core/Dimensions/HoleTable.cs
@@ -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
+{
+ /// One hole diameter in a and every hole cut at that size.
+ public sealed class HoleSize
+ {
+ public HoleSize(double diameter, IReadOnlyList centers, IReadOnlyList entityIds)
+ {
+ Diameter = diameter;
+ Centers = centers;
+ EntityIds = entityIds;
+ }
+
+ /// The diameter, rounded to places.
+ public double Diameter { get; }
+
+ public int Quantity => Centers.Count;
+
+ public IReadOnlyList Centers { get; }
+
+ /// The IDs of every entity that makes up a hole of this size.
+ public IReadOnlyList EntityIds { get; }
+
+ public string DiameterText => "\u00D8" + Diameter.ToString("0.0000", System.Globalization.CultureInfo.InvariantCulture);
+ }
+
+ ///
+ /// 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).
+ ///
+ public static class HoleTable
+ {
+ /// Holes whose diameters agree to this many decimal places share a row.
+ public const int Decimals = 4;
+
+ public static IReadOnlyList Build(IEnumerable entities)
+ {
+ var cut = entities
+ .Where(e => SpecialLayers.IsMaterial(e.Layer) && !ScribeLayerRepair.IsMarkLayer(e.Layer))
+ .ToList();
+ if (cut.Count == 0)
+ return Array.Empty();
+
+ var shapes = ShapeBuilder.GetShapes(cut);
+ var holes = new List<(double Diameter, Vector Center, List 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().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;
+ }
+ }
+}
diff --git a/OpenNest.Tests/Dimensions/HoleTableTests.cs b/OpenNest.Tests/Dimensions/HoleTableTests.cs
new file mode 100644
index 00000000..41bbc136
--- /dev/null
+++ b/OpenNest.Tests/Dimensions/HoleTableTests.cs
@@ -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 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
+ {
+ 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 { 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)));
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Forms/CadConverterHoleTableTests.cs b/OpenNest.WinForms.Tests/Forms/CadConverterHoleTableTests.cs
new file mode 100644
index 00000000..8045a502
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Forms/CadConverterHoleTableTests.cs
@@ -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;
+
+///
+/// The CAD Converter's Hole Table on a 10 x 6 plate with two 0.5 holes and one 0.25 hole.
+///
+public class CadConverterHoleTableTests
+{
+ [Fact]
+ public void Holes_ListsEachSizeWithItsQuantity_AndColorsItsHolesOnTheView() => ArchUnitsTests.RunSta(() =>
+ {
+ using var form = new CadConverterForm();
+ Show(form);
+ form.LoadDrawings([Part()]);
+ var view = Field(form, "entityView1");
+ var item = Field(form, "fileList").SelectedItem!;
+ var circles = item.Entities.OfType().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