diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs index 9bb63cd..fa4073a 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs @@ -15,6 +15,24 @@ internal enum AutomaticEntryKind /// A tangent line/arc joint. A collinear line/line split is not a joint and never appears. TangentJoint = 2, + + /// + /// Tier 3 (fallback): a point on a straight edge meeting a convex corner, back from the + /// corner by about twice the applicable lead-in length, strictly inside the edge. + /// + NearCorner = 3, + + /// + /// Tier 3 (fallback): the exact native closest point facing the caller's look-ahead + /// position (pass the arrival there when there is no next cut). Never a reflex/cusp vertex. + /// + TargetFacing = 4, + + /// Tier 3 (fallback): the native midpoint of an arc entity. + ArcMidpoint = 5, + + /// Tier 3 (fallback): one of the eight compass points of a whole circle. + CircleCompass = 6, } /// diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index 47f6d27..4283f3f 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -162,63 +162,153 @@ public sealed class PreparedContours /// 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. + /// preferred point at all — 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) + if (IsSingleCircle(shape)) 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."); + return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token)); + } - // 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(); + /// + /// The complete uncapped automatic start catalogue: the preferred points of + /// followed by tier-3 fallbacks — native arc + /// midpoints, near-convex-corner points on straight edges, the eight compass points of a + /// whole circle, and the exact target-facing closest point toward + /// (pass the arrival point there when there is no next cut). + /// A pure-circle contour therefore yields compass points instead of refusing. Every + /// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the + /// preferred tier; fallbacks never replace a preferred point at the same geometry. + /// + internal IReadOnlyList AutomaticEntryCandidatesWithFallbacks( + int contourOrdinal, + Vector? lookAhead = null, + CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + var shape = GetShape(contourOrdinal); + var all = IsSingleCircle(shape) + ? new List() + : PreferredCandidates(shape, contourOrdinal, token); + all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token)); + return MergeByGeometry(all); + } + + private static bool IsSingleCircle(Shape shape) => + shape.Entities.Count == 1 && shape.Entities[0] is Circle; + + /// Preferred tier: convex corners, straight midpoints, tangent joints. + private List PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token) + => CataloguePoints(shape, token) + .Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind)) + .ToList(); + + /// + /// Tier-3 fallbacks for one contour, each already run through the reflex/cusp exclusion: + /// native arc midpoints, the eight compass points of whole circles, near-convex-corner + /// insets on straight edges, and the exact target-facing closest point toward + /// . No ranking and no lead-safety verdict here. + /// + private List FallbackCandidates( + Shape shape, + int contourOrdinal, + Vector? lookAhead, + CancellationToken token) + { + var fallbacks = new List(); + var lead = ApplicableLeadInLength(contourOrdinal); 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 (shape.Entities[i]) { - 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; - } + case Arc arc: + // The native midpoint of an arc (exact native API, never tessellation). + fallbacks.Add(Fallback(i, arc.MidPoint(), AutomaticEntryKind.ArcMidpoint)); + break; + case Circle circle: + // The legacy eight compass points, same native construction. + for (var angle = 0; angle < 8; angle++) + fallbacks.Add(Fallback(i, circle.Center + new Vector( + System.Math.Cos(angle * System.Math.PI / 4), + System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius, + AutomaticEntryKind.CircleCompass)); + break; } + } - if (entity is Line line) - Add(i, line.MidPoint, AutomaticEntryKind.StraightMidpoint); + // Near-convex-corner fallbacks: about twice the applicable lead-in length back from + // each convex corner along each incident STRAIGHT edge, only when strictly inside + // that edge. Short edges simply omit the point; it never extrapolates past an edge + // endpoint and so never lands on the reflex/cusp vertex at the far end. + if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle)) + { + foreach (var corner in CataloguePoints(shape, token) + .Where(p => p.Kind == AutomaticEntryKind.ConvexCorner)) + { + token.ThrowIfCancellationRequested(); + var cornerPoint = End(shape.Entities[corner.EntityOrdinal]); + // Back INTO each incident edge from the corner: the edge ending at the + // corner retreats against its own travel, the edge starting at the corner + // advances along its own travel. + AddInset(corner.EntityOrdinal, cornerPoint, inward: false); + AddInset((corner.EntityOrdinal + 1) % count, cornerPoint, inward: true); + } + } + + if (lookAhead != null) + { + token.ThrowIfCancellationRequested(); + PostVerificationGeometry.Validate(lookAhead.Value); + var facing = ClosestEntry(contourOrdinal, lookAhead.Value); + // A raw closest point may land exactly on a reflex/cusp vertex; automatic + // selection must never sneak a forbidden inside corner back in, so drop it. + if (!IsForbiddenVertex(shape, facing.Point)) + fallbacks.Add(Fallback(facing.EntityOrdinal, facing.Point, AutomaticEntryKind.TargetFacing)); } token.ThrowIfCancellationRequested(); + return fallbacks; - // Deduplicate: at equal points the most preferred metadata wins, independent of - // which entity supplied it; the winner's own ordinal and point are kept. + void AddInset(int entityOrdinal, Vector corner, bool inward) + { + // `inward` selects along the edge's own travel from its start; without it the + // point retreats against travel. Both ways move BACK INTO the edge from the + // corner, which sits at the edge's end (inward=false) or start (inward=true). + if (shape.Entities[entityOrdinal] is not Line line) + return; + var direction = (line.EndPoint - line.StartPoint).Normalize(); + var offset = direction * (2 * lead); + var point = inward ? corner + offset : corner - offset; + // Strictly inside the edge by projection parameter (distance alone loses the + // sign when a short edge is overshoot): never the corner, never the far + // endpoint (where a reflex vertex might sit). Short edges omit the point. + var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y; + if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon) + return; + fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner)); + } + + ContourEntryCandidate Fallback(int entityOrdinal, Vector point, AutomaticEntryKind kind) + => new(Entry(contourOrdinal, entityOrdinal, point), kind); + } + + /// + /// At equal geometric points the most preferred kind wins, independent of which entity + /// supplied it; the winner keeps its own ordinal and point. Result order: preference, + /// then entity ordinal. + /// + private static IReadOnlyList MergeByGeometry(List candidates) + { var byPoint = new Dictionary<(long, long), ContourEntryCandidate>(); var order = new List<(long, long)>(); - foreach (var candidate in found) + foreach (var candidate in candidates) { var key = candidate.GeometryKey; if (!byPoint.TryGetValue(key, out var existing)) @@ -234,13 +324,98 @@ public sealed class PreparedContours .ThenBy(key => byPoint[key].Choice.EntityOrdinal) .Select(key => byPoint[key]) .ToList(); + } + + /// + /// True when sits on a vertex of the contour that is reflex or + /// cusp from either travel direction — a point automatic selection must never emit. + /// + private bool IsForbiddenVertex(Shape shape, Vector point) + { + for (var i = 0; i < shape.Entities.Count; i++) + { + if (shape.Entities[i] is Circle) + continue; // A whole circle has no vertex. + var vertex = End(shape.Entities[i]); + if (vertex.DistanceTo(point) > PostVerificationGeometry.Epsilon) + continue; + if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, shape.Entities[i], out var corner) + && corner.Kind is ContourCuttingStrategy.CornerKind.Reflex or ContourCuttingStrategy.CornerKind.Cusp) + return true; + } + return false; + } + + /// + /// The applicable lead-in length for a contour following the emitter's own selection: + /// the perimeter (last shape) is external, anything else internal; non-length lead-in + /// styles contribute 0, which omits the near-corner fallback instead of approximating. + /// + private double ApplicableLeadInLength(int contourOrdinal) + => (contourOrdinal == PerimeterOrdinal + ? parameters.ExternalLeadIn + : parameters.InternalLeadIn) switch + { + LineLeadIn line => line.Length, + LineLineLeadIn lineLine => lineLine.Length1, + _ => 0, + }; + + /// + /// The preferred catalogue points for one contour: for each entity, its convex corner + /// (end vertex, classified from the contour's own winding) and its straight-edge midpoint, + /// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute + /// nothing; a single whole circle yields no points at all. + /// + private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token) + { + var found = new List<(int, Vector, AutomaticEntryKind)>(); + var count = shape.Entities.Count; + if (count == 1 && shape.Entities[0] is Circle) + { + // A whole circle has no corners or joints; only the fallback tier applies. + return found; + } + + if (count < 2) + throw new ArgumentException("Contour has no vertex to classify."); + 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: + found.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. + found.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: + found.Add((i, vertex, AutomaticEntryKind.TangentJoint)); + break; + } + } + + if (entity is Line line) + found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint)); + } + return found; 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. diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs index 797ed38..28e6258 100644 --- a/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs +++ b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs @@ -231,5 +231,191 @@ public class ContourEntryCandidateTests Assert.Equal(candidates.Count, candidates.Select(c => c.GeometryKey).Distinct().Count()); } + // --- S04 fallback tier ------------------------------------------------------ + + private static IReadOnlyList WithFallbacks( + Program program, Vector? lookAhead = null, string style = "line") => + PreparedContours.Capture(program, ExplicitContourTests.Parameters(style)) + .AutomaticEntryCandidatesWithFallbacks(0, lookAhead); + + [Fact] + public void Circle_FallbacksAreCompassPointsAndTargetFacing() + { + var p = new Program(); + p.MoveTo(5, 3); + p.ArcTo(5, 3, 3, 3, RotationType.CCW); // end (5,3), center (3,3), radius 2 + + // The preferred catalogue still refuses a whole circle... + Assert.Throws(() => Capture(p).AutomaticEntryCandidates(0)); + + // ...but the fallback catalogue makes it usable: the eight native compass points. + var plain = WithFallbacks(p); + Assert.Equal(8, plain.Count); + Assert.All(plain, c => Assert.Equal(AutomaticEntryKind.CircleCompass, c.Kind)); + for (var angle = 0; angle < 8; angle++) + { + var expected = new Vector(3, 3) + + new Vector(System.Math.Cos(angle * System.Math.PI / 4), System.Math.Sin(angle * System.Math.PI / 4)) * 2; + Assert.Contains(plain, c => Distance(c.Choice.Point, expected) < 1e-9); + } + + // With a look-ahead the exact target-facing closest point joins the set. The + // look-ahead is off-compass so the merge cannot deduplicate it away. + var ahead = new Vector(4, -1); + var facing = WithFallbacks(p, ahead); + Assert.Equal(9, facing.Count); + var target = Assert.Single(facing, c => c.Kind == AutomaticEntryKind.TargetFacing); + var facingPoint = new Vector(3, 3) + (ahead - new Vector(3, 3)).Normalize() * 2; + Assert.Equal(facingPoint.X, target.Choice.Point.X, 6); + Assert.Equal(facingPoint.Y, target.Choice.Point.Y, 6); + } + + [Fact] + public void FilletArcMidpoint_IsTier3_KeepsJointPreferred() + { + // Half-circle bump on the top edge: joints (0,10),(10,10); arc midpoint (5,15). + 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); + + // The preferred tier has no arc midpoint at all. + Assert.DoesNotContain(Capture(p).AutomaticEntryCandidates(0), + c => c.Kind == AutomaticEntryKind.ArcMidpoint); + + var withFallbacks = WithFallbacks(p); + var mid = Assert.Single(withFallbacks, c => c.Kind == AutomaticEntryKind.ArcMidpoint); + Assert.Equal(5, mid.Choice.Point.X, 9); + Assert.Equal(15, mid.Choice.Point.Y, 9); + // Tier 3: strictly after every preferred kind in catalogue order. + var lastPreferred = withFallbacks.Select(c => c.Kind).ToList() + .FindLastIndex(k => k <= AutomaticEntryKind.TangentJoint); + var midpointIndex = withFallbacks.ToList().IndexOf(mid); + Assert.True(midpointIndex > lastPreferred); + } + + [Fact] + public void LongEdge_GetsNearCornerPointsAtTwoTimesLeadIn() + { + // 10x10 square, default LineLeadIn Length 0.3 -> inset 0.6 on each incident edge. + var candidates = WithFallbacks(ExplicitContourTests.Square(false)); + var near = candidates.Where(c => c.Kind == AutomaticEntryKind.NearCorner).ToList(); + + Assert.Equal(8, near.Count); // 4 convex corners x 2 incident straight edges + Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(0.6, 0)) < 1e-9); + Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(9.4, 0)) < 1e-9); + Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(10, 9.4)) < 1e-9); + Assert.All(near, c => Assert.True(c.Kind > AutomaticEntryKind.TangentJoint)); + } + + [Fact] + public void ShortEdge_OmitsNearCornerInsteadOfExtrapolating() + { + // 10 x 0.4 rectangle: every convex corner's vertical edge (0.4) is shorter than + // 2 x lead-in (0.6), so only the horizontal edges carry near-corner points. + var p = ClosedContour(new[] + { + new Vector(0, 0), new Vector(10, 0), new Vector(10, 0.4), new Vector(0, 0.4), + }); + var near = WithFallbacks(p).Where(c => c.Kind == AutomaticEntryKind.NearCorner).ToList(); + + Assert.Equal(4, near.Count); + // Every point stays strictly inside a horizontal edge of the rectangle — never + // extrapolated onto a vertical edge or past an endpoint. + Assert.All(near, c => + { + var onBottom = Distance(c.Choice.Point, new Vector(c.Choice.Point.X, 0)) < 1e-9 + && c.Choice.Point.X > 0 && c.Choice.Point.X < 10; + var onTop = Distance(c.Choice.Point, new Vector(c.Choice.Point.X, 0.4)) < 1e-9 + && c.Choice.Point.X > 0 && c.Choice.Point.X < 10; + Assert.True(onBottom || onTop); + }); + Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(0.6, 0)) < 1e-9); + Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(9.4, 0.4)) < 1e-9); + } + + [Fact] + public void InsetExactlyTwoLeadInsFromAReflexCorner_IsOmitted() + { + // Boundary notch 0.6 deep — exactly 2 x lead-in. The inset from each convex + // opening corner lands exactly on the reflex inner corner, so neither inner + // corner may appear in the automatic catalogue at all. + var p = ClosedContour(new[] + { + new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10), + new Vector(0, 4.6), new Vector(0.6, 4.6), new Vector(0.6, 4), new Vector(0, 4), + }); + var candidates = WithFallbacks(p); + + Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, new Vector(0.6, 4.6)) < 1e-9); + Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, new Vector(0.6, 4)) < 1e-9); + } + + [Fact] + public void TargetExactlyAtReflexVertex_IsNotAnAutomaticStart() + { + // L-outline; (5,5) is reflex for its own travel. The raw closest point from that + // position lands exactly on the reflex vertex and must be dropped. + 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 reflex = new Vector(5, 5); + var candidates = WithFallbacks(p, reflex); + + // The forbidden closest point is dropped entirely... + Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, reflex) < 1e-9); + Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.TargetFacing); + // ...and the catalogue stays usable through the other fallbacks. + Assert.Contains(candidates, c => c.Kind == AutomaticEntryKind.NearCorner); + } + + [Fact] + public void TargetFacingAtSharedPoint_KeepsThePreferredCorner() + { + // Look-ahead straight at corner (10,10): closest point IS the convex corner, so + // the geometric merge keeps the more preferred kind at that single point. + var candidates = WithFallbacks(ExplicitContourTests.Square(false), new Vector(10, 10)); + + var atCorner = candidates.Where(c => Distance(c.Choice.Point, new Vector(10, 10)) < 1e-9).ToList(); + var corner = Assert.Single(atCorner); + Assert.Equal(AutomaticEntryKind.ConvexCorner, corner.Kind); + } + + [Fact] + public void FallbackCatalogue_IsNonEmptyForEveryShape_AndHonoursCancellation() + { + // All-rounded contour (bump square) stays usable; cancellation is honoured. + var bump = new Program(); + bump.MoveTo(0, 0); + bump.LineTo(10, 0); + bump.LineTo(10, 10); + bump.ArcTo(new Vector(0, 10), new Vector(5, 10), RotationType.CCW); + bump.LineTo(0, 0); + Assert.NotEmpty(WithFallbacks(bump)); + + var prepared = PreparedContours.Capture(ExplicitContourTests.Square(false), ExplicitContourTests.Parameters()); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + Assert.Throws(() => + prepared.AutomaticEntryCandidatesWithFallbacks(0, new Vector(1, 1), cancelled.Token)); + } + + [Theory] + [InlineData("arc")] + [InlineData("linearc")] + [InlineData("clean")] + public void NonLengthLeadIns_OmitNearCornerInsteadOfApproximating(string style) + { + // Only lead-in styles with a straight length feed the inset; others contribute 0 + // and the near-corner fallback is omitted entirely — no invented setting. + var candidates = WithFallbacks(ExplicitContourTests.Square(false), style: style); + Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.NearCorner); + } + private static double Distance(Vector a, Vector b) => a.DistanceTo(b); }