feat: add ContourInfo model with shape classification logic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ContourInfo> Classify(List<Shape> shapes)
|
||||||
|
{
|
||||||
|
if (shapes.Count == 0)
|
||||||
|
return new List<ContourInfo>();
|
||||||
|
|
||||||
|
// 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<ContourInfo>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Shape>
|
||||||
|
{
|
||||||
|
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<Shape>
|
||||||
|
{
|
||||||
|
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<Shape>
|
||||||
|
{
|
||||||
|
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<Shape>
|
||||||
|
{
|
||||||
|
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<Shape>
|
||||||
|
{
|
||||||
|
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<Shape>
|
||||||
|
{
|
||||||
|
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<Shape> { 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user