From ad5f2a9041ee14d0a85aab00e0c8a9f5082d5b6b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 31 Oct 2026 05:50:22 -0400 Subject: [PATCH] feat(cad-converter): add a Hole Table window that counts and colors holes by size --- OpenNest.Core/Dimensions/HoleTable.cs | 105 ++++++++++++ OpenNest.Tests/Dimensions/HoleTableTests.cs | 101 ++++++++++++ .../Forms/CadConverterHoleTableTests.cs | 86 ++++++++++ OpenNest/Controls/EntityView.Holes.cs | 49 ++++++ OpenNest/Controls/EntityView.cs | 3 +- OpenNest/Forms/CadConverterForm.Designer.cs | 16 ++ OpenNest/Forms/CadConverterForm.cs | 39 +++++ OpenNest/Forms/HoleTableForm.cs | 149 ++++++++++++++++++ docs/dimensions.md | 19 +++ 9 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 OpenNest.Core/Dimensions/HoleTable.cs create mode 100644 OpenNest.Tests/Dimensions/HoleTableTests.cs create mode 100644 OpenNest.WinForms.Tests/Forms/CadConverterHoleTableTests.cs create mode 100644 OpenNest/Controls/EntityView.Holes.cs create mode 100644 OpenNest/Forms/HoleTableForm.cs 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