From 63f2a4b555cb1fdd11bcfadffd5441ee347c93d7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 6 Oct 2026 23:58:01 -0400 Subject: [PATCH] feat(cutting): enumerate preferred contour entry candidates Internal PreparedContours.AutomaticEntryCandidates builds the uncapped preferred start catalogue per contour from the contour's own winding: convex corners, then straight-edge midpoints, then line/arc tangent joints, deduplicated geometrically with the preparation epsilon (the most preferred metadata wins at equal points). Reflex and cusp vertices, collinear splits and circles are never enumerated for automatic placement; hole slugs classify from their own travel. Manual Entry/ClosestEntry and the legacy Entries API are unchanged; emission wiring comes in S09. --- .../CuttingPlanning/ContourEntryCandidate.cs | 35 +++ .../CNC/CuttingPlanning/PreparedContours.cs | 87 +++++++ .../ContourEntryCandidateTests.cs | 235 ++++++++++++++++++ 3 files changed, 357 insertions(+) create mode 100644 OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs create mode 100644 OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs 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); +}