From cb446e1057eb336bab436248e6b820151572525b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 31 Mar 2026 21:26:38 -0400 Subject: [PATCH] feat: add ContourInfo model with shape classification logic Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Core/Converters/ContourInfo.cs | 132 +++++++++++++++++ OpenNest.Tests/ContourClassificationTests.cs | 143 +++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 OpenNest.Core/Converters/ContourInfo.cs create mode 100644 OpenNest.Tests/ContourClassificationTests.cs diff --git a/OpenNest.Core/Converters/ContourInfo.cs b/OpenNest.Core/Converters/ContourInfo.cs new file mode 100644 index 0000000..57d5f24 --- /dev/null +++ b/OpenNest.Core/Converters/ContourInfo.cs @@ -0,0 +1,132 @@ +using OpenNest.Geometry; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Converters +{ + public enum ContourClassification + { + Perimeter, + Hole, + Etch, + Open + } + + public sealed class ContourInfo + { + public Shape Shape { get; } + public ContourClassification Type { get; private set; } + public string Label { get; private set; } + + private ContourInfo(Shape shape, ContourClassification type, string label) + { + Shape = shape; + Type = type; + Label = label; + } + + public string DirectionLabel + { + get + { + if (Type == ContourClassification.Open || Type == ContourClassification.Etch) + return "Open"; + var poly = Shape.ToPolygon(); + if (poly == null || poly.Vertices.Count < 3) + return "?"; + return poly.RotationDirection() == RotationType.CW ? "CW" : "CCW"; + } + } + + public string DimensionLabel + { + get + { + if (Shape.Entities.Count == 1 && Shape.Entities[0] is Circle c) + return $"Circle R{c.Radius:0.#}"; + Shape.UpdateBounds(); + var box = Shape.BoundingBox; + return $"{box.Width:0.#} x {box.Length:0.#}"; + } + } + + public void Reverse() + { + Shape.Reverse(); + } + + public static List Classify(List shapes) + { + if (shapes.Count == 0) + return new List(); + + // Ensure bounding boxes are up to date before comparing + foreach (var s in shapes) + s.UpdateBounds(); + + // Find perimeter — largest bounding box area + var perimeterIndex = 0; + var maxArea = shapes[0].BoundingBox.Area(); + for (var i = 1; i < shapes.Count; i++) + { + var area = shapes[i].BoundingBox.Area(); + if (area > maxArea) + { + maxArea = area; + perimeterIndex = i; + } + } + + var result = new List(); + var holeCount = 0; + var etchCount = 0; + var openCount = 0; + + // Non-perimeter shapes first (matches CNC cut order: holes before perimeter) + for (var i = 0; i < shapes.Count; i++) + { + if (i == perimeterIndex) continue; + var shape = shapes[i]; + var type = ClassifyShape(shape); + + string label; + switch (type) + { + case ContourClassification.Hole: + holeCount++; + label = $"Hole {holeCount}"; + break; + case ContourClassification.Etch: + etchCount++; + label = etchCount == 1 ? "Etch" : $"Etch {etchCount}"; + break; + default: + openCount++; + label = openCount == 1 ? "Open" : $"Open {openCount}"; + break; + } + + result.Add(new ContourInfo(shape, type, label)); + } + + // Perimeter last + result.Add(new ContourInfo(shapes[perimeterIndex], ContourClassification.Perimeter, "Perimeter")); + + return result; + } + + private static ContourClassification ClassifyShape(Shape shape) + { + // Check etch layer — all entities must be on ETCH layer + if (shape.Entities.Count > 0 && + shape.Entities.All(e => string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase))) + return ContourClassification.Etch; + + if (shape.IsClosed()) + return ContourClassification.Hole; + + return ContourClassification.Open; + } + } +} diff --git a/OpenNest.Tests/ContourClassificationTests.cs b/OpenNest.Tests/ContourClassificationTests.cs new file mode 100644 index 0000000..67281e8 --- /dev/null +++ b/OpenNest.Tests/ContourClassificationTests.cs @@ -0,0 +1,143 @@ +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class ContourClassificationTests +{ + private static Shape MakeRectShape(double x, double y, double w, double h) + { + var shape = new Shape(); + shape.Entities.Add(new Line(new Vector(x, y), new Vector(x + w, y))); + shape.Entities.Add(new Line(new Vector(x + w, y), new Vector(x + w, y + h))); + shape.Entities.Add(new Line(new Vector(x + w, y + h), new Vector(x, y + h))); + shape.Entities.Add(new Line(new Vector(x, y + h), new Vector(x, y))); + return shape; + } + + private static Shape MakeCircleShape(double cx, double cy, double r) + { + var shape = new Shape(); + shape.Entities.Add(new Circle(new Vector(cx, cy), r)); + return shape; + } + + private static Shape MakeEtchShape() + { + var etchLayer = new Layer("ETCH"); + var shape = new Shape(); + shape.Entities.Add(new Line(new Vector(10, 10), new Vector(50, 10)) { Layer = etchLayer }); + return shape; + } + + [Fact] + public void Classify_identifies_largest_shape_as_perimeter() + { + var shapes = new List + { + MakeCircleShape(25, 25, 5), + MakeRectShape(0, 0, 100, 50), + MakeCircleShape(75, 25, 5), + }; + + var contours = ContourInfo.Classify(shapes); + + Assert.Equal(3, contours.Count); + Assert.Single(contours, c => c.Type == ContourClassification.Perimeter); + var perimeter = contours.First(c => c.Type == ContourClassification.Perimeter); + Assert.Same(shapes[1], perimeter.Shape); + } + + [Fact] + public void Classify_identifies_closed_non_perimeter_as_holes() + { + var shapes = new List + { + MakeCircleShape(25, 25, 5), + MakeRectShape(0, 0, 100, 50), + MakeCircleShape(75, 25, 5), + }; + + var contours = ContourInfo.Classify(shapes); + + var holes = contours.Where(c => c.Type == ContourClassification.Hole).ToList(); + Assert.Equal(2, holes.Count); + } + + [Fact] + public void Classify_identifies_etch_layer_shapes() + { + var shapes = new List + { + MakeRectShape(0, 0, 100, 50), + MakeEtchShape(), + }; + + var contours = ContourInfo.Classify(shapes); + + Assert.Single(contours, c => c.Type == ContourClassification.Etch); + } + + [Fact] + public void Classify_identifies_open_shapes() + { + var openShape = new Shape(); + openShape.Entities.Add(new Line(new Vector(0, 0), new Vector(10, 0))); + openShape.Entities.Add(new Line(new Vector(10, 0), new Vector(10, 5))); + // Not closed — doesn't return to (0,0) + + var shapes = new List + { + MakeRectShape(0, 0, 100, 50), + openShape, + }; + + var contours = ContourInfo.Classify(shapes); + + Assert.Single(contours, c => c.Type == ContourClassification.Open); + } + + [Fact] + public void Classify_orders_holes_first_perimeter_last() + { + var shapes = new List + { + MakeRectShape(0, 0, 100, 50), + MakeCircleShape(25, 25, 5), + }; + + var contours = ContourInfo.Classify(shapes); + + Assert.Equal(ContourClassification.Hole, contours[0].Type); + Assert.Equal(ContourClassification.Perimeter, contours[^1].Type); + } + + [Fact] + public void Classify_labels_holes_sequentially() + { + var shapes = new List + { + MakeRectShape(0, 0, 100, 50), + MakeCircleShape(25, 25, 5), + MakeCircleShape(75, 25, 5), + }; + + var contours = ContourInfo.Classify(shapes); + + var holes = contours.Where(c => c.Type == ContourClassification.Hole).ToList(); + Assert.Equal("Hole 1", holes[0].Label); + Assert.Equal("Hole 2", holes[1].Label); + } + + [Fact] + public void Classify_single_shape_is_perimeter() + { + var shapes = new List { MakeRectShape(0, 0, 50, 30) }; + + var contours = ContourInfo.Classify(shapes); + + Assert.Single(contours); + Assert.Equal(ContourClassification.Perimeter, contours[0].Type); + Assert.Equal("Perimeter", contours[0].Label); + } +}