diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs new file mode 100644 index 0000000..9bb63cd --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs @@ -0,0 +1,35 @@ +using System; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingPlanning; + +/// Automatic start preference, ordered: a lower value is more preferred. +internal enum AutomaticEntryKind +{ + /// A convex turn of the contour's own travel, classified exactly as emission classifies it. + ConvexCorner = 0, + + /// The midpoint of a straight edge. + StraightMidpoint = 1, + + /// A tangent line/arc joint. A collinear line/line split is not a joint and never appears. + TangentJoint = 2, +} + +/// +/// One automatic start candidate: the owned contour choice plus its preference kind and a +/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as +/// owner; nothing here exposes or mutates the underlying shape. +/// +internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind) +{ + /// + /// Stable geometric tie key: the point quantized to the preparation epsilon grid, so + /// equal points rank together regardless of the entity that produced them. + /// + internal (long X, long Y) GeometryKey => (Quantize(Choice.Point.X), Quantize(Choice.Point.Y)); + + private static long Quantize(double value) => + (long)System.Math.Round(value / PostVerificationGeometry.Epsilon); +} diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index 377ba77..47f6d27 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -156,6 +156,93 @@ public sealed class PreparedContours return choice; } + /// + /// The uncapped preferred automatic start catalogue for one contour, in preference then + /// contour-travel order: convex corners of the contour's own winding, then straight-edge + /// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear + /// line/line splits, circles and interior points never appear; each geometric point is + /// reported once, keeping the most preferred kind. A pure-arc contour can have no + /// preferred point at all — the S04 fallback catalogue supplies those. Manual entry + /// through / is unaffected. + /// + internal IReadOnlyList AutomaticEntryCandidates(int contourOrdinal, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + var shape = GetShape(contourOrdinal); + if (shape.Entities.Count == 1 && shape.Entities[0] is Circle) + throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue."); + if (shape.Entities.Count < 2) + throw new ArgumentException("Contour has no vertex to classify."); + + // Collect EVERY eligible candidate first, then deduplicate geometrically; at equal + // points the most preferred kind wins regardless of which entity supplied it. + var found = new List(); + var count = shape.Entities.Count; + for (var i = 0; i < count; i++) + { + token.ThrowIfCancellationRequested(); + var entity = shape.Entities[i]; + + // Vertex reached by travelling along entity i (its end point), reported under + // entity i. The closed contour guarantees every vertex appears exactly once this + // way; each is classified from the contour's own winding. + var vertex = End(entity); + if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)) + { + switch (corner.Kind) + { + case ContourCuttingStrategy.CornerKind.Convex: + Add(i, vertex, AutomaticEntryKind.ConvexCorner); + break; + case ContourCuttingStrategy.CornerKind.Smooth + when entity is Line && Next(i) is Arc: + // A line leaving into an arc: the tangent joint. The reverse travel + // order classifies the same joint from the arc, matched below. + Add(i, vertex, AutomaticEntryKind.TangentJoint); + break; + // Reflex, cusp, collinear splits (smooth line→line) and arc→line joins + // of a plain straight edge are not preferred automatic starts here. + case ContourCuttingStrategy.CornerKind.Smooth + when entity is Arc && Next(i) is Line: + Add(i, vertex, AutomaticEntryKind.TangentJoint); + break; + } + } + + if (entity is Line line) + Add(i, line.MidPoint, AutomaticEntryKind.StraightMidpoint); + } + token.ThrowIfCancellationRequested(); + + // Deduplicate: at equal points the most preferred metadata wins, independent of + // which entity supplied it; the winner's own ordinal and point are kept. + var byPoint = new Dictionary<(long, long), ContourEntryCandidate>(); + var order = new List<(long, long)>(); + foreach (var candidate in found) + { + var key = candidate.GeometryKey; + if (!byPoint.TryGetValue(key, out var existing)) + { + byPoint[key] = candidate; + order.Add(key); + } + else if (candidate.Kind < existing.Kind) + byPoint[key] = candidate; + } + return order + .OrderBy(key => byPoint[key].Kind) + .ThenBy(key => byPoint[key].Choice.EntityOrdinal) + .Select(key => byPoint[key]) + .ToList(); + + Entity Next(int index) => shape.Entities[(index + 1) % count]; + + void Add(int entityOrdinal, Vector point, AutomaticEntryKind kind) + { + found.Add(new ContourEntryCandidate(Entry(contourOrdinal, entityOrdinal, point), kind)); + } + } + /// Emits every contour once in caller order, holes before perimeter, with scribes once. public Program Emit(IReadOnlyList choices) { diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs new file mode 100644 index 0000000..797ed38 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs @@ -0,0 +1,235 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// The uncapped preferred automatic start catalogue: convex corners first, then straight +/// midpoints, then line/arc tangent joints — from the contour's own winding, geometrically +/// deduplicated, never reflex/cusp vertices or collinear splits, and owned immutably. +/// +public class ContourEntryCandidateTests +{ + private static PreparedContours Capture(Program program) => + PreparedContours.Capture(program, ExplicitContourTests.Parameters()); + + // --- fixtures --------------------------------------------------------------- + + private static Program ClosedContour(IEnumerable vertices) + { + var p = new Program(); + p.MoveTo(vertices.First().X, vertices.First().Y); + foreach (var v in vertices.Skip(1).Append(vertices.First())) + p.LineTo(v.X, v.Y); + return p; + } + + /// Square perimeter with an L-notch hole slug; its concave vertex (3,3.5) is reflex for the slug's own travel. + private static Program NotchedHole() + { + var p = ExplicitContourTests.Square(false); // CCW outer square 10x10 + p.MoveTo(2, 2); + p.LineTo(4, 2); p.LineTo(4, 3.5); p.LineTo(3, 3.5); p.LineTo(3, 4); p.LineTo(2, 4); + p.LineTo(2, 2); // closing leg + return p; + } + + // --- positive catalogue ------------------------------------------------------- + + [Fact] + public void Square_ExposesFourCornersThenFourMidpoints() + { + var prepared = Capture(ExplicitContourTests.Square(false)); + + var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal); + + Assert.Equal(8, candidates.Count); + Assert.Equal(4, candidates.Count(c => c.Kind == AutomaticEntryKind.ConvexCorner)); + Assert.Equal(4, candidates.Count(c => c.Kind == AutomaticEntryKind.StraightMidpoint)); + // Preference order: every corner precedes every midpoint. + var kinds = candidates.Select(c => c.Kind).ToList(); + Assert.Equal(kinds.OrderBy(k => k).ToList(), kinds); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReversedWinding_SameCornersAndMidpoints(bool reversed) + { + static List<(AutomaticEntryKind Kind, double X, double Y)> Catalogue(Program program) => + Capture(program).AutomaticEntryCandidates(0) + .Select(c => (Kind: c.Kind, X: System.Math.Round(c.Choice.Point.X, 6), Y: System.Math.Round(c.Choice.Point.Y, 6))) + .OrderBy(x => x.Kind).ThenBy(x => x.X).ThenBy(x => x.Y).ToList(); + + var forward = Catalogue(ExplicitContourTests.Square(false)); + Assert.Equal(forward, Catalogue(ExplicitContourTests.Square(reversed))); + Assert.Equal(8, forward.Count); + } + + [Fact] + public void TangentLineArcJoint_IsTangentJoint_NotACorner() + { + // A half-circle bump on the top edge of a square: joints at (0,10) and (10,10). + var p = new Program(); + p.MoveTo(0, 0); + p.LineTo(10, 0); + p.LineTo(10, 10); + p.ArcTo(new Vector(0, 10), new Vector(5, 10), RotationType.CCW); + p.LineTo(0, 0); + var prepared = Capture(p); + + var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal); + var joints = candidates.Where(c => c.Kind == AutomaticEntryKind.TangentJoint).ToList(); + + Assert.Equal(2, joints.Count); + Assert.Contains(joints, c => Distance(c.Choice.Point, new Vector(10, 10)) < 1e-6); + Assert.Contains(joints, c => Distance(c.Choice.Point, new Vector(0, 10)) < 1e-6); + // The joint point is never also reported as a convex corner. + Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.ConvexCorner + && Distance(c.Choice.Point, new Vector(10, 10)) < 1e-6); + } + + [Fact] + public void CollinearSplit_IsNotATangentJoint_ButBothMidpointsRemain() + { + // Bottom edge split into two collinear lines at (5,0). + var p = ClosedContour(new[] + { + new Vector(0, 0), new Vector(5, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10), + }); + var prepared = Capture(p); + + var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal); + + Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.TangentJoint); + Assert.Equal(4, candidates.Count(c => c.Kind == AutomaticEntryKind.ConvexCorner)); // (10,0),(10,10),(0,10),(0,0) + Assert.Contains(candidates, c => Distance(c.Choice.Point, new Vector(2.5, 0)) < 1e-6); + Assert.Contains(candidates, c => Distance(c.Choice.Point, new Vector(7.5, 0)) < 1e-6); + } + + // --- exclusions --------------------------------------------------------------- + + [Fact] + public void HoleSlugClassifiesCornersFromItsOwnWinding_NotInverted() + { + var prepared = Capture(NotchedHole()); + var hole = 0; // Capture lists holes first; the outer square is the perimeter. + Assert.NotEqual(prepared.PerimeterOrdinal, hole); + + var candidates = prepared.AutomaticEntryCandidates(hole); + + // The slug's five convex corners come from its own travel; the concave vertex (3,3.5) + // is reflex for the slug and must never be enumerated for automatic placement. + Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, new Vector(3, 3.5)) < 1e-6); + var corners = candidates.Where(c => c.Kind == AutomaticEntryKind.ConvexCorner).ToList(); + Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(2, 2)) < 1e-6); + Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(4, 2)) < 1e-6); + Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(2, 4)) < 1e-6); + Assert.Equal(5, candidates.Count(c => c.Kind == AutomaticEntryKind.ConvexCorner)); + Assert.Equal(6, candidates.Count(c => c.Kind == AutomaticEntryKind.StraightMidpoint)); + } + + [Fact] + public void ReflexVertexOfOuterOutline_IsNeverEnumerated() + { + // L-shaped outline: (5,5) is reflex for the part's own CCW travel. + var p = ClosedContour(new[] + { + new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(5, 10), + new Vector(5, 5), new Vector(0, 5), + }); + var prepared = Capture(p); + + var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal); + + Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, new Vector(5, 5)) < 1e-6); + Assert.Equal(5, candidates.Count(c => c.Kind == AutomaticEntryKind.ConvexCorner)); + } + + [Fact] + public void Circle_HasNoPreferredCatalogue() + { + var p = new Program(); + p.MoveTo(5, 3); + p.ArcTo(5, 3, 3, 3, RotationType.CCW); + var prepared = Capture(p); + + Assert.Throws(() => prepared.AutomaticEntryCandidates(0)); + } + + // --- ownership / stability ------------------------------------------------------ + + [Fact] + public void ManualEntry_StillAcceptsAReflexPoint() + { + // The manual path is unchanged: an explicit reflex vertex is a valid choice. + var prepared = Capture(ClosedContour(new[] + { + new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(5, 10), + new Vector(5, 5), new Vector(0, 5), + })); + var reflex = new Vector(5, 5); + var choice = prepared.ClosestEntry(prepared.PerimeterOrdinal, reflex + new Vector(0.0001, 0.0001)); + + // ClosestEntry lands exactly on the reflex vertex and Entry validates it. + var manual = prepared.Entry(prepared.PerimeterOrdinal, choice.EntityOrdinal, reflex); + Assert.Equal(reflex.X, manual.Point.X, 9); + Assert.Equal(reflex.Y, manual.Point.Y, 9); + } + + [Fact] + public void MutatingSourceProgramAfterCapture_CannotChangeTheCatalogue() + { + var source = ExplicitContourTests.Square(false); + var prepared = Capture(source); + var before = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal) + .Select(c => (c.Kind, c.Choice.EntityOrdinal, c.Choice.Point.X, c.Choice.Point.Y)).ToList(); + + source.Codes.Clear(); + + var after = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal) + .Select(c => (c.Kind, c.Choice.EntityOrdinal, c.Choice.Point.X, c.Choice.Point.Y)).ToList(); + Assert.Equal(before, after); + } + + [Fact] + public void CancelledToken_Throws() + { + var prepared = Capture(ExplicitContourTests.Square(false)); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + + Assert.Throws(() => + prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal, cancelled.Token)); + } + + [Fact] + public void CatalogueCandidates_BelongToThisPreparation_AndEmit() + { + var prepared = Capture(ExplicitContourTests.Square(false)); + var corner = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal) + .First(c => c.Kind == AutomaticEntryKind.ConvexCorner); + + // The owned choice is accepted by this preparation's emit path... + var program = prepared.Emit(new[] { corner.Choice }); + Assert.NotEmpty(program.Codes); + + // ...and a foreign preparation rejects it. + var other = Capture(ExplicitContourTests.Square(false)); + Assert.Throws(() => other.Emit(new[] { corner.Choice })); + } + + [Fact] + public void GeometryKey_DeduplicatesTheSamePointAcrossEntities() + { + // Every convex corner is discovered from the entity ending at it; no two candidates + // share a geometric point. + var prepared = Capture(ExplicitContourTests.Square(false)); + var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal); + + Assert.Equal(candidates.Count, candidates.Select(c => c.GeometryKey).Distinct().Count()); + } + + private static double Distance(Vector a, Vector b) => a.DistanceTo(b); +}