diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs new file mode 100644 index 0000000..fa4073a --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs @@ -0,0 +1,53 @@ +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, + + /// + /// 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, +} + +/// +/// 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/ContourEntryRanking.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs new file mode 100644 index 0000000..56e7a28 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingPlanning; + +/// +/// Pure deterministic ordering of the automatic entry catalogue toward the next cut: +/// circles rank by outgoing distance; other contours use facing sides, tier and travel, +/// then a stable geometric key. It adds no +/// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility +/// filtering and the bounded selection belong to S07/S08, the wiring to S09. +/// +internal static class ContourEntryRanking +{ + /// + /// Orders for one contour in local coordinates. With a + /// (the next cut's look-ahead point): the number of matched + /// facing sides of the candidate bounding rectangle descending (a corner on both facing + /// sides is ideal), then rank tier ascending, then arrival->entry + entry->target + /// ascending, then the stable geometric key. With no target (the last part): tier first, + /// then distance to — never toward the plate origin. The + /// input list is returned untouched as a new list; entity order is never meaningful. + /// + internal static IReadOnlyList RankTowardNextCut( + this IReadOnlyList candidates, + Vector? target = null, + Vector? arrival = null) + { + if (target.HasValue) PostVerificationGeometry.Validate(target.Value); + if (arrival.HasValue) PostVerificationGeometry.Validate(arrival.Value); + if (candidates.Count == 0) + return new List(); + + // Whole circles have no corners. A diagonal compass point is equally near two + // bounding-box sides, but must not gain the two-side bonus of a real corner. + // Rank outgoing travel first so arrival cannot pull the start away from the next + // cut. Retain every compass/polar alternative for feasibility and rapid checks; + // configured angle rounding remains the emitter's responsibility. + if (target is { } next && candidates.Any(c => c.Kind == AutomaticEntryKind.CircleCompass) + && candidates.All(c => c.Kind is AutomaticEntryKind.CircleCompass or AutomaticEntryKind.TargetFacing)) + return candidates.OrderBy(c => c.Choice.Point.DistanceTo(next)) + .ThenBy(c => arrival is { } from ? c.Choice.Point.DistanceTo(from) : 0.0) + .ThenBy(c => c.GeometryKey.X) + .ThenBy(c => c.GeometryKey.Y) + .ToList(); + + // Candidate bounding rectangle in the contour's local coordinates; every candidate + // lies on the contour, so distances to the four side lines order side proximity. + var minX = double.PositiveInfinity; + var minY = double.PositiveInfinity; + var maxX = double.NegativeInfinity; + var maxY = double.NegativeInfinity; + foreach (var candidate in candidates) + { + var p = candidate.Choice.Point; + if (p.X < minX) minX = p.X; + if (p.X > maxX) maxX = p.X; + if (p.Y < minY) minY = p.Y; + if (p.Y > maxY) maxY = p.Y; + } + + // Facing sides from the target relative to the centre, matching the source plan: + // horizontal right when the target is right of centre else left; vertical likewise. + var centreX = minX + (maxX - minX) * 0.5; + var centreY = minY + (maxY - minY) * 0.5; + var facingRight = target != null && target.Value.X > centreX; + var facingTop = target != null && target.Value.Y > centreY; + + return candidates + .Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival))) + .OrderByDescending(x => x.Score.Facing) + .ThenBy(x => x.Score.Tier) + .ThenBy(x => x.Score.Travel) + .ThenBy(x => x.Candidate.GeometryKey.X) + .ThenBy(x => x.Candidate.GeometryKey.Y) + .Select(x => x.Candidate) + .ToList(); + } + + /// + /// The ranking tier — coarser than the preference kind: outside corners first, then + /// straight midpoints and tangent joints as peers, then every fallback kind. + /// + internal static int RankTier(this AutomaticEntryKind kind) => kind switch + { + AutomaticEntryKind.ConvexCorner => 0, + AutomaticEntryKind.StraightMidpoint or AutomaticEntryKind.TangentJoint => 1, + _ => 2, + }; + + private static (int Facing, int Tier, double Travel) Score( + ContourEntryCandidate candidate, + double minX, + double minY, + double maxX, + double maxY, + bool facingRight, + bool facingTop, + Vector? target, + Vector? arrival) + { + var p = candidate.Choice.Point; + var facing = 0; + if (target != null) + { + // Nearest side(s) of the candidate bounding rectangle (a corner belongs to two + // sides within tolerance); count how many of them are facing sides. + var left = p.X - minX; + var right = maxX - p.X; + var bottom = p.Y - minY; + var top = maxY - p.Y; + var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top)); + if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++; + if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++; + if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++; + if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++; + } + var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0) + + (target != null ? p.DistanceTo(target.Value) : 0.0); + return (facing, candidate.Kind.RankTier(), travel); + } +} diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs index c354417..7ce0854 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs @@ -90,7 +90,9 @@ public static class LeadPathValidator if (ReferenceEquals(material, target)) continue; if (CheckMaterial(material, null) != null) - return new(true, false, "Lead contacts or enters another placed material."); + return new(true, false, "Lead contacts or enters another placed material. " + + "Try spacing the parts farther apart or reducing the lead-in/lead-out length, then replan. " + + "For locked parts, edit the leads or unlock the part before replanning."); } string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint) diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index 377ba77..ef1e206 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -156,6 +156,268 @@ 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 — supplies + /// those. Manual entry through / is + /// unaffected. + /// + internal IReadOnlyList AutomaticEntryCandidates(int contourOrdinal, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + var shape = GetShape(contourOrdinal); + if (IsSingleCircle(shape)) + throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue."); + return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token)); + } + + /// + /// 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(); + switch (shape.Entities[i]) + { + 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; + } + } + + // 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; + + 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 candidates) + { + 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(); + } + + /// + /// 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]; + } + /// Emits every contour once in caller order, holes before perimeter, with scribes once. public Program Emit(IReadOnlyList choices) { @@ -163,6 +425,25 @@ public sealed class PreparedContours return EmitPrefix(choices); } + /// + /// Diagnostic seam: emit exactly ONE owned contour — with its normal lead-in and + /// lead-out and its ORIGINAL contour type (the perimeter keeps External even when no + /// holes precede it) — so a candidate's emitted leads can be validated before the hole + /// choices exist. The result is a throwaway probe, not a plan: it must never be + /// installed on a Part or accepted as complete output. Normal and + /// keep the perimeter-last rule untouched. + /// + internal Program EmitCandidateForValidation(ContourChoice choice) + { + if (choice == null || !ReferenceEquals(choice.Owner, this)) + throw new ArgumentException("Foreign contour choice."); + ValidateChoice(choice); + // Same owned clones as a real emission; the source shapes/settings are never used + // directly, so the probe cannot drift the preparation or mutate it. + return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated( + shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice); + } + // Each prefix is a standalone owned program, including the same scribes once. internal Program EmitPrefix(IReadOnlyList choices) { @@ -212,6 +493,23 @@ public sealed class PreparedContours private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count ? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal]; + /// + /// Representative POINTS per contour ordinal (bounding-box centre) for hole routing + /// only — the S10 CuttingHoleOrder proxy. The perimeter has no entry; it is the + /// route's fixed endpoint, not a stop. These are ordering proxies, never cut points. + /// + internal IReadOnlyList HoleCentres(CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + var centres = new Vector?[Count]; + for (var contour = 0; contour < PerimeterOrdinal; contour++) + { + token.ThrowIfCancellationRequested(); + centres[contour] = GetShape(contour).BoundingBox.Center; + } + return centres; + } + private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint(); private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint(); diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index 1bbd239..450906b 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -239,6 +239,32 @@ namespace OpenNest.CNC.CuttingStrategy { var result = new Program(Mode.Absolute); EmitScribeContours(result, scribes); + EmitChosenContours(result, shapes, choices); + result.Mode = Mode.Incremental; + return result; + } + + // Diagnostic seam for candidate lead validation (PreparedContours + // .EmitCandidateForValidation): emit a single already-validated choice with its + // ORIGINAL contour type — the last shape is External even when emitted alone, so a + // perimeter never degrades to a hole because it was isolated. Output must not be + // installed on a Part or accepted as a complete plan. + internal Program EmitCandidateIsolated(Shape[] shapes, List scribes, + CuttingPlanning.ContourChoice choice) + { + var result = new Program(Mode.Absolute); + EmitScribeContours(result, scribes); + EmitChosenContours(result, shapes, new[] { choice }); + result.Mode = Mode.Incremental; + return result; + } + + // One contour per choice in order; the perimeter (last shape) is always External. + // Contour emission itself reads only the contour's own geometry and settings, never + // prior choices — the differential tests in ContourCandidateEmissionTests pin this. + private void EmitChosenContours(Program result, Shape[] shapes, + IReadOnlyList choices) + { foreach (var choice in choices) { var shape = shapes[choice.ContourOrdinal]; @@ -246,8 +272,6 @@ namespace OpenNest.CNC.CuttingStrategy choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null, exactCirclePrograms: true); } - result.Mode = Mode.Incremental; - return result; } private void EmitRawContour(Program program, Shape shape) @@ -636,7 +660,7 @@ namespace OpenNest.CNC.CuttingStrategy } } - private enum CornerKind + internal enum CornerKind { Convex, Reflex, @@ -644,6 +668,38 @@ namespace OpenNest.CNC.CuttingStrategy Cusp, } + /// + /// A vertex classified for automatic start-point planning: what kind of turn the + /// contour makes there, and the travel tangents of the two edges it joins. Read-only + /// so callers cannot mutate the contour; winding is derived the same way + /// derives it, so the kind matches actual emission. + /// + internal readonly record struct AutomaticCorner(CornerKind Kind, Vector TangentIn, Vector TangentOut); + + /// + /// Classification query shared with entry planning: the turn at + /// on a closed line/arc contour, using the same corner geometry and winding derivation + /// as emission. False when is not a shared vertex of two + /// chainable entities (an interior point, an open contour, a degenerate or non-finite + /// corner). Lead generation itself is not involved. + /// + internal static bool TryClassifyAutomaticStartCorner( + Shape shape, + Vector point, + Entity entity, + out AutomaticCorner corner) + { + if (!TryGetCorner(shape, point, entity, out var raw)) + { + corner = default; + return false; + } + + corner = new AutomaticCorner( + ClassifyCorner(raw, DetermineWinding(shape)), raw.TangentIn, raw.TangentOut); + return true; + } + /// A contour vertex: the entity cut into it and the one cut away from it. private readonly record struct ContourCorner( Entity Incoming, diff --git a/OpenNest.Core/PlateDisplayNumbering.cs b/OpenNest.Core/PlateDisplayNumbering.cs new file mode 100644 index 0000000..e6b761b --- /dev/null +++ b/OpenNest.Core/PlateDisplayNumbering.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; + +namespace OpenNest +{ + /// + /// Display-only plate numbering for labels. The editor keeps one trailing empty plate as the + /// "new plate" workspace (PlateManager.EnsureSentinel), and that sentinel is excluded from the + /// shown total so a one-plate nest reads "Plate 1 of 1". Navigation, storage indexes, exported + /// names and batch selection keep using the real collection positions; an interior empty plate + /// keeps its slot and its number. + /// + public static class PlateDisplayNumbering + { + /// + /// The number shown for the plate at : real collection + /// positions count as 1-based plate numbers. Returns null for the trailing "new plate" + /// sentinel and for indexes outside the collection. + /// + public static int? DisplayedPlateNumber(IList plates, int storageIndex) + { + if (plates == null || storageIndex < 0 || storageIndex >= plates.Count) + return null; + if (IsTrailingSentinel(plates, storageIndex)) + return null; + return storageIndex + 1; + } + + /// + /// The number shown in an "of M" total: the plates minus only a trailing empty sentinel. + /// An all-empty collection shows nothing (the caller says "No plates"). + /// + public static int DisplayedPlateCount(IList plates) + { + if (plates == null || plates.Count == 0) + return 0; + var last = plates[plates.Count - 1]; + return plates.Count - (last != null && last.Parts.Count == 0 ? 1 : 0); + } + + /// True when is the trailing empty new-plate sentinel. + public static bool IsTrailingSentinel(IList plates, int storageIndex) + { + if (plates == null || plates.Count == 0) + return false; + if (storageIndex != plates.Count - 1) + return false; + var plate = plates[storageIndex]; + return plate != null && plate.Parts.Count == 0; + } + + /// Header text for the plate at . + public static string FormatHeader(IList plates, int storageIndex, string plateSizeText) + { + var displayed = DisplayedPlateNumber(plates, storageIndex); + if (displayed == null) + return IsTrailingSentinel(plates, storageIndex) + ? "New plate (empty)" + : "No plates"; + return string.IsNullOrEmpty(plateSizeText) + ? string.Format("Plate {0} of {1}", displayed.Value, DisplayedPlateCount(plates)) + : string.Format("Plate {0} of {1} | {2}", displayed.Value, DisplayedPlateCount(plates), plateSizeText); + } + } +} diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs new file mode 100644 index 0000000..87ee195 --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs @@ -0,0 +1,122 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Engine.CuttingPlanning; + +/// The three verdicts a candidate entry can receive from the shared lead validator. +public enum ContourFeasibilityStatus +{ + /// Every emitted lead of this contour is complete AND clear. + Clear, + + /// A complete check found a lead contact/overlap — the reason is preserved. + Blocked, + + /// The check could not complete (incomplete material, malformed emission) — never clear. + Incomplete, +} + +/// Verdict for one candidate entry. Complete/blocked/incomplete stay distinct. +public sealed record ContourFeasibilityVerdict(ContourFeasibilityStatus Status, string? Reason) +{ + public bool IsClear => Status == ContourFeasibilityStatus.Clear; +} + +/// +/// Feasibility adapter over the EXISTING : emits one owned +/// candidate contour through the S06 diagnostic seam, reads it at the placement position, +/// and certifies the emitted lead-in and lead-out against ALL placed material. It is not a +/// new collision implementation and not a plan approval: a Clear verdict certifies this +/// contour's emitted leads only — the missing-lead (NoLeadIn) check still runs later on the +/// complete plan, and rapids/pierce clearance belong to their existing checkers. A +/// candidate rejected here is not proven infeasible by anything else: this adapter only +/// reports what the validator reported. +/// One instance is one captured planning attempt: verdicts cache per exact choice and node +/// context for the instance's lifetime — settings and placement are fixed per instance — +/// and nothing is cached across instances or statically. Evaluation is lazy: only the +/// choice handed to is ever emitted or validated, and +/// counts validator executions (not cache hits) for cost tests. +/// +public sealed class ContourEntryFeasibility +{ + private readonly PreparedContours prepared; + private readonly Vector location; + private readonly LeadMaterialSnapshot ownMaterial; + private readonly LeadMaterialSnapshot[] materials; + private readonly Dictionary cache = new(); + + public ContourEntryFeasibility(PreparedContours prepared, Vector location, + LeadMaterialSnapshot ownMaterial, IReadOnlyList otherMaterials) + { + this.prepared = prepared ?? throw new ArgumentException("Prepared contours are required.", nameof(prepared)); + this.ownMaterial = ownMaterial ?? throw new ArgumentException("Own material snapshot is required.", nameof(ownMaterial)); + if (otherMaterials == null) + throw new ArgumentException("Placed-material snapshots are required.", nameof(otherMaterials)); + this.location = location; + // Immutable copy; the validator's own-material-first convention is preserved. + var all = new List { this.ownMaterial }; + foreach (var material in otherMaterials) + { + if (material == null) + throw new ArgumentException("Placed-material snapshots must not be null.", nameof(otherMaterials)); + if (!ReferenceEquals(material, this.ownMaterial)) + all.Add(material); + } + materials = all.ToArray(); + } + + /// Validator executions performed (cache misses only) during this attempt. + public int EvaluationCount { get; private set; } + + /// + /// The verdict for one owned candidate. Same choice + node context within this attempt + /// is answered from cache. Cancellation propagates; a malformed emission is an + /// Incomplete verdict with the emission's own reason, never a crash and never Clear. + /// + public ContourFeasibilityVerdict Check(ContourChoice choice, string nodeContext = "", + CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + if (choice == null) + throw new ArgumentException("A contour choice is required.", nameof(choice)); + // Ownership is an input boundary, not part of a memoized geometric verdict. + // Reject before lookup so neither a warm hit nor a foreign first call can + // substitute for the isolated emitter's owned-choice check. + if (!ReferenceEquals(choice.Owner, prepared)) + return new(ContourFeasibilityStatus.Incomplete, "Foreign contour choice."); + var key = new Key(choice.ContourOrdinal, choice.EntityOrdinal, + choice.Point.X, choice.Point.Y, nodeContext ?? string.Empty); + if (cache.TryGetValue(key, out var known)) + return known; + var verdict = Probe(choice, token); + cache[key] = verdict; + EvaluationCount++; + return verdict; + } + + private ContourFeasibilityVerdict Probe(ContourChoice choice, CancellationToken token) + { + try + { + var program = prepared.EmitCandidateForValidation(choice); + var execution = ExecutionMotionReader.Read(program, location, null, token); + var result = LeadPathValidator.Check(execution, ownMaterial, materials, token); + if (!result.IsComplete) + return new(ContourFeasibilityStatus.Incomplete, result.Reason); + return result.IsClear + ? new(ContourFeasibilityStatus.Clear, null) + : new(ContourFeasibilityStatus.Blocked, result.Reason); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + // Malformed emission or foreign choice: refused, reason preserved, never cached as clear. + return new(ContourFeasibilityStatus.Incomplete, ex.Message); + } + } + + private readonly record struct Key(int ContourOrdinal, int EntityOrdinal, double X, double Y, string NodeContext); +} diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs b/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs new file mode 100644 index 0000000..f070bda --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs @@ -0,0 +1,266 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.Engine.CuttingPlanning; + +/// Why a selection ended the way it did. Honest metadata, never a geometric overclaim. +internal enum ContourSelectionShortfall +{ + /// Selection ended at the cap with the side scan satisfied: a complete verdict. + None, + + /// + /// The finite catalogue was fully evaluated and the selection is all there is. With an + /// empty selection this is exactly the sentence "no tested lead-in fits on part N, + /// contour M" — and ONLY here may that sentence be spoken. + /// + Exhausted, + + /// + /// At least one check could not complete (incomplete material or emission). Never + /// present this as geometric impossibility and never as "nothing fits". + /// + Incomplete, +} + +/// +/// Outcome of one bounded selection: selected choices in global rank order, how many +/// distinct candidates were evaluated, which ones (in catalogue order, for cost tests), +/// which evaluated uncertain (never refused — the caller's full check stays the authority +/// on those), and why the selection stopped. +/// +internal sealed record ContourSelectionResult( + IReadOnlyList Choices, + int EvaluatedCount, + IReadOnlyList<(long, long)> EvaluatedKeys, + IReadOnlyList UncertainChoices, + ContourSelectionShortfall Shortfall, + string? Reason); + +/// +/// Bounded, lazy selection of lead-feasible entry candidates for ONE contour. Greedy global +/// rank order until the cap (default 16) fills or the finite catalogue ends — rejected and +/// incomplete candidates never consume a slot. When the cap can afford it (4+), one +/// corrective scan makes sure every side of the candidate bounding rectangle that HAS a +/// feasible candidate is represented, replacing the worst selected candidate only when +/// every other covered side survives; one corner may cover two sides. The cap is never +/// exceeded and points are never manufactured on infeasible sides — a capped selection may +/// truthfully omit a feasible side. Sides are exactly the ranker's side geometry (the +/// candidate-set bounding rectangle). Verdicts are memoized per attempt (the adapter +/// memoizes too): no candidate is ever evaluated twice, and once selection settles the +/// untouched tail is never evaluated. No search/DFS changes and no larger search budget. +/// +internal static class ContourEntrySelection +{ + internal const int DefaultMaxEntries = 16; + + /// Below this cap all-side coverage is not promised; the cap and ranking bind first (caps 1-3). + internal const int SideCoverageMinCap = 4; + + /// + /// Selects up to feasible choices from one contour's + /// globally ranked catalogue. is the S07 adapter verdict, + /// called lazily at most once per distinct candidate. + /// + internal static ContourSelectionResult Select( + IReadOnlyList rankedCandidates, + Func evaluate, + int maxEntries = DefaultMaxEntries, + CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + if (rankedCandidates == null) + throw new ArgumentException("Ranked candidates are required.", nameof(rankedCandidates)); + if (evaluate == null) + throw new ArgumentException("A feasibility evaluation is required.", nameof(evaluate)); + if (maxEntries <= 0) + throw new ArgumentException("The cap must be positive.", nameof(maxEntries)); + + // Distinct points only — the cap must not double-count a geometric duplicate. + var candidates = new List(); + var seen = new HashSet<(long, long)>(); + foreach (var candidate in rankedCandidates) + if (seen.Add(candidate.GeometryKey)) + candidates.Add(candidate); + + var evaluated = new List(); + var verdicts = new Dictionary<(long, long), ContourFeasibilityVerdict>(); + var box = Box(candidates); + + var selected = new List(); + var uncertain = new List(); + var sawIncomplete = false; + string? incompleteReason = null; + var index = 0; + + ContourFeasibilityVerdict Verdict(ContourEntryCandidate candidate) + { + token.ThrowIfCancellationRequested(); + if (!verdicts.TryGetValue(candidate.GeometryKey, out var known)) + { + known = evaluate(candidate); + verdicts[candidate.GeometryKey] = known; + evaluated.Add(candidate); + } + return known; + } + + // Phase 1: greedy global order until the cap fills or the catalogue ends. + for (; index < candidates.Count && selected.Count < maxEntries; index++) + { + var verdict = Verdict(candidates[index]); + if (verdict.Status == ContourFeasibilityStatus.Incomplete) + { + // Uncertain is not refused: it never takes a selected slot, but the scan + // continues and the caller's full check stays the authority on it. + sawIncomplete = true; + incompleteReason ??= verdict.Reason; + uncertain.Add(candidates[index]); + continue; + } + if (verdict.IsClear) + selected.Add(candidates[index]); + } + + // Phase 2: reconsider the globally ranked catalogue for each missing side, reusing + // verdicts passed while chasing earlier sides and evaluating the tail lazily. + // A clear candidate on that side is appended when + // a slot remains, otherwise it replaces the worst selected candidate whose removal + // keeps every other covered side covered. + var coverage = maxEntries >= SideCoverageMinCap && selected.Count > 0; + if (coverage) + for (var side = 0; side < 4 && !sawIncomplete; side++) + { + if (selected.Any(c => Sides(c, box).Contains(side))) + continue; + for (index = 0; index < candidates.Count; index++) + { + var verdict = Verdict(candidates[index]); + if (verdict.Status == ContourFeasibilityStatus.Incomplete) + { + sawIncomplete = true; + incompleteReason ??= verdict.Reason; + uncertain.Add(candidates[index]); + continue; + } + if (!verdict.IsClear || !Sides(candidates[index], box).Contains(side)) + continue; + TryPlace(selected, candidates[index], box, maxEntries); + // A reused candidate can outrank an earlier replacement. Keep the + // safe-victim scan in global rank order for the next missing side. + selected.Sort((a, b) => candidates.IndexOf(a).CompareTo(candidates.IndexOf(b))); + break; + } + } + + var exhausted = index >= candidates.Count; + ContourSelectionShortfall shortfall; + string? reason; + if (sawIncomplete) + { + shortfall = ContourSelectionShortfall.Incomplete; + reason = "At least one lead check could not complete; this is not a geometric verdict and nothing is proven impossible." + + (incompleteReason == null ? "" : $" First reason: {incompleteReason}"); + } + else if (selected.Count >= maxEntries) + { + shortfall = ContourSelectionShortfall.None; + reason = null; + } + else if (exhausted) + { + shortfall = ContourSelectionShortfall.Exhausted; + reason = selected.Count == 0 + ? "No tested lead-in fits on this contour." + : null; + } + else + { + // Unreachable: phase 1 ends at cap or catalogue end and phase 2 chases every + // side to the end; a cancellation throws before this point. + shortfall = ContourSelectionShortfall.Incomplete; + reason = "Selection stopped before the catalogue ended."; + } + + var ordered = selected + .OrderBy(c => candidates.IndexOf(c)) + .Select(c => c.Choice) + .ToList(); + return new(ordered, evaluated.Count, + evaluated.Select(c => c.GeometryKey).ToList(), + uncertain.Select(c => c.Choice).ToList(), shortfall, reason); + } + + private static (double MinX, double MinY, double MaxX, double MaxY) Box( + List candidates) + { + double minX = double.PositiveInfinity, minY = double.PositiveInfinity, + maxX = double.NegativeInfinity, maxY = double.NegativeInfinity; + foreach (var c in candidates) + { + var p = c.Choice.Point; + if (p.X < minX) minX = p.X; + if (p.X > maxX) maxX = p.X; + if (p.Y < minY) minY = p.Y; + if (p.Y > maxY) maxY = p.Y; + } + if (!double.IsFinite(minX)) + return (0, 0, 0, 0); + return (minX, minY, maxX, maxY); + } + + /// Sides 0 left, 1 right, 2 bottom, 3 top of the candidate bounding rectangle that the point sits on. + private static List Sides(ContourEntryCandidate candidate, + (double MinX, double MinY, double MaxX, double MaxY) box) + { + var p = candidate.Choice.Point; + var left = p.X - box.MinX; + var right = box.MaxX - p.X; + var bottom = p.Y - box.MinY; + var top = box.MaxY - p.Y; + var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top)); + var sides = new List(2); + if (left <= min + PostVerificationGeometry.Epsilon) sides.Add(0); + if (right <= min + PostVerificationGeometry.Epsilon) sides.Add(1); + if (bottom <= min + PostVerificationGeometry.Epsilon) sides.Add(2); + if (top <= min + PostVerificationGeometry.Epsilon) sides.Add(3); + return sides; + } + + /// Appends when a slot remains, else replaces the worst (latest-ranked) candidate whose removal preserves every other covered side. + private static void TryPlace(List selected, ContourEntryCandidate candidate, + (double MinX, double MinY, double MaxX, double MaxY) box, int maxEntries) + { + if (selected.Count < maxEntries) + { + selected.Add(candidate); + return; + } + var incoming = Sides(candidate, box); + for (var i = selected.Count - 1; i >= 0; i--) + { + var removalSafe = true; + for (var side = 0; side < 4 && removalSafe; side++) + { + if (incoming.Contains(side)) + continue; // the replacement covers it + var covers = selected.Where((c, at) => at != i && Sides(c, box).Contains(side)).Any(); + var wasCovered = selected.Any(c => Sides(c, box).Contains(side)); + if (wasCovered && !covers) + removalSafe = false; + } + if (removalSafe) + { + selected[i] = candidate; + return; + } + } + // No safe victim: the side stays truthfully unrepresented at this cap. + } +} diff --git a/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs new file mode 100644 index 0000000..e716ea4 --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest.Engine.CuttingPlanning; + +/// +/// Proposes the ORDER in which a part's remaining holes are cut when the perimeter entry is +/// already chosen: an open path from the tool's arrival through every hole centre and ON to +/// the selected perimeter entry — the terminal edge is real rapid travel and is optimised, +/// not scored after the fact. It reuses 's bounded nearest- +/// neighbour + 2-opt/Or-opt machinery (no exhaustive permutations) with an optional fixed +/// endpoint, so whole-part routing without an endpoint keeps byte-identical behaviour. +/// This is a proposal only: the search still certifies every rapid and emitted lead along +/// it, exactly as it does for part orders. +/// +internal static class CuttingHoleOrder +{ + /// + /// Orders (stable ordinals, each appearing exactly once in the + /// result, never the perimeter) to minimize arrival -> holes -> distance. supplies one + /// representative point per hole ordinal. Empty holes give an empty order. + /// + internal static IReadOnlyList Plan(IReadOnlyList holes, + IReadOnlyList centresByOrdinal, Vector arrival, Vector perimeterEntry, + CancellationToken token = default) + { + if (holes == null) + throw new ArgumentException("Hole ordinals are required.", nameof(holes)); + if (centresByOrdinal == null) + throw new ArgumentException("Hole centres are required.", nameof(centresByOrdinal)); + token.ThrowIfCancellationRequested(); + if (holes.Count == 0) + return Array.Empty(); + + var none = Array.Empty(); + var prerequisites = holes.Select(_ => (IReadOnlyCollection)none).ToList(); + var centres = holes.Select(hole => centresByOrdinal[hole]).ToList(); + var route = CuttingPartOrder.Plan(centres, arrival, prerequisites, token, perimeterEntry); + return route.Select(index => holes[index]).ToArray(); + } +} diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs index 2c4b280..6b3efbd 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs @@ -39,8 +39,13 @@ internal static class CuttingPartOrder /// One representative point per part ordinal. /// The modeled tool position before the first part. /// Ordinals that must come before each part; must be acyclic. + /// + /// Optional fixed final position AFTER the last visited point (an open path with a closed + /// terminal edge). The terminal edge joins EVERY improvement delta, not just final + /// scoring. Null keeps the previous whole-part open-route semantics exactly. + /// internal static int[] Plan(IReadOnlyList centres, Vector start, - IReadOnlyList> prerequisites, CancellationToken token) + IReadOnlyList> prerequisites, CancellationToken token, Vector? endpoint = null) { var count = centres.Count; var order = NearestNeighbour(centres, start, prerequisites, token); @@ -48,8 +53,8 @@ internal static class CuttingPartOrder for (var pass = 0; pass < MaxPasses; pass++) { token.ThrowIfCancellationRequested(); - var improved = TwoOpt(order, centres, start, prerequisites, position, token); - improved |= OrOpt(order, centres, start, prerequisites, position, token); + var improved = TwoOpt(order, centres, start, prerequisites, position, token, endpoint); + improved |= OrOpt(order, centres, start, prerequisites, position, token, endpoint); if (!improved) break; } @@ -90,19 +95,23 @@ internal static class CuttingPartOrder // Reverses order[i..j] when that shortens the open path and keeps every prerequisite earlier. private static bool TwoOpt(int[] order, IReadOnlyList centres, Vector start, - IReadOnlyList> prerequisites, int[] position, CancellationToken token) + IReadOnlyList> prerequisites, int[] position, CancellationToken token, + Vector? endpoint = null) { var improved = false; var count = order.Length; + // The terminal edge belongs to every delta: reversing the route's tail swaps which + // endpoint-side centre faces the fixed final position. + double Tail(Vector from) => endpoint is { } e ? from.DistanceTo(e) : 0; for (var i = 0; i < count - 1; i++) { token.ThrowIfCancellationRequested(); for (var j = i + 1; j < count; j++) { var before = Point(i - 1).DistanceTo(centres[order[i]]) - + (j + 1 < count ? centres[order[j]].DistanceTo(centres[order[j + 1]]) : 0); + + (j + 1 < count ? centres[order[j]].DistanceTo(centres[order[j + 1]]) : Tail(centres[order[j]])); var after = Point(i - 1).DistanceTo(centres[order[j]]) - + (j + 1 < count ? centres[order[i]].DistanceTo(centres[order[j + 1]]) : 0); + + (j + 1 < count ? centres[order[i]].DistanceTo(centres[order[j + 1]]) : Tail(centres[order[i]])); if (after >= before - Epsilon || !CanReverse(order, i, j, prerequisites, position)) continue; Array.Reverse(order, i, j - i + 1); @@ -129,7 +138,8 @@ internal static class CuttingPartOrder // Moves a run of one to three parts to a later or earlier gap when that shortens the path. private static bool OrOpt(int[] order, IReadOnlyList centres, Vector start, - IReadOnlyList> prerequisites, int[] position, CancellationToken token) + IReadOnlyList> prerequisites, int[] position, CancellationToken token, + Vector? endpoint = null) { var improved = false; var count = order.Length; @@ -157,12 +167,15 @@ internal static class CuttingPartOrder } return improved; - // Path length between order[a] and order[b] (a == -1 is the start; b == count is the open end). + // Path length between order[a] and order[b] (a == -1 is the start; b == count is the open + // end — the fixed endpoint when one was supplied, so the terminal edge is in every delta). double Gap(int a, int b) { - if (b >= count || b < 0) - return 0; var from = a < 0 ? start : centres[order[a]]; + if (b >= count) + return endpoint is { } e ? from.DistanceTo(e) : 0; + if (b < 0) + return 0; return from.DistanceTo(centres[order[b]]); } } diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs index 341d4d3..2e180d7 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs @@ -307,14 +307,22 @@ public sealed class CuttingPlanProposal yield break; } + var missingLead = !plate.IsRouteReady && result.Status != CuttingPlanStatus.Cancelled + && result.Findings.Any(f => f.Kind == PostVerificationKind.MissingLeadIn); yield return heading + (plate.IsRouteReady ? "blocked: parts overlap or could not be checked for overlap." - : StatusText(result.Status)); + : missingLead ? "blocked: missing or zero-length lead-in." : StatusText(result.Status)); foreach (var line in Limit(DescribeOverlap(plate.Overlap))) yield return line; if (plate.IsRouteReady) yield break; - if (plate.KeptCurrentOrder) + if (missingLead) + { + yield return " Open Cutting Settings... and select a lead-in type other than None with " + + "nonzero length for the affected contour (External, Internal, or Arc / Circle), then replan."; + yield return " If the affected part is locked, edit its lead-ins or unlock it before replanning."; + } + else if (plate.KeptCurrentOrder) yield return " No new part order was found within the search limit, and planning with the " + "current order was refused:"; foreach (var line in Limit(result.Findings.Select(DescribeFinding))) diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 1ee9e2b..9a02ad2 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -10,8 +10,9 @@ using OpenNest.Geometry; namespace OpenNest.Engine.CuttingPlanning; /// -/// Plans whole parts and their emitted contour prefixes along a part order. A preserved order is -/// followed with full backtracking. Otherwise the order comes from ; +/// Plans whole parts and their emitted contour prefixes along a part order. Multi-part holed +/// requests first try one ranked hole chain per endpoint, then retain full backtracking. +/// A preserved order stays fixed. Otherwise the order comes from ; /// when a part on it cannot be reached without crossing parts already cut, the search learns /// "cut this part before those", keeps the parts cut before them and re-plans the rest. Once /// nothing new can be learned, the remaining budget goes to a full search over every ready part. @@ -24,31 +25,47 @@ internal static class JointCuttingPlanSearch /// internal const int StallExpansionsPerEntry = 8; + /// LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions. internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList Order, - IReadOnlyList Findings, int Expansions); + IReadOnlyList Findings, int Expansions, int LeadPrechecks = 0); internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token) { var walk = new Walk(snapshot, token); var count = snapshot.Placements.Count; + var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1); + var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours; + var preferEndpoints = count > 1 && maxContours > 1; try { if (snapshot.PreservePartOrder) { - var kept = walk.Follow(Enumerable.Range(0, count).ToArray(), null, null); + var keptSequence = Enumerable.Range(0, count).ToArray(); + if (preferEndpoints) + { + var preferred = walk.Follow(keptSequence, stall, null, preferredOnly: true); + if (preferred.Order != null) + return walk.Ready(preferred.Order); + } + var kept = walk.Follow(keptSequence, null, null); return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted(); } var centres = snapshot.Placements.Select(Centre).ToArray(); var prerequisites = Enumerable.Range(0, count) .Select(i => new HashSet(snapshot.Dependencies.PrerequisitesOf(i))).ToArray(); - var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1); - var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours; + var sequence = CuttingPartOrder.Plan(Enumerable.Range(0, count).ToArray(), centres, snapshot.StartPoint, prerequisites, token); Node resume = null; while (true) { + if (preferEndpoints) + { + var preferred = walk.Follow(sequence, stall, resume, preferredOnly: true); + if (preferred.Order != null) + return walk.Ready(preferred.Order); + } var attempt = walk.Follow(sequence, stall, resume); if (attempt.Order != null) return walk.Ready(attempt.Order); @@ -69,11 +86,11 @@ internal static class JointCuttingPlanSearch } catch (BudgetExceededException) { - return new(CuttingPlanStatus.NoSolutionWithinBudget, [], walk.Rejected, walk.Expansions); + return new(CuttingPlanStatus.NoSolutionWithinBudget, [], walk.Rejected, walk.Expansions, walk.LeadPrechecks); } catch (OperationCanceledException) { - return new(CuttingPlanStatus.Cancelled, [], [], walk.Expansions); + return new(CuttingPlanStatus.Cancelled, [], [], walk.Expansions, walk.LeadPrechecks); } } @@ -141,13 +158,20 @@ internal static class JointCuttingPlanSearch private readonly List rejected = []; private readonly LeadMaterialSnapshot[] materials = snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray(); + // One feasibility adapter per source part per captured planning attempt: verdicts + // memoize per owned choice for the attempt, never statically or across attempts. + private readonly Dictionary feasibility = []; + private readonly HashSet reportedNoFit = []; internal int Expansions { get; private set; } + /// Lead precheck evaluations, tracked apart from DFS expansions: they are work, not free. + internal int LeadPrechecks { get; private set; } + internal IReadOnlyList Rejected => rejected.Distinct().ToArray(); internal Outcome Ready(IReadOnlyList order) => - new(CuttingPlanStatus.Ready, order, [], Expansions); + new(CuttingPlanStatus.Ready, order, [], Expansions, LeadPrechecks); // Entries are capped; exhaustion is not a proof over all possible entries. internal Outcome Exhausted() @@ -156,7 +180,7 @@ internal static class JointCuttingPlanSearch ? CuttingPlanStatus.NoSolutionWithinBudget : rejected.Any(f => f.Kind == PostVerificationKind.Incomplete) ? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict; - return new(status, [], Rejected, Expansions); + return new(status, [], Rejected, Expansions, LeadPrechecks); } /// @@ -165,7 +189,7 @@ internal static class JointCuttingPlanSearch /// With a stall limit the attempt ends once that many expansions pass without getting /// further along the order; it never backtracks behind its starting node. /// - internal Attempt Follow(int[] sequence, int? stall, Node resume) + internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false) { var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(), null, null); var attempt = new Attempt(sequence); @@ -186,8 +210,7 @@ internal static class JointCuttingPlanSearch progressExpansions = Expansions; else if (stall is int limit && Expansions - progressExpansions > limit) return attempt; - frame.Children ??= Expand(node, sequence, attempt).OrderBy(c => c.Distance) - .ThenBy(c => c.Ordinal).ThenBy(c => c.Contour).ThenBy(c => c.Entry).ToArray(); + frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly); if (frame.Next == frame.Children.Length) { stack.Pop(); @@ -198,7 +221,65 @@ internal static class JointCuttingPlanSearch return attempt; } - private IEnumerable Expand(Node node, int[] sequence, Attempt attempt) + /// + /// The NEXT cut's centre that the outside entry should face, or null for the last + /// part. Supplied order: the next not-yet-finished part in that order (the sequence + /// is re-read after every learned-order replan). Sequence-free fallback: nearest + /// dependency-ready remaining part once the current part counts as finished, stable + /// ordinal ties. Never the current or a finished part. Global coordinates. + /// + private Vector? LookAheadCentre(Node node, int[] sequence, FixedProgramPlacement source) + { + var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet(); + finished.Add(source.SourceOrdinal); + if (sequence != null) + for (var i = node.Order.Length + 1; i < sequence.Length; i++) + if (!finished.Contains(sequence[i])) + return Centre(snapshot.Placements[sequence[i]]); + var from = Centre(source); + Vector? best = null; + var bestDistance = double.PositiveInfinity; + foreach (var candidate in snapshot.Placements + .Where(p => !finished.Contains(p.SourceOrdinal) + && snapshot.Dependencies.IsReady(p.SourceOrdinal, finished)) + .OrderBy(p => p.SourceOrdinal)) + { + var distance = Centre(candidate).DistanceTo(from); + if (distance < bestDistance - 1e-9) + { + bestDistance = distance; + best = Centre(candidate); + } + } + return best; + } + + /// + /// Nearest-first BETWEEN source parts (fallback search keeps its tour), but inside + /// one part and contour stage the automatic rank leads — OrderBy(Distance) alone + /// would undo the look-ahead facing. Legacy (unranked) children keep distance order. + /// + private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly) + { + var edges = Expand(node, sequence, attempt, preferredOnly).ToList(); + if (edges.Count <= 1) + return edges.ToArray(); + // Stable source order: the minimum incremental rapid per source, ties ordinal. + var sourceOrder = edges.GroupBy(e => e.Ordinal) + .OrderBy(g => g.Min(e => e.Distance)).ThenBy(g => g.Key) + .SelectMany((g, rank) => g.Select(e => (Edge: e, Rank: rank))) + .ToDictionary(x => x.Edge, x => x.Rank); + return edges + .OrderBy(e => sourceOrder[e]) + .ThenBy(e => e.ContourRank) + .ThenBy(e => e.Contour) + .ThenBy(e => e.Rank) + .ThenBy(e => e.Distance) + .ThenBy(e => e.Entry) + .ToArray(); + } + + private IEnumerable Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly) { IEnumerable sources; if (node.Active is { } active) @@ -225,17 +306,69 @@ internal static class JointCuttingPlanSearch continue; } var prepared = source.Prepared; + if (prepared.Count > 1 && node.Active == null) + { + // Selecting an endpoint does not cut it. Each endpoint owns a separate + // branch, whose holes are still emitted first and checked normally. + CountExpansion(source); + var perimeters = AutomaticEntries(node, sequence, source, prepared.PerimeterOrdinal); + for (var index = 0; index < perimeters.Count; index++) + { + CountExpansion(source); + var state = new ActivePart(source, [], node.Position, node.Checker, 0, node, + perimeters[index]); + yield return new(new(node.Order, node.Position, node.Checker, state, null), + Centre(source).DistanceTo(node.Position), source.SourceOrdinal, -1, index, index); + } + continue; + } + var preference = node.Active?.Preference; + if (node.Active?.Perimeter is { } outside && preference == null) + preference = PlanHoles(node.Active, outside); var choices = node.Active?.Choices ?? []; var arrival = node.Active?.Arrival ?? node.Position; var before = node.Active?.Before ?? node.Checker; var boundary = node.Active?.Boundary ?? node; var contours = choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal } : Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c)); + // First try one ranked hole chain per endpoint. A later-part failure then + // changes the endpoint before replaying all earlier hole combinations. + // The retained pass below still searches every entry and hole order. + if (preferredOnly && preference != null) + contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1); foreach (var contour in contours) { token.ThrowIfCancellationRequested(); - var entries = prepared.Entries(contour, node.Position - source.Location, snapshot.MaxEntries, token); - for (var entry = 0; entry < entries.Count; entry++) + IReadOnlyList entries; + var contourRank = 0; + if (node.Active?.Perimeter is { } perimeter) + { + if (contour == prepared.PerimeterOrdinal) + entries = [perimeter]; + else + { + contourRank = Array.IndexOf(preference.Route, contour); + var downstream = preference.Route.Skip(contourRank + 1) + .FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1); + var target = downstream < 0 ? preference.PerimeterPierce + : preference.Pierces[downstream]; + entries = SelectEntries(source, contour, target, node.Position - source.Location); + // Preference only reorders retained candidates. Neither a failed + // preferred entry nor an alternate hole order prunes this branch. + if (preference.Entries.TryGetValue(contour, out var preferred)) + entries = entries.OrderBy(e => e.Point.DistanceTo(preferred.Point) + <= PostVerificationGeometry.Epsilon ? 0 : 1).ToArray(); + } + } + else + { + CountExpansion(source); + entries = AutomaticEntries(node, sequence, source, contour); + } + if (entries.Count == 0) + continue; + var entryCount = preferredOnly && node.Active?.Perimeter != null ? System.Math.Min(1, entries.Count) : entries.Count; + for (var entry = 0; entry < entryCount; entry++) { CountExpansion(source); // Before emission/native queries, including rejected candidates. var prefix = choices.Append(entries[entry]).ToArray(); @@ -260,13 +393,111 @@ internal static class JointCuttingPlanSearch ? new Node([.. node.Order, source.Propose(program, execution, prefix, token)], execution.DeparturePoint, checker, null, boundary) : new Node(node.Order, execution.DeparturePoint, checker, - new(source, prefix, arrival, before, distance, boundary), null); - yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal, contour, entry); + new(source, prefix, arrival, before, distance, boundary, + node.Active?.Perimeter, preference), null); + yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal, + contour, entry, entry, contourRank); } } } } + /// + /// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates + /// converted exactly once: rank the (fallback-complemented) catalogue toward the + /// next cut, lazily filter through the shared validator adapter, cap at MaxEntries + /// with side coverage. Empty ONLY when the finite catalogue was fully evaluated and + /// nothing fits — then the honest part/contour finding is recorded once. + /// + private IReadOnlyList AutomaticEntries(Node node, int[] sequence, + FixedProgramPlacement source, int contour) + { + var prepared = source.Prepared!; + var arrival = node.Position - source.Location; + // One global->local conversion of target and arrival; geometry is already rotated. + Vector? local = LookAheadCentre(node, sequence, source) is { } target + ? target - source.Location : null; + return SelectEntries(source, contour, local, arrival); + } + + private ContourEntryFeasibility Adapter(FixedProgramPlacement source) => + feasibility.TryGetValue(source.SourceOrdinal, out var known) ? known + : feasibility[source.SourceOrdinal] = new ContourEntryFeasibility( + source.Prepared, source.Location, source.Material, materials); + + private ContourFeasibilityVerdict Evaluate(FixedProgramPlacement source, ContourEntryCandidate candidate) + { + CountExpansion(source); + var adapter = Adapter(source); + var before = adapter.EvaluationCount; + var verdict = adapter.Check(candidate.Choice, token: token); + LeadPrechecks += adapter.EvaluationCount - before; + return verdict; + } + + private IReadOnlyList SelectEntries(FixedProgramPlacement source, int contour, + Vector? target, Vector arrival) + { + // Last contour has no downstream target, but still needs its exact native + // closest-arrival fallback. Keep target null in the ranker: arrival is not + // a next cut and must not acquire facing-side priority. + var catalogue = source.Prepared.AutomaticEntryCandidatesWithFallbacks(contour, target ?? arrival, token); + var ordered = catalogue.RankTowardNextCut(target, arrival); + var selection = ContourEntrySelection.Select(ordered, + candidate => Evaluate(source, candidate), snapshot.MaxEntries, token); + if (selection.Shortfall == ContourSelectionShortfall.Incomplete && selection.UncertainChoices.Count == 0) + rejected.Add(Finding(source, PostVerificationKind.Incomplete, + $"Contour {contour}: {selection.Reason}")); + else if (selection.Choices.Count == 0 && selection.UncertainChoices.Count == 0 + && reportedNoFit.Add(source.SourceOrdinal * 1000 + contour)) + rejected.Add(Finding(source, null, + $"No tested lead-in fits on cutting contour {contour + 1}: {selection.Reason} " + + "Try reducing the lead-in length in Cutting Settings...; if nearby parts obstruct the lead-in, " + + "space the parts farther apart. Replan to check the changes.")); + // Uncertain candidates are NOT refused by the precheck: they reach the emitted- + // prefix Check and complete replay, which remain the authority on them. + return selection.Choices.Concat(selection.UncertainChoices).Take(snapshot.MaxEntries).ToArray(); + } + + private HolePreference PlanHoles(ActivePart active, ContourChoice perimeter) + { + var source = active.Source; + var prepared = source.Prepared; + var centres = prepared.HoleCentres(token).Select(c => c ?? Vector.Zero).ToArray(); + var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray(); + var localArrival = active.Arrival - source.Location; + var endpoint = perimeter.Point; + var entries = new Dictionary(); + var pierces = holes.ToDictionary(h => h, h => centres[h]); + var route = holes; + try + { + CountExpansion(source); + endpoint = PreferredContourEntries.Pierce(prepared, perimeter, token); + route = CuttingHoleOrder.Plan(holes, centres, localArrival, endpoint, token).ToArray(); + var proposal = PreferredContourEntries.TryPlan(prepared, perimeter, route, centres, + localArrival, candidate => Evaluate(source, candidate), token); + if (proposal.IsPreferred) + foreach (var choice in proposal.HoleChoices) + { + CountExpansion(source); + entries.Add(choice.ContourOrdinal, choice); + pierces[choice.ContourOrdinal] = PreferredContourEntries.Pierce(prepared, choice, token); + } + else + rejected.Add(Finding(source, proposal.Shortfall == ContourSelectionShortfall.Incomplete + ? PostVerificationKind.Incomplete : null, proposal.Reason ?? "No preferred hole path.")); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException) + { + // A proposal is not a gate. Unknown emissions still reach prefix/final + // replay through retained candidates; never label an uncertain probe clear. + rejected.Add(Finding(source, PostVerificationKind.Incomplete, + $"Preferred hole path unavailable: {ex.Message}")); + } + return new(route, entries, pierces, endpoint); + } + private void CountExpansion(FixedProgramPlacement source) { token.ThrowIfCancellationRequested(); @@ -347,12 +578,18 @@ internal static class JointCuttingPlanSearch } private sealed class BudgetExceededException : Exception; + // Every endpoint branch owns its preference; all retained entries/orders remain searchable. + private sealed record HolePreference(int[] Route, IReadOnlyDictionary Entries, + IReadOnlyDictionary Pierces, Vector PerimeterPierce); private sealed record ActivePart(FixedProgramPlacement Source, ContourChoice[] Choices, Vector Arrival, - ReleasedContourState Before, double Distance, Node Boundary); + ReleasedContourState Before, double Distance, Node Boundary, + ContourChoice Perimeter = null, HolePreference Preference = null); /// A search state; Previous links a whole-part boundary to the boundary before it. private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker, ActivePart Active, Node Previous); - private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry); + /// Rank is the automatic selection slot (entry order) inside its contour stage; int.MinValue for legacy children. + private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry, + int Rank = int.MinValue, int ContourRank = 0); private sealed class Frame(Node node) { internal Node Node { get; } = node; diff --git a/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs b/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs new file mode 100644 index 0000000..fcb5818 --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs @@ -0,0 +1,101 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Engine.CuttingPlanning; + +/// +/// One preferred sequence of owned contour choices for a holed part: every remaining hole +/// gets exactly one lead-feasible entry, resolved BACKWARD from the already-chosen +/// perimeter entry — the last hole faces the perimeter's actual emitted pierce, each earlier +/// hole faces the next hole's actual emitted pierce — so the cut chain flows toward the +/// outside start. This is a deterministic recommended v1 proposal, not an optimal joint +/// tour and not an installed program: the search still certifies every rapid, lead and +/// crossing. A blocked preferred rapid is the search's problem (S12), never ignored here. +/// +internal static class PreferredContourEntries +{ + /// Proposed hole choices in cut order, or an explicit no-preference verdict. + internal sealed record Proposal( + IReadOnlyList HoleChoices, + ContourSelectionShortfall Shortfall, + int? BlockedContour, + string? Reason) + { + public bool IsPreferred => Shortfall != ContourSelectionShortfall.Incomplete + && HoleChoices.Count > 0 && BlockedContour is null; + } + + /// + /// Resolves one preferred entry per hole on (cut order, + /// from ), ending at . + /// Arrival proxies avoid circularity: the actual (local) part arrival for the first + /// hole, otherwise the previous hole's centre. Facing target (the downstream actual + /// pierce) and tier still rank first. is the S07 adapter + /// verdict; candidates it refuses or leaves uncertain are not preferred. + /// + internal static Proposal TryPlan( + PreparedContours prepared, + ContourChoice perimeterChoice, + IReadOnlyList holeRoute, + IReadOnlyList centresByOrdinal, + Vector arrival, + Func evaluate, + CancellationToken token = default) + { + if (prepared == null) + throw new ArgumentException("Prepared contours are required.", nameof(prepared)); + if (perimeterChoice == null || !ReferenceEquals(perimeterChoice.Owner, prepared)) + throw new ArgumentException("The perimeter choice must belong to this preparation.", nameof(perimeterChoice)); + if (holeRoute == null || centresByOrdinal == null || evaluate == null) + throw new ArgumentException("Hole route, centres and evaluation are required."); + token.ThrowIfCancellationRequested(); + if (holeRoute.Count == 0) + return new(Array.Empty(), ContourSelectionShortfall.Exhausted, null, null); + + // The downstream target is where the tool ACTUALLY arrives next: the emitted + // contour's first cut/lead motion start (native rounding/clamping included). + var target = Pierce(prepared, perimeterChoice, token); + var choices = new ContourChoice[holeRoute.Count]; + for (var index = holeRoute.Count - 1; index >= 0; index--) + { + token.ThrowIfCancellationRequested(); + var contour = holeRoute[index]; + var arrivalProxy = index == 0 ? arrival : centresByOrdinal[holeRoute[index - 1]]; + // The S04 merged catalogue: preferred kinds when present, fallback kinds + // (compass points, arc midpoints, ...) otherwise — pure circles included. + var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(contour, target, token); + var ranked = catalogue.RankTowardNextCut(target, arrivalProxy); + var selection = ContourEntrySelection.Select(ranked, evaluate, 1, token); + if (selection.Choices.Count == 0) + return new(Array.Empty(), selection.Shortfall, contour, + $"Hole contour {contour} has no preferred lead-feasible entry: {selection.Reason}"); + choices[index] = selection.Choices[0]; + target = Pierce(prepared, choices[index], token); + } + + if (choices.Any(c => c == null!)) + throw new InvalidOperationException("Preferred hole resolution left a gap."); + return new(choices, ContourSelectionShortfall.None, null, null); + } + + /// + /// The actual emitted pierce of one owned choice in local coordinates: the start of the + /// first non-rapid motion (lead-in when present — that is where the next rapid must + /// arrive), which reflects native rounding and clamping of a nominal entry. + /// + internal static Vector Pierce(PreparedContours prepared, ContourChoice choice, CancellationToken token) + { + var program = prepared.EmitCandidateForValidation(choice); + var execution = ExecutionMotionReader.Read(program, Vector.Zero, null, token); + // The diagnostic emission includes the part's scribes before the selected contour. + // Those marks are not the downstream cut's pierce. + var first = execution.Motions.First(m => !m.Rapid && m.Layer != LayerType.Scribe); + return first.Start ?? first.End; + } +} diff --git a/OpenNest.Tests/CuttingPlanning/CircleNextCutTests.cs b/OpenNest.Tests/CuttingPlanning/CircleNextCutTests.cs new file mode 100644 index 0000000..980a629 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/CircleNextCutTests.cs @@ -0,0 +1,139 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class CircleNextCutTests +{ + private static Program WithHoles() + { + var program = LeadPathValidationTests.Rectangle(0, 0, 16, 12); + foreach (var centre in new[] { new Vector(3, 3), new Vector(9, 4), new Vector(11, 8) }) + { + program.MoveTo(centre.X + 1, centre.Y); + program.ArcTo(centre.X + 1, centre.Y, centre.X, centre.Y, RotationType.CCW); + } + return program; + } + + [Theory] + [InlineData(13, 4)] + [InlineData(3, 13)] + [InlineData(-7, 2)] + [InlineData(4, -7)] + public void CirclesPreferTheExactNextCutDirectionWithoutDroppingPolarOrDiagonalOptions(double x, double y) + { + var prepared = PreparedContours.Capture(WithHoles(), HoleLookAheadTests.Parameters()); + var target = new Vector(x, y); + var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(0, target); + var ranked = catalogue.RankTowardNextCut(target, new Vector(-4, -3)); + var expected = prepared.ClosestEntry(0, target).Point; + + Assert.True(ranked[0].Choice.Point.DistanceTo(expected) < 1e-8, + $"Expected next-facing {expected}, got {ranked[0].Choice.Point}"); + Assert.Equal(catalogue.Count, ranked.Count); + Assert.All(catalogue, candidate => Assert.Contains(candidate, ranked)); + for (var angle = 0; angle < 8; angle++) + { + var point = new Vector(3, 3) + new Vector(System.Math.Cos(angle * System.Math.PI / 4), + System.Math.Sin(angle * System.Math.PI / 4)); + Assert.Contains(ranked, c => c.Choice.Point.DistanceTo(point) < 1e-8); + } + } + + [Fact] + public void BlockedExactPointRetainsAFeasiblePolarAlternative() + { + var prepared = PreparedContours.Capture(WithHoles(), HoleLookAheadTests.Parameters()); + var target = new Vector(13, 4); + var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(0, target); + var ranked = catalogue.RankTowardNextCut(target, new Vector(-4, -3)); + var exact = prepared.ClosestEntry(0, target).Point; + var material = LeadMaterialSnapshot.Capture(WithHoles(), Vector.Zero); + var feasibility = new ContourEntryFeasibility(prepared, Vector.Zero, material, []); + var blocked = 0; + var selection = ContourEntrySelection.Select(ranked, candidate => + { + if (candidate.Choice.Point.DistanceTo(exact) < 1e-8) + { + blocked++; + return new(ContourFeasibilityStatus.Blocked, "Exact direction unavailable in this control."); + } + return feasibility.Check(candidate.Choice); + }, 1); + + Assert.True(blocked > 0); + var selected = Assert.Single(selection.Choices); + Assert.True(selected.Point.DistanceTo(new Vector(4, 3)) < 1e-8, + $"Expected east polar fallback, got {selected.Point}"); + Assert.Equal(ContourFeasibilityStatus.Clear, feasibility.Check(selected).Status); + } + + [Theory] + [InlineData(45)] + [InlineData(90)] + public void ConfiguredAngleRoundingStillSnapsTheActualCircleCuts(double increment) + { + var parameters = HoleLookAheadTests.Parameters(); + parameters.RoundLeadInAngles = true; + parameters.LeadInAngleIncrement = increment; + var part = new Part(new Drawing("rounded starts", WithHoles())); + var next = new Part(new Drawing("next part", LeadPathValidationTests.Rectangle(0, 0, 2, 2)), + new Vector(22, 1)); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part, next], new Vector(-2, 3), + confirmedParameters: parameters, preservePartOrder: true)); + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.True(result.IndependentlyReplayed); + var proposed = result.ProposedOrder[0]; + var runs = HoleLookAheadTests.CutRuns(proposed.Execution); + var centres = new[] { new Vector(3, 3), new Vector(9, 4), new Vector(11, 8) }; + for (var i = 0; i < 3; i++) + { + var radial = runs[i].End - centres[proposed.ContourChoices[i].ContourOrdinal]; + var steps = System.Math.Atan2(radial.Y, radial.X) * 180 / System.Math.PI / increment; + Assert.True(System.Math.Abs(steps - System.Math.Round(steps)) < 1e-8); + } + Assert.True(parameters.RoundLeadInAngles); + Assert.Equal(increment, parameters.LeadInAngleIncrement); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(23, 17)] + public void PlannedCircleStartsFaceTheImmediateNextPierceNotTwoFeaturesAhead(double x, double y) + { + var location = new Vector(x, y); + var part = new Part(new Drawing("three round cutouts", WithHoles()), location); + var next = new Part(new Drawing("next part", LeadPathValidationTests.Rectangle(0, 0, 2, 2)), + location + new Vector(22, 1)); + var before = ExplicitContourTests.Fingerprint(part.Program); + var arrival = location + new Vector(-2, 3); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part, next], arrival, + confirmedParameters: HoleLookAheadTests.Parameters(), preservePartOrder: true)); + var result = CuttingPlanService.Plan(snapshot); + Assert.True(result.Status == CuttingPlanStatus.Ready, + $"{result.Status}: {string.Join("; ", result.Findings.Select(f => f.Message))}"); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal)); + var proposal = result.ProposedOrder[0]; + var choices = proposal.ContourChoices; + var runs = HoleLookAheadTests.CutRuns(proposal.Execution); + Assert.Equal(4, runs.Count); + Assert.Equal(new[] { 0, 1, 2, 3 }, choices.Select(c => c.ContourOrdinal)); + var prepared = snapshot.Placements[0].Prepared; + for (var i = 0; i < choices.Count - 1; i++) + { + var target = runs[i + 1].Pierce - location; + var expected = prepared.ClosestEntry(choices[i].ContourOrdinal, target).Point; + Assert.True(choices[i].Point.DistanceTo(expected) < 1e-8, + $"Hole {i}: expected immediate-next-facing {expected}, got {choices[i].Point}"); + // The emitter rounds circle start angles; verify actual output too, allowing + // only that existing rounding rather than a snapped compass direction. + Assert.True((runs[i].End - location).DistanceTo(expected) < 0.01); + } + Assert.Empty(new ReleasedContourState().Check(proposal.Execution, arrival, 1)); + Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program)); + } +} diff --git a/OpenNest.Tests/CuttingPlanning/ContourCandidateEmissionTests.cs b/OpenNest.Tests/CuttingPlanning/ContourCandidateEmissionTests.cs new file mode 100644 index 0000000..52d23f1 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContourCandidateEmissionTests.cs @@ -0,0 +1,303 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// The single-contour diagnostic emitter: identical emitted lead groups in isolation and +/// inside a valid complete program (holes first), with the perimeter keeping its External +/// identity — and the normal perimeter-last gate left untouched. +/// +public class ContourCandidateEmissionTests +{ + private static PreparedContours Capture(Program program, CuttingParameters? parameters = null) => + PreparedContours.Capture(program, parameters ?? ExplicitContourTests.Parameters()); + + // --- fixtures --------------------------------------------------------------- + + /// Square perimeter with two circular holes; Capture lists holes 0-1, perimeter 2. + private static Program TwoHoles(bool reversed = false) + { + var p = ExplicitContourTests.Square(reversed); + foreach (var center in new[] { new Vector(3, 3), new Vector(7, 7) }) + { + p.MoveTo(center.X + 0.5, center.Y); + p.ArcTo(center.X + 0.5, center.Y, center.X, center.Y, RotationType.CCW); + } + return p; + } + + private static Program WithScribe(Program p) + { + p.MoveTo(1, 1); + p.Codes.Add(new LinearMove(new Vector(2, 1)) { Layer = LayerType.Scribe }); + return p; + } + + private static ContourChoice PerimeterCorner(PreparedContours prepared) + { + var corner = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal) + .First(c => c.Kind == AutomaticEntryKind.ConvexCorner).Choice; + return corner; + } + + private static Program CompleteWithHolesFirst(PreparedContours prepared, ContourChoice perimeterChoice) => + prepared.Emit(new[] + { + prepared.ClosestEntry(0, new Vector(3, 3)), + prepared.ClosestEntry(1, new Vector(7, 7)), + perimeterChoice, + }); + + // --- differential fidelity ------------------------------------------------------ + + [Fact] + public void PerimeterInIsolation_MatchesCompleteProgramWithHolesFirst() + { + // Distinct External/Internal leads: if isolation misclassified the perimeter as a + // hole, its emitted lead geometry would differ from the complete program's. + var parameters = ExplicitContourTests.Parameters(); + parameters.ExternalLeadIn = new LineLeadIn { Length = 1.5, ApproachAngle = 45 }; + parameters.InternalLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }; + var prepared = Capture(TwoHoles(), parameters); + var corner = PerimeterCorner(prepared); + + var isolated = prepared.EmitCandidateForValidation(corner); + var complete = CompleteWithHolesFirst(prepared, corner); + + // The perimeter is the LAST contour of the complete program and alone in the probe. + AssertIsolatedMatchesTrailing(isolated, complete); + } + + [Theory] + [InlineData("line")] + [InlineData("arc")] + [InlineData("linearc")] + [InlineData("lineline")] + [InlineData("clean")] + public void EveryLeadStyle_StillIsolatedEqualComplete(string style) + { + var parameters = ExplicitContourTests.Parameters(style); + var prepared = Capture(TwoHoles(), parameters); + var corner = PerimeterCorner(prepared); + + var isolated = prepared.EmitCandidateForValidation(corner); + var complete = CompleteWithHolesFirst(prepared, corner); + AssertIsolatedMatchesTrailing(isolated, complete); + } + + [Fact] + public void HoleInIsolation_MatchesItsSliceOfTheCompleteProgram() + { + var prepared = Capture(TwoHoles()); + var hole = prepared.ClosestEntry(0, new Vector(3, 3)); + + var isolated = prepared.EmitCandidateForValidation(hole); + var complete = CompleteWithHolesFirst(prepared, PerimeterCorner(prepared)); + Assert.Equal(BlockHashes(isolated).Single(), BlockHashes(complete).First()); + } + + [Fact] + public void EarlierHoleChoices_DoNotChangePerimeterEmission() + { + // Two different hole-entry choices/orders: the perimeter block is invariant. + var prepared = Capture(TwoHoles()); + var corner = PerimeterCorner(prepared); + + var orderA = prepared.Emit(new[] + { + prepared.ClosestEntry(0, new Vector(3, 3)), + prepared.ClosestEntry(1, new Vector(7, 7)), + corner, + }); + var orderB = prepared.Emit(new[] + { + prepared.ClosestEntry(1, new Vector(7, 7.5)), + prepared.ClosestEntry(0, new Vector(3, 3.5)), + corner, + }); + var isolated = prepared.EmitCandidateForValidation(corner); + + Assert.Equal(BlockHashes(orderA)[^1..], BlockHashes(orderB)[^1..]); + AssertIsolatedMatchesTrailing(isolated, orderA); + } + + [Fact] + public void Scribes_AppearOnceInIsolation() + { + var prepared = Capture(WithScribe(TwoHoles())); + var corner = PerimeterCorner(prepared); + + var isolated = prepared.EmitCandidateForValidation(corner); + var scribes = NonRapid(isolated).Where(m => m.Layer == LayerType.Scribe).ToList(); + Assert.Single(scribes); + } + + [Fact] + public void ReversedWinding_IsolatedMatchesComplete() + { + var prepared = Capture(TwoHoles(reversed: true)); + var corner = PerimeterCorner(prepared); + + var isolated = prepared.EmitCandidateForValidation(corner); + var complete = CompleteWithHolesFirst(prepared, corner); + AssertIsolatedMatchesTrailing(isolated, complete); + } + + [Fact] + public void RotatedCapture_IsolatedMatchesComplete() + { + var source = TwoHoles(); + source.Rotate(System.Math.PI / 7); + var prepared = Capture(source); + var corner = PerimeterCorner(prepared); + + var isolated = prepared.EmitCandidateForValidation(corner); + var complete = prepared.Emit(new[] + { + prepared.ClosestEntry(0, new Vector(3, 3)), + prepared.ClosestEntry(1, new Vector(7, 7)), + corner, + }); + AssertIsolatedMatchesTrailing(isolated, complete); + } + + [Fact] + public void CircleRoundingAndClamping_AreNotSharedState() + { + // Two circles at different normals with 90-degree rounding and clamping: the seam + // must not leak rounded/clamped state between contours. + var parameters = ExplicitContourTests.Parameters(); + parameters.RoundLeadInAngles = true; + parameters.LeadInAngleIncrement = 90; + parameters.ArcCircleLeadIn = new LineLeadIn { Length = 2 }; + var p = ExplicitContourTests.Square(false); + foreach (var center in new[] { new Vector(3, 3), new Vector(7, 7) }) + { + p.MoveTo(center.X + 0.5, center.Y); + p.ArcTo(center.X + 0.5, center.Y, center.X, center.Y, RotationType.CCW); + } + var prepared = Capture(p, parameters); + var hole0 = prepared.ClosestEntry(0, new Vector(3.5, 3)); + var hole1 = prepared.ClosestEntry(1, new Vector(7, 6.5)); + + var isolated0 = prepared.EmitCandidateForValidation(hole0); + var isolated1 = prepared.EmitCandidateForValidation(hole1); + var complete = prepared.Emit(new[] { hole0, hole1, PerimeterCorner(prepared) }); + + var blocks = BlockHashes(complete); + // Each isolated circle matches its own slice; different normals give different + // emitted circle programs — no cross-contour sharing of rounded state. + Assert.Equal(blocks[0], BlockHashes(isolated0).Single()); + Assert.Equal(blocks[1], BlockHashes(isolated1).Single()); + Assert.NotEqual(blocks[0], blocks[1]); + } + + // --- safety gates --------------------------------------------------------------- + + [Fact] + public void NormalEmit_StillRefusesPerimeterBeforeHoles() + { + var prepared = Capture(TwoHoles()); + var corner = PerimeterCorner(prepared); + + // The diagnostic seam does not loosen the ordinary perimeter-last gate. + Assert.Throws(() => prepared.Emit(new[] { corner })); + Assert.Throws(() => prepared.EmitPrefix(new[] { corner })); + } + + [Fact] + public void ForeignOrForgedChoice_IsRejected() + { + var prepared = Capture(TwoHoles()); + var other = Capture(TwoHoles()); + var foreign = other.Entry(other.PerimeterOrdinal, 0, new Vector(0, 0)); + Assert.Throws(() => prepared.EmitCandidateForValidation(foreign)); + + // A copy re-stamped with this preparation as owner is still rejected when its + // contour or point does not exist on this preparation: ownership alone is not enough. + var forgedContour = other.Entry(0, 0, new Vector(3.5, 3)) with { Owner = prepared, ContourOrdinal = 99 }; + Assert.Throws(() => prepared.EmitCandidateForValidation(forgedContour)); + var forgedEntity = other.Entry(0, 0, new Vector(3.5, 3)) with { Owner = prepared, EntityOrdinal = 99 }; + Assert.Throws(() => prepared.EmitCandidateForValidation(forgedEntity)); + } + + [Fact] + public void SourceProgramAndSettings_AreNotMutated() + { + var source = TwoHoles(); + var before = ExplicitContourTests.Fingerprint(source); + var parameters = ExplicitContourTests.Parameters(); + var prepared = Capture(source, parameters); + var corner = PerimeterCorner(prepared); + + prepared.EmitCandidateForValidation(corner); + + Assert.Equal(before, ExplicitContourTests.Fingerprint(source)); + Assert.Equal(ExplicitContourTests.Parameters().ExternalLeadIn.GetType(), + parameters.ExternalLeadIn.GetType()); + } + + // --- helpers -------------------------------------------------------------------- + + private static List NonRapid(Program program) => + ExecutionMotionReader.Read(program, Vector.Zero, null, default).Motions + .Where(m => !m.Rapid).ToList(); + + /// + /// One hash per emitted contour block: cut/lead motion runs in emission order, split at + /// each first lead-in that follows cut motion (every lead style in Parameters() emits + /// lead-in motions; scribe motions precede all blocks and are excluded). Each block is + /// normalised to its own first motion's start and quantised to 1e-9 because the emitted + /// programs are incremental — the reader resolves absolute positions by accumulating + /// from the program head, so the same code chain differs from a fresh head by ulps. + /// + /// The complete program's LAST contour is the perimeter: assert the isolated + /// probe's blocks equal the trailing block sequence of the complete program. + private static void AssertIsolatedMatchesTrailing(Program isolated, Program complete) + { + var iso = BlockHashes(isolated); + var blocks = BlockHashes(complete); + Assert.True(blocks.Count >= iso.Count); + Assert.Equal(iso, blocks.Skip(blocks.Count - iso.Count).ToList()); + } + + private static List BlockHashes(Program program) + { + var motions = NonRapid(program).Where(m => m.Layer != LayerType.Scribe).ToList(); + var blocks = new List>(); + var current = new List(); + var sawCut = true; + foreach (var motion in motions) + { + var isLead = motion.Layer == LayerType.Leadin || motion.Layer == LayerType.Leadout; + if (motion.Layer == LayerType.Leadin && sawCut && current.Count > 0) + { + blocks.Add(current); + current = new List(); + } + current.Add(motion); + if (!isLead) + sawCut = true; + } + if (current.Count > 0) + blocks.Add(current); + return blocks.Select(Hash).ToList(); + } + + private static string Hash(List block) + { + var origin = block[0].Start ?? block[0].End; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("\n", block.Select(m => + string.Join("|", m.Layer, + Q((m.Start ?? m.End).X - origin.X), Q((m.Start ?? m.End).Y - origin.Y), + Q(m.End.X - origin.X), Q(m.End.Y - origin.Y))))))); + + static string Q(double value) => (value / 1e-9).ToString("F0", CultureInfo.InvariantCulture); + } +} diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs new file mode 100644 index 0000000..28e6258 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs @@ -0,0 +1,421 @@ +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()); + } + + // --- 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); +} diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs new file mode 100644 index 0000000..3ff8c63 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs @@ -0,0 +1,248 @@ +using System.Threading; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// S07: the feasibility adapter certifies EXACTLY what LeadPathValidator certifies for one +/// emitted candidate contour — no more (NoLeadIn is not a plan approval) and no less +/// (incomplete checks never read as clear), cached per captured attempt only. +/// +public class ContourEntryFeasibilityTests +{ + private static readonly Vector At = Vector.Zero; + + private static (PreparedContours Prepared, ContourChoice Choice, LeadMaterialSnapshot Own) + PreparedSquare(string style = "line") + { + var clean = ExplicitContourTests.Square(false); + var prepared = PreparedContours.Capture(clean, ExplicitContourTests.Parameters(style)); + var choice = prepared.Entry(0, 0, new Vector(0, 5)); // left edge, mid-side + return (prepared, choice, LeadMaterialSnapshot.Capture(clean, At)); + } + + private static LeadMaterialSnapshot Box(double x1, double y1, double x2, double y2) + { + var p = new Program(); + p.MoveTo(x1, y1); + p.LineTo(x2, y1); p.LineTo(x2, y2); p.LineTo(x1, y2); p.LineTo(x1, y1); + var snapshot = LeadMaterialSnapshot.Capture(p, Vector.Zero); + Assert.True(snapshot.IsComplete, snapshot.Reason); + return snapshot; + } + + [Fact] + public void FittingStraightLeadIsClear() + { + var (prepared, choice, own) = PreparedSquare(); + var feasibility = new ContourEntryFeasibility(prepared, At, own, []); + + var verdict = feasibility.Check(choice); + + Assert.True(verdict.IsClear, verdict.Reason); + Assert.Equal(ContourFeasibilityStatus.Clear, verdict.Status); + Assert.Equal(1, feasibility.EvaluationCount); + } + + [Fact] + public void ArcLeadCandidateVerdictsComeFromTheSharedValidator() + { + var (prepared, choice, own) = PreparedSquare("arc"); + var feasibility = new ContourEntryFeasibility(prepared, At, own, []); + Assert.True(feasibility.Check(choice).IsClear); + } + + [Fact] + public void NeighbourContactRejectsKeepingReason() + { + var (prepared, choice, own) = PreparedSquare(); + // A neighbouring sheet covering the lead-in approach region. + var neighbour = Box(-1.5, 4.4, -0.05, 5.6); + var feasibility = new ContourEntryFeasibility(prepared, At, own, [neighbour]); + + var verdict = feasibility.Check(choice); + + Assert.Equal(ContourFeasibilityStatus.Blocked, verdict.Status); + Assert.False(verdict.IsClear); + Assert.False(string.IsNullOrEmpty(verdict.Reason)); + } + + [Fact] + public void OwnMaterialCrossingRejects() + { + var clean = ExplicitContourTests.Square(false); + var prepared = PreparedContours.Capture(clean, ExplicitContourTests.Parameters()); + var choice = prepared.Entry(0, 0, new Vector(0, 5)); + // Own snapshot is where the sheet actually is; the placement drifts 0.4 east, so the + // emitted external lead (approach x=-0.21..0 relative, entry at x=0.4 absolute) + // crosses into the sheet's own material. + var own = LeadMaterialSnapshot.Capture(clean, Vector.Zero); + var feasibility = new ContourEntryFeasibility(prepared, new Vector(0.4, 0), own, []); + + var verdict = feasibility.Check(choice); + + Assert.Equal(ContourFeasibilityStatus.Blocked, verdict.Status); + Assert.False(verdict.IsClear); + } + + [Fact] + public void IncompleteMaterialNeverReadsAsClear() + { + var (prepared, choice, _) = PreparedSquare(); + // Two touching rings: the capture itself refuses, producing an incomplete snapshot. + var joined = ExplicitContourTests.Square(false); + var touching = new Program(); + touching.MoveTo(0, 10); + touching.LineTo(10, 10); touching.LineTo(10, 20); touching.LineTo(0, 20); touching.LineTo(0, 10); + joined.Codes.AddRange(touching.Codes); + var incomplete = LeadMaterialSnapshot.Capture(joined, Vector.Zero); + Assert.False(incomplete.IsComplete); + Assert.NotNull(incomplete.Reason); + + var ownVerdict = new ContourEntryFeasibility(prepared, At, incomplete, []).Check(choice); + Assert.Equal(ContourFeasibilityStatus.Incomplete, ownVerdict.Status); + var otherVerdict = new ContourEntryFeasibility(prepared, At, + LeadMaterialSnapshot.Capture(ExplicitContourTests.Square(false), At), [incomplete]).Check(choice); + Assert.Equal(ContourFeasibilityStatus.Incomplete, otherVerdict.Status); + } + + [Fact] + public void NoLeadInClearVerdictIsNotAPlanApproval() + { + var parameters = ExplicitContourTests.Parameters(); + parameters.ExternalLeadIn = new NoLeadIn(); + parameters.InternalLeadIn = new NoLeadIn(); + parameters.ArcCircleLeadIn = new NoLeadIn(); + var clean = ExplicitContourTests.Square(false); + var prepared = PreparedContours.Capture(clean, parameters); + var choice = prepared.Entry(0, 0, new Vector(0, 5)); + var feasibility = new ContourEntryFeasibility(prepared, At, LeadMaterialSnapshot.Capture(clean, At), []); + + var verdict = feasibility.Check(choice); + + // Nothing to certify means the validator passes vacuously — the emitted program + // carries no lead motion at all, so this verdict certifies no lead and the + // complete-plan missing-lead check still has to run later. The adapter must not + // silently drop the distinction: consumers can see it is vacuous by counting leads. + Assert.True(verdict.IsClear); + var motions = ExecutionMotionReader.Read(prepared.Emit(new[] { choice }), At, null, default).Motions; + Assert.Empty(motions.Where(m => !m.Rapid + && (m.Layer == LayerType.Leadin || m.Layer == LayerType.Leadout))); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void ForeignChoiceCannotReuseOrPoisonAnOwnedVerdict(bool foreignFirst, bool unowned) + { + var (prepared, choice, own) = PreparedSquare(); + var (_, otherChoice, _) = PreparedSquare(); + var foreign = unowned + ? new ContourChoice(choice.ContourOrdinal, choice.EntityOrdinal, choice.Point) + : otherChoice; + Assert.Throws(() => prepared.Emit([foreign])); + var adapter = new ContourEntryFeasibility(prepared, At, own, []); + var cold = new ContourEntryFeasibility(prepared, At, own, []).Check(foreign); + Assert.Equal(ContourFeasibilityStatus.Incomplete, cold.Status); + + if (foreignFirst) + Assert.Equal(cold, adapter.Check(foreign)); + else + Assert.True(adapter.Check(choice).IsClear); + + var refused = adapter.Check(foreign); + Assert.Equal(cold, refused); + Assert.Contains("Foreign contour choice", refused.Reason); + Assert.True(adapter.Check(choice).IsClear); + Assert.True(adapter.Check(choice with { }).IsClear); + Assert.Equal(1, adapter.EvaluationCount); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + Assert.ThrowsAny(() => adapter.Check(foreign, token: cancelled.Token)); + Assert.True(adapter.Check(choice).IsClear); + Assert.Equal(1, adapter.EvaluationCount); + } + + [Fact] + public void MalformedEmissionRefusesWithReasonInsteadOfCrashing() + { + var (prepared, choice, own) = PreparedSquare(); + var (other, _, _) = PreparedSquare(); + var forgedContour = choice with { Owner = other, ContourOrdinal = 99 }; + var forgedEntity = choice with { Owner = prepared, EntityOrdinal = 99 }; + var feasibility = new ContourEntryFeasibility(prepared, At, own, []); + + var verdict = feasibility.Check(forgedContour); + Assert.Equal(ContourFeasibilityStatus.Incomplete, verdict.Status); + Assert.False(string.IsNullOrEmpty(verdict.Reason)); + Assert.Equal(ContourFeasibilityStatus.Incomplete, feasibility.Check(forgedEntity).Status); + } + + [Fact] + public void CancellationPropagatesAndPoisonsNothing() + { + var (prepared, choice, own) = PreparedSquare(); + var feasibility = new ContourEntryFeasibility(prepared, At, own, []); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + + Assert.ThrowsAny(() => feasibility.Check(choice, token: cancelled.Token)); + Assert.Equal(0, feasibility.EvaluationCount); + Assert.True(feasibility.Check(choice).IsClear); + } + + [Fact] + public void SameChoiceAndNodeContextIsCachedWithinTheAttemptOnly() + { + var (prepared, choice, own) = PreparedSquare(); + var feasibility = new ContourEntryFeasibility(prepared, At, own, []); + + Assert.True(feasibility.Check(choice, "A").IsClear); + Assert.True(feasibility.Check(choice, "A").IsClear); + Assert.Equal(1, feasibility.EvaluationCount); + + // A different node context is a different key (the cache is deliberately conservative). + Assert.True(feasibility.Check(choice, "B").IsClear); + Assert.Equal(2, feasibility.EvaluationCount); + + // Nothing survives into a fresh planning attempt: no static or cross-instance cache. + var fresh = new ContourEntryFeasibility(prepared, At, own, []); + Assert.True(fresh.Check(choice, "A").IsClear); + Assert.Equal(1, fresh.EvaluationCount); + } + + [Fact] + public void EvaluationIsLazyPerCandidate() + { + var (prepared, choice, own) = PreparedSquare(); + var feasibility = new ContourEntryFeasibility(prepared, At, own, []); + var other = prepared.Entry(0, 1, new Vector(5, 10)); + + // One Check evaluates exactly that candidate — never every point of every contour. + Assert.True(feasibility.Check(other).IsClear); + Assert.Equal(1, feasibility.EvaluationCount); + Assert.NotSame(choice, other); + } + + [Fact] + public void VerdictsAreTheValidatorsVerdictsExactly() + { + // Adapter verdict == LeadPathValidator outcome for the same emitted program. + var (prepared, choice, own) = PreparedSquare(); + var materials = new[] { own }; + var execution = ExecutionMotionReader.Read( + prepared.EmitCandidateForValidation(choice), At, null, default); + var direct = LeadPathValidator.Check(execution, own, materials, default); + + var verdict = new ContourEntryFeasibility(prepared, At, own, materials).Check(choice); + + Assert.Equal(direct.IsClear, verdict.IsClear); + Assert.Equal(direct.Reason, verdict.Reason); + } +} diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs new file mode 100644 index 0000000..b91440e --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs @@ -0,0 +1,207 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// The pure facing/tier/travel ranking of the entry catalogue: deterministic lexicographic +/// order toward a look-ahead target (or toward arrival alone for the last part), with the +/// catalogue itself untouched and entity order never meaningful. +/// +public class ContourEntryRankingTests +{ + private static IReadOnlyList Catalogue(Program program) => + PreparedContours.Capture(program, ExplicitContourTests.Parameters()) + .AutomaticEntryCandidatesWithFallbacks(0); + + // --- fixtures --------------------------------------------------------------- + + /// Square 0..10 in travel order, optionally cyclically reindexed or reversed. + private static Program Square(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; + } + + private static readonly Vector[] SquareVertices = + { + new(0, 0), new(10, 0), new(10, 10), new(0, 10), + }; + + private static Program BumpSquare() + { + // Half-circle bump on the top edge; tangent 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); + return p; + } + + private static bool At(ContourEntryCandidate c, double x, double y) => + c.Choice.Point.DistanceTo(new Vector(x, y)) < 1e-6; + + private static List<(AutomaticEntryKind Kind, double X, double Y)> Shape(IReadOnlyList ranked) => + ranked.Select(c => (c.Kind, System.Math.Round(c.Choice.Point.X, 6), System.Math.Round(c.Choice.Point.Y, 6))).ToList(); + + // --- facing target ------------------------------------------------------------ + + [Fact] + public void LowerRightTarget_PrefersTheBottomRightCorner() + { + var ranked = Catalogue(Square(SquareVertices)).RankTowardNextCut(new Vector(14, -2)); + + // Facing sides right and bottom; the shared corner is the ideal start. + var first = Assert.Single(ranked.Take(1)); + Assert.Equal(AutomaticEntryKind.ConvexCorner, first.Kind); + Assert.True(At(first, 10, 0)); + // It precedes every other corner and midpoint. + Assert.True(ranked.ToList().FindIndex(c => At(c, 10, 0)) < ranked.ToList().FindIndex(c => At(c, 0, 10))); + Assert.True(ranked.ToList().FindIndex(c => At(c, 10, 0)) < ranked.ToList().FindIndex(c => At(c, 5, 0))); + } + + [Fact] + public void FacingMidpoint_BeatsANonFacingCorner() + { + // Target due right: only the right side faces it; the left corners face away. + var ranked = Catalogue(Square(SquareVertices)).RankTowardNextCut(new Vector(14, 5)); + var order = ranked.ToList(); + + var rightMid = order.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 10, 5)); + var farCorner = order.FindIndex(c => c.Kind == AutomaticEntryKind.ConvexCorner && At(c, 0, 10)); + Assert.True(rightMid < farCorner); + } + + [Fact] + public void SameFacingClass_CornerBeatsMidpoint() + { + var ranked = Catalogue(Square(SquareVertices)).RankTowardNextCut(new Vector(14, -2)); + var order = ranked.ToList(); + + var corner = order.FindIndex(c => c.Kind == AutomaticEntryKind.ConvexCorner && At(c, 10, 0)); + var bottomMid = order.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 5, 0)); + var rightMid = order.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 10, 5)); + // The shared facing corner beats both facing midpoints; tier breaks the facing tie. + Assert.True(corner < bottomMid && corner < rightMid); + // Between the two facing midpoints travel decides: right-mid is nearer the target. + Assert.True(rightMid < bottomMid); + } + + [Fact] + public void TangentAndMidpoint_ShareARankTier() + { + Assert.Equal(AutomaticEntryKind.StraightMidpoint.RankTier(), AutomaticEntryKind.TangentJoint.RankTier()); + Assert.True(AutomaticEntryKind.ConvexCorner.RankTier() < AutomaticEntryKind.TangentJoint.RankTier()); + Assert.True(AutomaticEntryKind.TangentJoint.RankTier() < AutomaticEntryKind.NearCorner.RankTier()); + Assert.True(AutomaticEntryKind.NearCorner.RankTier() <= AutomaticEntryKind.TargetFacing.RankTier()); + Assert.Equal(AutomaticEntryKind.NearCorner.RankTier(), AutomaticEntryKind.CircleCompass.RankTier()); + + // Facing still dominates tier: a joint on both facing sides outranks a non-facing corner. + var ranked = Catalogue(BumpSquare()).RankTowardNextCut(new Vector(14, 14)); + var joint = Assert.Single(ranked.Where(c => c.Kind == AutomaticEntryKind.TangentJoint && At(c, 10, 10))); + Assert.True(ranked.ToList().IndexOf(joint) + < ranked.ToList().FindIndex(c => c.Kind == AutomaticEntryKind.ConvexCorner && At(c, 0, 0))); + } + + // --- stability ------------------------------------------------------------------ + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReversedOrReindexedDrawing_SameGeometricOrdering(bool reversed) + { + // Same square geometry, different entity travel order: entity order must never be + // the meaningful tie-break — the geometric ordering is identical. + var vertices = reversed + ? new[] { SquareVertices[3], SquareVertices[2], SquareVertices[1], SquareVertices[0] } + : new[] { SquareVertices[1], SquareVertices[2], SquareVertices[3], SquareVertices[0] }; + var target = new Vector(14, -2); + + Assert.Equal( + Shape(Catalogue(Square(SquareVertices)).RankTowardNextCut(target, new Vector(-1, -1))), + Shape(Catalogue(Square(vertices)).RankTowardNextCut(target, new Vector(-1, -1)))); + } + + [Fact] + public void InputCatalogue_IsNotMutated_AndNothingIsIntroduced() + { + var catalogue = Catalogue(BumpSquare()); + var before = Shape(catalogue); + var snapshot = catalogue.ToList(); + + var ranked = catalogue.RankTowardNextCut(new Vector(14, -2), new Vector(-1, -1)); + + Assert.Equal(before, Shape(snapshot)); // input untouched + Assert.Equal(catalogue.Count, ranked.Count); // permutation only + Assert.True(ranked.All(catalogue.Contains)); + } + + [Fact] + public void Ranking_NeverIntroducesReflexCandidates() + { + // L-outline: (5,5) is reflex for its own travel and absent from the catalogue. + var p = Square(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), + }); + + foreach (var target in new[] { new Vector(14, -2), new Vector(-4, 14), new Vector(14, 14) }) + { + var ranked = Catalogue(p).RankTowardNextCut(target); + Assert.DoesNotContain(ranked, c => At(c, 5, 5)); + } + } + + [Fact] + public void NonFiniteTarget_IsRejected() + { + var catalogue = Catalogue(Square(SquareVertices)); + Assert.Throws(() => + catalogue.RankTowardNextCut(new Vector(double.NaN, 5))); + } + + // --- no target (last part) -------------------------------------------------------- + + [Fact] + public void NoTarget_TierFirstThenDistanceToArrival() + { + var ranked = Catalogue(Square(SquareVertices)).RankTowardNextCut(arrival: new Vector(5, 10)); + var order = ranked.ToList(); + + // Tier first: an outside corner outranks the midpoint the arrival sits on. + var firstCorner = order.FindIndex(c => c.Kind == AutomaticEntryKind.ConvexCorner && At(c, 0, 10)); + var touchedMid = order.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 5, 10)); + Assert.True(firstCorner < touchedMid); + Assert.Equal(AutomaticEntryKind.ConvexCorner, order[0].Kind); + // Within corners, distance to arrival decides (tie broken by stable key): (0,10) and + // (10,10) are equidistant from (5,10), so the lower X key wins. + Assert.True(At(order[0], 0, 10)); + } + + [Theory] + [InlineData(0.1, 0.1, 0.0, 0.0)] + [InlineData(9.9, 9.9, 10.0, 10.0)] + public void NoTarget_StartsNearTheArrival_NotBackAtTheOrigin(double arrivalX, double arrivalY, double expectedX, double expectedY) + { + // The last part faces where the head already is; the ranking must not drag it to + // the plate origin when the arrival is elsewhere. + var ranked = Catalogue(Square(SquareVertices)) + .RankTowardNextCut(arrival: new Vector(arrivalX, arrivalY)); + + Assert.True(At(ranked[0], expectedX, expectedY)); + } + + [Fact] + public void EmptyCatalogue_RanksToEmpty() + { + var empty = new List(); + Assert.Empty(empty.RankTowardNextCut(new Vector(3, 4), new Vector(-1, -1))); + } +} diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntrySelectionTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntrySelectionTests.cs new file mode 100644 index 0000000..d3e0577 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContourEntrySelectionTests.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// The bounded, lazy selection over ranked candidates and the S07 verdict: cap first, side +/// coverage as a corrective scan, honest shortfall metadata — and the untouched tail is +/// never evaluated. Counts, not elapsed time. +/// +public class ContourEntrySelectionTests +{ + private static readonly PreparedContours Owner = + PreparedContours.Capture(ExplicitContourTests.Square(false), ExplicitContourTests.Parameters()); + + private static ContourEntryCandidate Cand(double x, double y, AutomaticEntryKind kind = AutomaticEntryKind.ConvexCorner) => + new(new ContourChoice(0, 0, new Vector(x, y)) { Owner = Owner }, kind); + + private static Func Feasible(params (double X, double Y)[] clearAt) + { + var set = new HashSet<(long, long)>(clearAt.Select(p => Cand(p.X, p.Y).GeometryKey)); + return c => set.Contains(c.GeometryKey) + ? new(ContourFeasibilityStatus.Clear, null) + : new(ContourFeasibilityStatus.Blocked, "test block"); + } + + private static Func AllClear() => + _ => new(ContourFeasibilityStatus.Clear, null); + + private static bool Selected(ContourSelectionResult result, double x, double y) => + result.Choices.Any(c => c.Point.DistanceTo(new Vector(x, y)) < 1e-9); + + [Fact] + public void LaterSafeCandidateSurvivesTwentyRejectedRivals() + { + // 18 blocked facing candidates cannot crowd out the one safe point at the tail. + var candidates = new List(); + for (var i = 0; i < 18; i++) + candidates.Add(Cand(i * 0.001, 0)); + candidates.Add(Cand(100, 100)); + + var result = ContourEntrySelection.Select(candidates, Feasible((100, 100))); + + Assert.Single(result.Choices); + Assert.True(Selected(result, 100, 100)); + Assert.Equal(19, result.EvaluatedCount); + Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall); + } + + [Fact] + public void EveryFeasibleSideIsRepresented() + { + // Corners on left/bottom and right/bottom, plus one point each on top-left and + // top-right; a wide tail of blocked points so coverage must chase the sides lazily. + var candidates = new List + { + Cand(0, 0), Cand(10, 0), // bottom corners + Cand(0, 10, AutomaticEntryKind.StraightMidpoint), // top-left + Cand(10, 10, AutomaticEntryKind.TangentJoint), // top-right + }; + for (var i = 1; i < 30; i++) + candidates.Add(Cand(i * 0.3, 0, AutomaticEntryKind.NearCorner)); // blocked bottom tail + + var result = ContourEntrySelection.Select(candidates, + Feasible((0, 0), (10, 0), (0, 10), (10, 10)), maxEntries: 6); + + Assert.Equal(4, result.Choices.Count); + Assert.True(Selected(result, 0, 10)); + Assert.True(Selected(result, 10, 10)); + // The blocked tail was chased to its end exactly once per missing side scan. + Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall); + } + + [Fact] + public void BlockedSideIsOmittedWithoutManufacturingPoints() + { + // No feasible candidate touches the top side. + var candidates = new List + { + Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10), + }; + + var result = ContourEntrySelection.Select(candidates, Feasible((0, 0), (10, 0))); + + Assert.Equal(2, result.Choices.Count); + Assert.False(Selected(result, 0, 10)); + Assert.False(Selected(result, 10, 10)); + // Every selected choice was feasible: nothing was manufactured. + Assert.All(result.Choices, c => Assert.InRange(c.Point.Y, 0, 0)); + } + + [Fact] + public void CapIsNeverExceededAndRankingComesFirst() + { + var candidates = new List(); + for (var i = 0; i < 10; i++) + candidates.Add(Cand(10 - i * 0.01, 0)); // first-ranked points on the right/bottom + + var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 3); + + Assert.Equal(3, result.Choices.Count); + Assert.Equal(3, result.EvaluatedCount); // stopped the moment the cap filled + Assert.Equal(ContourSelectionShortfall.None, result.Shortfall); + Assert.Equal(10.0, result.Choices[0].Point.X, 6); // rank-first point kept + } + + [Fact] + public void NormalCaseNeverEvaluatesTheUntouchedTail() + { + // Four corners first (all sides covered), then a long clear tail. The cap fills at + // 16 with full coverage, so the chase runs empty and the tail stays untouched. + var candidates = new List + { + Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10), + }; + for (var i = 0; i < 36; i++) + candidates.Add(Cand(1 + i * 0.1, 1)); + + var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 16); + + Assert.Equal(16, result.Choices.Count); + Assert.Equal(16, result.EvaluatedCount); + Assert.DoesNotContain(candidates[16].GeometryKey, result.EvaluatedKeys); + Assert.DoesNotContain(candidates[^1].GeometryKey, result.EvaluatedKeys); + Assert.Equal(ContourSelectionShortfall.None, result.Shortfall); + } + + [Fact] + public void AllBlockedIsExhaustedGeometricImpossibility() + { + var candidates = new[] { Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10) }; + + var result = ContourEntrySelection.Select(candidates, + _ => new(ContourFeasibilityStatus.Blocked, "test block")); + + Assert.Empty(result.Choices); + Assert.Equal(4, result.EvaluatedCount); + Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall); + Assert.Contains("No tested lead-in fits", result.Reason); + } + + [Fact] + public void IncompleteCheckIsNeverGeometricImpossibility() + { + var candidates = new[] { Cand(0, 0), Cand(10, 0) }; + var first = true; + + var result = ContourEntrySelection.Select(candidates, _ => + { + ContourFeasibilityVerdict verdict = first + ? new(ContourFeasibilityStatus.Incomplete, "incomplete material") + : new(ContourFeasibilityStatus.Clear, null); + first = false; + return verdict; + }); + + Assert.Equal(ContourSelectionShortfall.Incomplete, result.Shortfall); + Assert.DoesNotContain("No tested lead-in fits", result.Reason ?? string.Empty); + Assert.Contains("not a geometric verdict", result.Reason); + } + + [Fact] + public void SmallCapsObeyTheCapNotCoverage() + { + var candidates = new List + { + Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10), + }; + + var cap1 = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 1); + var cap3 = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 3); + + Assert.Single(cap1.Choices); + Assert.Equal(3, cap3.Choices.Count); + Assert.Equal(1, cap1.EvaluatedCount); // cap fills before any coverage scan + Assert.Equal(3, cap3.EvaluatedCount); + } + + [Fact] + public void InvalidCapStaysInvalid() + { + Assert.Throws(() => + ContourEntrySelection.Select(Array.Empty(), _ => null!, 0)); + Assert.Throws(() => + ContourEntrySelection.Select(Array.Empty(), _ => null!, -1)); + } + + [Fact] + public void DuplicatePointsNeverConsumeTwoSlots() + { + var candidates = new[] { Cand(5, 5), Cand(5, 5), Cand(5, 5) }; + + var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 2); + + Assert.Single(result.Choices); + Assert.Equal(1, result.EvaluatedCount); // deduplicated before evaluation + } + + [Fact] + public void EmptyCatalogueIsExhaustedWithoutEvaluation() + { + var result = ContourEntrySelection.Select(Array.Empty(), _ => null!); + + Assert.Empty(result.Choices); + Assert.Equal(0, result.EvaluatedCount); + Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall); + } + + [Fact] + public void CancellationStopsBeforeAnyEvaluation() + { + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + var candidates = new[] { Cand(0, 0) }; + + Assert.ThrowsAny(() => + ContourEntrySelection.Select(candidates, _ => new(ContourFeasibilityStatus.Clear, null), + token: cancelled.Token)); + } + + [Fact] + public void CoverageReplacementDropsTheWorstAndResorts() + { + // Cap 5 fills on points covering left/bottom/top; the right side is chased to (6,6), + // which must displace the worst selected point whose removal preserves the others + // ((3,4), the top-only cover is re-covered by (6,6)) and settle at its true rank slot. + var candidates = new List + { + Cand(0, 0), Cand(2, 0), Cand(0, 2), Cand(0, 4), Cand(3, 4), Cand(6, 6), + }; + + var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 5); + + Assert.Equal(5, result.Choices.Count); + Assert.True(Selected(result, 6, 6)); + Assert.False(Selected(result, 3, 4)); + Assert.Equal(6, result.EvaluatedCount); + // Evaluation order IS global rank order; the late chaser sorts to the end. + Assert.Equal(6, result.Choices[^1].Point.X); + Assert.Equal(0, result.Choices[0].Point.X); + Assert.Equal(0, result.Choices[0].Point.Y); + } + + [Fact] + public void CoverageNeverDestroysTheOnlyCoverOfAnotherSide() + { + // (2,0) is the ONLY cover of bottom; chasing the right side must not remove it even + // though it outranks nothing. The chaser replaces (2,4) (top, re-covered by (6,6)). + var candidates = new List + { + Cand(0, 0), Cand(2, 0), Cand(0, 2), Cand(2, 4), Cand(6, 6), + }; + + var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 4); + + // Cap 4: phase 1 keeps the first four (right not covered: (2,4) left+top only). + Assert.True(Selected(result, 2, 0)); // sole bottom cover survives + Assert.True(Selected(result, 6, 6)); // right-side chaser placed + Assert.False(Selected(result, 2, 4)); // safely displaced (top re-covered) + Assert.Equal(ContourSelectionShortfall.None, result.Shortfall); + } +} diff --git a/OpenNest.Tests/CuttingPlanning/CuttingHoleOrderTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingHoleOrderTests.cs new file mode 100644 index 0000000..692a975 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/CuttingHoleOrderTests.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// The pure hole-path proposal: an OPEN path arrival -> every hole exactly once -> the fixed +/// perimeter entry, reusing the bounded whole-part routing machinery. Optimum comparisons +/// are computed from coordinates by brute force in the test — never hand-estimated. +/// +public class CuttingHoleOrderTests +{ + private static readonly Vector Arrival = new(0, 0); + + // Asymmetric fixture: without the terminal edge the route should end at C (the far + // cluster point); with the perimeter entry at (6,0) the route must end at B instead, + // so the endpoint changes the ORDER, not merely the total. + private static readonly Vector A = new(1, 0); + private static readonly Vector B = new(5, 0); + private static readonly Vector C = new(3, 10); + private static readonly Vector Entry = new(6, 0); + + private static double Cost(IReadOnlyList route, IReadOnlyList centres, + Vector arrival, Vector? endpoint) + { + var total = 0.0; + var position = arrival; + foreach (var hole in route) + { + total += position.DistanceTo(centres[hole]); + position = centres[hole]; + } + return endpoint is { } e ? total + position.DistanceTo(e) : total; + } + + private static double Minimum(IReadOnlyList holes, IReadOnlyList centres, + Vector arrival, Vector? endpoint) + { + double Best(IEnumerable remaining, Vector from, double soFar) + { + if (!remaining.Any()) + return soFar + (endpoint is { } e ? from.DistanceTo(e) : 0); + return remaining.Min(next => Best(remaining.Where(h => h != next), + centres[next], soFar + from.DistanceTo(centres[next]))); + } + return Best(holes, arrival, 0); + } + + [Fact] + public void EmptyHoleListReturnsEmptyOrder() + { + Assert.Empty(CuttingHoleOrder.Plan(Array.Empty(), new[] { Arrival }, Arrival, Entry)); + } + + [Fact] + public void SingleHoleIsTheOnlyOrder() + { + var centres = new[] { A }; + var order = CuttingHoleOrder.Plan(new[] { 0 }, centres, Arrival, Entry); + Assert.Equal(new[] { 0 }, order); + } + + [Fact] + public void PerimeterEntryChangesTheRouteWhereBruteForceSaysItShould() + { + var centres = new[] { A, B, C }; + var holes = new[] { 0, 1, 2 }; + + var open = CuttingPartOrder.Plan(centres, Arrival, + holes.Select(_ => (IReadOnlyCollection)Array.Empty()).ToList(), default); + var faced = CuttingHoleOrder.Plan(holes, centres, Arrival, Entry); + + // The endpoint genuinely changes the proposal: the open path ends at C (away from + // the entry), the faced path ends at B (next to the entry). + Assert.Equal(2, open[^1]); + Assert.Equal(1, faced[^1]); + + // Each is the brute-force optimum for its own cost function (computed here). + Assert.Equal(Minimum(holes, centres, Arrival, null), Cost(open, centres, Arrival, null), 9); + Assert.Equal(Minimum(holes, centres, Arrival, Entry), Cost(faced, centres, Arrival, Entry), 9); + } + + [Fact] + public void EveryRemainingHoleAppearsExactlyOnceAndNeverThePerimeter() + { + // Ordinals are the part's holes; the perimeter (say 7) is not among them. + var centres = new[] { A, B, C, new Vector(-4, 2), new Vector(2, -3), new Vector(8, 8), + new Vector(0, 6), new Vector(6, -6) }; + var holes = new[] { 0, 1, 2, 3, 4, 5, 6 }; + + var order = CuttingHoleOrder.Plan(holes, centres, Arrival, Entry); + + Assert.Equal(holes.Length, order.Count); + Assert.Equal(holes.OrderBy(h => h), order.OrderBy(h => h)); + Assert.DoesNotContain(7, order); // the perimeter ordinal is never visited + } + + [Fact] + public void OptimalForRandomSetAgainstBruteForce() + { + var random = new Random(20261006); + var centres = Enumerable.Range(0, 7) + .Select(_ => new Vector(random.NextDouble() * 20 - 10, random.NextDouble() * 20 - 10)) + .ToList(); + var entry = new Vector(12.5, -11.25); + var holes = Enumerable.Range(0, 7).ToArray(); + + var order = CuttingHoleOrder.Plan(holes, centres, Arrival, entry); + + // The bounded heuristic may not always equal the true optimum, so assert it is no + // worse than the nearest-neighbour baseline and within 1.05x brute force. + var brute = Minimum(holes, centres, Arrival, entry); + Assert.True(Cost(order, centres, Arrival, entry) <= brute * 1.05 + 1e-9, + $"proposed {Cost(order, centres, Arrival, entry):F4} > 1.05 x brute {brute:F4}"); + } + + [Fact] + public void TiesBreakByStableOrdinal() + { + // Exact-distance pair from the arrival: nearest-neighbour takes the lower ordinal, + // and strict-improvement-only passes never swap an equal-cost order arbitrarily. + var centres = new[] { new Vector(5, 0), new Vector(-5, 0) }; + var noEndpoint = Array.Empty(); + + var first = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival, new Vector(0, 100)); + var second = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival, new Vector(0, 100)); + + Assert.Equal(new[] { 0, 1 }, first); + Assert.Equal(first, second); + } + + [Fact] + public void CancellationPropagates() + { + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + var centres = new[] { A, B, C }; + + Assert.ThrowsAny(() => + CuttingHoleOrder.Plan(new[] { 0, 1, 2 }, centres, Arrival, Entry, cancelled.Token)); + } + + [Fact] + public void NoEndpointMatchesLegacyWholePartRouteExactly() + { + // The whole-part overload without an endpoint must be untouched by the extension. + var random = new Random(7); + var centres = Enumerable.Range(0, 8) + .Select(_ => new Vector(random.NextDouble() * 30, random.NextDouble() * 30)) + .ToList(); + var none = Array.Empty(); + var prerequisites = centres.Select(_ => (IReadOnlyCollection)none).ToList(); + + var legacy = CuttingPartOrder.Plan(centres, Arrival, prerequisites, default); + var extended = CuttingPartOrder.Plan(centres, Arrival, prerequisites, default, endpoint: null); + + Assert.Equal(legacy, extended); + } +} diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs new file mode 100644 index 0000000..5992e23 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs @@ -0,0 +1,151 @@ +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Diagnostics; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class CuttingPlanMessageTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Describe_MissingOrZeroLengthLead_ExplainsSettingsWithoutBlamingSearch(bool keepOrder, bool zeroLength) + { + var part = new Part(new Drawing("sample", ExplicitContourTests.Square(false)), new Vector(1, 1)); + var plate = new Nest().CreatePlate(); + plate.Size = new Size(100, 100); + plate.Parts.Add(part); + var program = part.Program; + var original = OwnedProgramCopy.Copy(program); + var settings = new CuttingParameters(); + if (zeroLength) + settings.ExternalLeadIn = new LineLeadIn { Length = 0, ApproachAngle = 90 }; + + var proposal = CuttingPlanBatch.Capture([plate], settings, keepOrder).Plan(); + + Assert.False(proposal.CanApply); + var result = Assert.Single(proposal.Plates).Result; + Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status); + Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.MissingLeadIn); + var text = string.Join("\n", proposal.Describe("in")); + Assert.Contains("Plate 1: blocked: missing or zero-length lead-in.", text); + Assert.Contains("Open Cutting Settings...", text); + Assert.Contains("other than None", text); + Assert.Contains("nonzero length", text); + Assert.Contains("Part 1 (sample):", text); + Assert.Contains("Cutting contour 1", text); + Assert.DoesNotContain("search limit", text); + Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status); + Assert.Same(program, part.Program); + Assert.True(ProgramContent.Equal(original, part.Program)); + Assert.Null(plate.CuttingParameters); + Assert.False(part.HasManualLeadIns); + + // Choosing valid settings fixes the refusal; reporting never changes settings or bypasses checks. + var ready = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), keepOrder).Plan(); + Assert.True(ready.CanApply, string.Join("\n", ready.Describe("in"))); + Assert.True(Assert.Single(ready.Plates).Result.IndependentlyReplayed); + Assert.DoesNotContain("missing or zero-length", string.Join("\n", ready.Describe("in"))); + Assert.Same(program, part.Program); + Assert.True(ProgramContent.Equal(original, part.Program)); + } + + [Fact] + public void Describe_LockedProgramWithoutLead_ExplainsThatSettingsCannotRegenerateIt() + { + var part = new Part(new Drawing("locked sample", ExplicitContourTests.Square(false)), new Vector(1, 1)) + { + LeadInsLocked = true, + }; + var plate = new Nest().CreatePlate(); + plate.Size = new Size(100, 100); + plate.Parts.Add(part); + var program = part.Program; + + var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan(); + + Assert.Equal(CuttingPlanStatus.ConstraintConflict, Assert.Single(proposal.Plates).Result.Status); + Assert.False(proposal.CanApply); + var text = string.Join("\n", proposal.Describe("in")); + Assert.Contains("missing or zero-length lead-in", text); + Assert.Contains("If the affected part is locked, edit its lead-ins or unlock it before replanning.", text); + Assert.Contains("Part 1 (locked sample):", text); + Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status); + Assert.True(part.LeadInsLocked); + Assert.Same(program, part.Program); + } + + [Fact] + public void Describe_LeadHitsNeighbour_SuggestsSpacingOrShorterLeadWithoutAllowingApply() + { + var settings = ExplicitContourTests.Parameters(); + var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10); + var prepared = PreparedContours.Capture(clean, settings); + var part = new Part(new Drawing("lead blocked", clean)); + Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(-1, 5))]), true)); + var obstacle = new Part(new Drawing("neighbour", LeadPathValidationTests.Rectangle(-0.3, 4.5, -0.1, 5.5))); + var plate = new Nest().CreatePlate(); + plate.Parts.Add(part); + plate.Parts.Add(obstacle); + var original = OwnedProgramCopy.Copy(part.Program); + + var proposal = CuttingPlanBatch.Capture([plate], settings, true).Plan(); + var text = string.Join("\n", proposal.Describe("in")); + + Assert.False(proposal.CanApply); + Assert.Contains("another placed material", text); + Assert.Contains("spacing the parts farther apart", text); + Assert.Contains("reducing the lead-in", text); + Assert.Contains("then replan", text); + Assert.DoesNotContain("other than None", text); + Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status); + Assert.True(ProgramContent.Equal(original, part.Program)); + Assert.True(part.LeadInsLocked); + } + + [Fact] + public void Describe_NoTestedLeadFits_SuggestsChangesButDoesNotClaimTheyWillWork() + { + var settings = ExplicitContourTests.Parameters(); + var part = new Part(new Drawing("inside", LeadPathValidationTests.Rectangle(0, 0, 10, 10))); + var wrap = new Part(new Drawing("wrap", LeadPathValidationTests.Rectangle(-0.05, -0.05, 10.05, 10.05))); + var plate = new Nest().CreatePlate(); + plate.Parts.Add(part); + plate.Parts.Add(wrap); + + var proposal = CuttingPlanBatch.Capture([plate], settings, false).Plan(); + var text = string.Join("\n", proposal.Describe("in")); + + Assert.Contains("No tested lead-in fits", text); + Assert.Contains("Try reducing the lead-in length", text); + Assert.Contains("if nearby parts obstruct the lead-in, space the parts farther apart", text); + Assert.Contains("Replan to check the changes.", text); + Assert.False(proposal.CanApply); + Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status); + } + + [Theory] + [InlineData(null)] + [InlineData(PostVerificationKind.RapidCrossing)] + public void Describe_OtherRefusals_KeepTheirStatusAndFindings(PostVerificationKind? kind) + { + // The presentation uses the typed finding, not matching words in a diagnostic. + var finding = new CuttingPlanFinding(null, null, null, null, kind, "MissingLeadIn lookalike text"); + var result = new CuttingPlanResult(CuttingPlanStatus.NoSolutionWithinBudget, findings: [finding]); + var proposal = new CuttingPlanProposal([new(new Nest().CreatePlate(), 7, result, result, null)], + ExplicitContourTests.Parameters()); + + var text = string.Join("\n", proposal.Describe("in")); + + Assert.Contains("Plate 7: no complete plan was found within the search limit.", text); + Assert.Contains("planning with the current order was refused:", text); + Assert.Contains(finding.Message, text); + Assert.DoesNotContain("Open Cutting Settings...", text); + Assert.DoesNotContain("missing or zero-length lead-in", text); + Assert.False(proposal.CanApply); + } +} diff --git a/OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs b/OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs new file mode 100644 index 0000000..b188e2c --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs @@ -0,0 +1,85 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Diagnostics; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class HoleEndpointBudgetTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LaterFixedApproachTriesOtherEndpointsBeforeHoleCombinations(bool preserveOrder) + { + var parameters = HoleLookAheadTests.Parameters(); + var clean = HoleLookAheadTests.MixedHoles(); + foreach (var centre in new[] { new Vector(4, 8), new Vector(8, 8) }) + { + clean.MoveTo(centre.X + 0.75, centre.Y); + clean.ArcTo(centre.X + 0.75, centre.Y, centre.X, centre.Y, RotationType.CCW); + } + var first = new Part(new Drawing("four holes", clean)); + var nextClean = LeadPathValidationTests.Rectangle(0, 0, 2, 2); + nextClean.MoveTo(-22, 10); + nextClean.Codes.Add(new LinearMove(-21, 10) { Layer = LayerType.Scribe }); + nextClean.MoveTo(0, 10); + nextClean.Codes.Add(new LinearMove(1, 10) { Layer = LayerType.Scribe }); + var next = new Part(new Drawing("fixed marked part", nextClean), new Vector(20, 2)); + var nextPrepared = PreparedContours.Capture(nextClean, parameters); + Assert.True(next.RestoreLeadInProgram(nextPrepared.Emit( + [nextPrepared.ClosestEntry(0, new Vector(3, 1))]), true)); + var before = new[] { first, next }.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray(); + var start = new Vector(-2, 4); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, next], start, + confirmedParameters: parameters, preservePartOrder: preserveOrder)); + var source = snapshot.Placements[0]; + var prepared = source.Prepared; + var adapter = new ContourEntryFeasibility(prepared, source.Location, source.Material, + snapshot.Placements.Select(p => p.Material).ToArray()); + var target = new Vector(21, 3); + var outside = ContourEntrySelection.Select(prepared.AutomaticEntryCandidatesWithFallbacks(4, target) + .RankTowardNextCut(target, start), c => adapter.Check(c.Choice)).Choices[0]; + var centres = prepared.HoleCentres().Select(c => c ?? Vector.Zero).ToArray(); + var route = CuttingHoleOrder.Plan(new[] { 0, 1, 2, 3 }, centres, start, + PreferredContourEntries.Pierce(prepared, outside, default)); + var preferred = PreferredContourEntries.TryPlan(prepared, outside, route, centres, start, + c => adapter.Check(c.Choice)); + Assert.True(preferred.IsPreferred, preferred.Reason); + var execution = ExecutionMotionReader.Read(prepared.Emit(preferred.HoleChoices.Append(outside).ToArray()), + Vector.Zero, start, default); + var checker = new ReleasedContourState(); + Assert.Empty(checker.Check(execution, start, 1)); + Assert.Contains(checker.Check(snapshot.Placements[1].Execution, execution.DeparturePoint, 2), + f => f.Kind == PostVerificationKind.RapidCrossing); + + var result = CuttingPlanService.Plan(snapshot); + + Assert.True(result.Status == CuttingPlanStatus.Ready, + $"{result.Status}, {result.Expansions} expansions: " + string.Join("; ", result.Findings.Select(f => f.Message))); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal)); + Assert.Equal(5, result.ProposedOrder[0].ContourChoices.Count); + Assert.Equal(4, result.ProposedOrder[0].ContourChoices[^1].ContourOrdinal); + Assert.NotEqual(outside.Point, result.ProposedOrder[0].ContourChoices[^1].Point); + Assert.InRange(result.Expansions, 1, 20000); + var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default); + Assert.Equal(CuttingPlanStatus.Ready, replay.Status); + Assert.True(replay.IndependentlyReplayed); + Assert.Equal(before, new[] { first, next }.Select(p => ExplicitContourTests.Fingerprint(p.Program))); + Assert.Equal(before[1], ExplicitContourTests.Fingerprint(result.ProposedOrder[1].CopyProgram())); + + var bounded = CuttingPlanService.Plan(new CuttingPlanRequest([first, next], start, + expansionBudget: 1, confirmedParameters: parameters, preservePartOrder: preserveOrder)); + Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status); + Assert.Equal(1, bounded.Expansions); + Assert.Empty(bounded.ProposedOrder); + Assert.False(bounded.IndependentlyReplayed); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + Assert.Equal(CuttingPlanStatus.Cancelled, CuttingPlanService.Plan(snapshot, cancelled.Token).Status); + Assert.Equal(before, new[] { first, next }.Select(p => ExplicitContourTests.Fingerprint(p.Program))); + } +} diff --git a/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs b/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs new file mode 100644 index 0000000..4b01bd8 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs @@ -0,0 +1,215 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Diagnostics; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class HoleLookAheadTests +{ + private static readonly Vector Start = new(-2, 4); + + internal static Program MixedHoles() + { + var clean = LeadPathValidationTests.Rectangle(0, 0, 12, 10); + clean.MoveTo(3, 3); + clean.LineTo(5, 3); clean.LineTo(5, 5); clean.LineTo(3, 5); clean.LineTo(3, 3); + clean.MoveTo(9, 4); + clean.ArcTo(9, 4, 8, 4, RotationType.CCW); + return clean; + } + + internal static CuttingParameters Parameters() + { + var p = ExplicitContourTests.Parameters(); + p.InternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }; + p.ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }; + p.PierceClearance = 0; + return p; + } + + [Fact] + public void MixedHolesFaceTheNextContourAndRectanglesUseFeasibleCorners() + { + var parameters = Parameters(); + var part = new Part(new Drawing("mixed holes", MixedHoles())); + var next = new Part(new Drawing("next", LeadPathValidationTests.Rectangle(0, 0, 2, 2)), new Vector(20, 2)); + var fingerprint = ExplicitContourTests.Fingerprint(part.Program); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part, next], Start, + confirmedParameters: parameters, preservePartOrder: true)); + var result = CuttingPlanService.Plan(snapshot); + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + var proposal = result.ProposedOrder[0]; + Assert.Equal(new[] { 0, 1, 2 }, proposal.ContourChoices.Select(c => c.ContourOrdinal)); + var rectangle = proposal.ContourChoices[0].Point; + Assert.True(rectangle.X > 4.9 && (rectangle.Y < 3.1 || rectangle.Y > 4.9), + $"Expected a right-facing rectangle corner, got {rectangle}"); + Assert.True(proposal.ContourChoices[1].Point.X > 8, "Circle must finish toward the outside start."); + Assert.True(proposal.ContourChoices[^1].Point.X > 11.9, "Outside start must face the next part."); + var runs = CutRuns(proposal.Execution); + Assert.Equal(3, runs.Count); + for (var i = 0; i < runs.Count - 1; i++) + Assert.True(runs[i].End.X < runs[i + 1].Pierce.X, "Actual emitted cuts must flow rightward."); + Assert.Empty(new ReleasedContourState().Check(proposal.Execution, Start, 1)); + Assert.Equal(fingerprint, ExplicitContourTests.Fingerprint(part.Program)); + } + + [Fact] + public void CrossingPreferredHolePathBacktracksToACheckedAlternative() + { + // Calibrated for next-facing circle entries: the preferred rapid from hole 1 + // to hole 2 touches already-cut hole 3; retain checked entry backtracking. + var centres = new[] { new Vector(3, 3), new Vector(6, 15), new Vector(3, 7), new Vector(3, 11) }; + var clean = LeadPathValidationTests.Rectangle(0, 0, 18, 18); + foreach (var c in centres) + { + clean.MoveTo(c.X + 1.75, c.Y); + clean.ArcTo(c.X + 1.75, c.Y, c.X, c.Y, RotationType.CCW); + } + var part = new Part(new Drawing("crossing preference", clean)); + var unchanged = ExplicitContourTests.Fingerprint(part.Program); + var start = new Vector(-2, 9); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], start, + confirmedParameters: Parameters(), preservePartOrder: true)); + var source = snapshot.Placements[0]; + var prepared = source.Prepared; + var adapter = new ContourEntryFeasibility(prepared, source.Location, source.Material, []); + var outside = ContourEntrySelection.Select(prepared.AutomaticEntryCandidatesWithFallbacks(4) + .RankTowardNextCut(null, start), c => adapter.Check(c.Choice)).Choices[0]; + var route = CuttingHoleOrder.Plan(new[] { 0, 1, 2, 3 }, centres, start, + PreferredContourEntries.Pierce(prepared, outside, default)); + var preferred = PreferredContourEntries.TryPlan(prepared, outside, route, centres, start, + c => adapter.Check(c.Choice)); + Assert.True(preferred.IsPreferred, preferred.Reason); + var unsafeChoices = preferred.HoleChoices.Append(outside).ToArray(); + var unsafeProgram = prepared.Emit(unsafeChoices); + var unsafeExecution = ExecutionMotionReader.Read(unsafeProgram, Vector.Zero, start, default); + Assert.Contains(new ReleasedContourState().Check(unsafeExecution, start, 1), + f => f.Kind == PostVerificationKind.RapidCrossing); + var rejected = CuttingPlanService.ReplayPrograms(snapshot, + [source.Propose(unsafeProgram, unsafeExecution, unsafeChoices)], 0, default); + Assert.NotEqual(CuttingPlanStatus.Ready, rejected.Status); + Assert.False(rejected.IndependentlyReplayed); + + var result = CuttingPlanService.Plan(snapshot); + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + var actual = result.ProposedOrder[0]; + Assert.Equal(outside.Point, actual.ContourChoices[^1].Point); + Assert.Equal(route, actual.ContourChoices.Take(4).Select(c => c.ContourOrdinal)); + Assert.NotEqual(unsafeChoices.Single(c => c.ContourOrdinal == 2).Point, + actual.ContourChoices.Single(c => c.ContourOrdinal == 2).Point); + Assert.Empty(new ReleasedContourState().Check(actual.Execution, start, 1)); + Assert.Equal(unchanged, ExplicitContourTests.Fingerprint(part.Program)); + } + + [Fact] + public void LaterLockedPartForcesNewPerimeterAndRecomputedHolePreference() + { + var parameters = Parameters(); + var first = new Part(new Drawing("holes", MixedHoles())); + var nextClean = LeadPathValidationTests.Rectangle(0, 0, 2, 2); + // The fixed next program starts above-left, then moves above the first part before + // cutting on the right. Its material centre alone cannot predict that approach. + nextClean.MoveTo(-22, 10); + nextClean.Codes.Add(new LinearMove(-21, 10) { Layer = LayerType.Scribe }); + nextClean.MoveTo(0, 10); + nextClean.Codes.Add(new LinearMove(1, 10) { Layer = LayerType.Scribe }); + var next = new Part(new Drawing("locked marked part", nextClean), new Vector(20, 2)); + var nextPrepared = PreparedContours.Capture(nextClean, parameters); + Assert.True(next.RestoreLeadInProgram(nextPrepared.Emit( + [nextPrepared.ClosestEntry(0, new Vector(3, 1))]), true)); + var lockedFingerprint = ExplicitContourTests.Fingerprint(next.Program); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, next], Start, + confirmedParameters: parameters, preservePartOrder: true)); + var source = snapshot.Placements[0]; + var prepared = source.Prepared; + var adapter = new ContourEntryFeasibility(prepared, source.Location, source.Material, + snapshot.Placements.Select(p => p.Material).ToArray()); + var preferredOutside = ContourEntrySelection.Select(prepared.AutomaticEntryCandidatesWithFallbacks(2, new Vector(21, 3)) + .RankTowardNextCut(new Vector(21, 3), Start), c => adapter.Check(c.Choice)).Choices[0]; + var centres = prepared.HoleCentres().Select(c => c ?? Vector.Zero).ToArray(); + var route = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Start, + PreferredContourEntries.Pierce(prepared, preferredOutside, default)); + var preferred = PreferredContourEntries.TryPlan(prepared, preferredOutside, route, centres, Start, + c => adapter.Check(c.Choice)); + Assert.True(preferred.IsPreferred, preferred.Reason); + var firstExecution = ExecutionMotionReader.Read(prepared.Emit( + preferred.HoleChoices.Append(preferredOutside).ToArray()), Vector.Zero, Start, default); + var state = new ReleasedContourState(); + Assert.Empty(state.Check(firstExecution, Start, 1)); + Assert.Contains(state.Check(snapshot.Placements[1].Execution, firstExecution.DeparturePoint, 2), + f => f.Kind == PostVerificationKind.RapidCrossing); + + var result = CuttingPlanService.Plan(snapshot); + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + Assert.NotEqual(preferredOutside.Point, result.ProposedOrder[0].ContourChoices[^1].Point); + Assert.NotEqual(preferred.HoleChoices.Select(c => c.Point).ToArray(), + result.ProposedOrder[0].ContourChoices.Take(2).Select(c => c.Point).ToArray()); + Assert.Equal(lockedFingerprint, ExplicitContourTests.Fingerprint(result.ProposedOrder[1].CopyProgram())); + Assert.Equal(lockedFingerprint, ExplicitContourTests.Fingerprint(next.Program)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void HoleProgramsRetainScribesOnceAndNeverInstallOnFailure(bool tabs) + { + var clean = MixedHoles(); + clean.MoveTo(-2, -2); + clean.Codes.Add(new LinearMove(-1, -1) { Layer = LayerType.Scribe }); + var parameters = Parameters(); + parameters.TabsEnabled = tabs; + parameters.TabConfig = new NormalTab { Size = 0.2 }; + parameters.ExternalLeadOut = new ArcLeadOut { Radius = 0.2 }; + var part = new Part(new Drawing("marked", clean)); + var fingerprint = ExplicitContourTests.Fingerprint(part.Program); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], Start, + confirmedParameters: parameters, preservePartOrder: true)); + if (tabs) + { + Assert.NotEqual(CuttingPlanStatus.Ready, result.Status); + Assert.Empty(result.ProposedOrder); + Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.Incomplete); + } + else + { + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + var actual = result.ProposedOrder[0]; + Assert.Equal(2, actual.ContourChoices[^1].ContourOrdinal); + Assert.Equal(3, actual.ContourChoices.Select(c => c.ContourOrdinal).Distinct().Count()); + Assert.Single(actual.Execution.Motions.Where(m => !m.Rapid && m.Layer == LayerType.Scribe)); + } + Assert.Equal(fingerprint, ExplicitContourTests.Fingerprint(part.Program)); + } + + internal static List<(Vector Pierce, Vector End)> CutRuns(OwnedExecution execution) + { + var runs = new List<(Vector Pierce, Vector End)>(); + Vector? pierce = null; + var end = Vector.Zero; + foreach (var motion in execution.Motions) + { + if (motion.Rapid) + { + if (pierce is { } p) runs.Add((p, end)); + pierce = null; + } + else if (motion.Layer != LayerType.Scribe) + { + pierce ??= motion.Start ?? motion.End; + end = motion.End; + } + } + if (pierce is { } last) runs.Add((last, end)); + return runs; + } + + private static string Describe(CuttingPlanResult result) => + $"{result.Status}: " + string.Join("; ", result.Findings.Select(f => f.Message)); +} diff --git a/OpenNest.Tests/CuttingPlanning/PerimeterLookAheadTests.cs b/OpenNest.Tests/CuttingPlanning/PerimeterLookAheadTests.cs new file mode 100644 index 0000000..499594c --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/PerimeterLookAheadTests.cs @@ -0,0 +1,373 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// S09: at a part boundary the outside contour's automatic entry faces the NEXT part, and +/// the emitted rapids are no worse than the measured legacy layout (BASELINE: legacy picks +/// the entry nearest the arrival — for this fixture the lower-left corner each time, so +/// every departure trails the left edge and each inter-part rapid carries the full 10.1 +/// pitch plus lead offsets). No-hole parts only — holed parts keep the legacy path for S12. +/// +public class PerimeterLookAheadTests +{ + // Measured on the legacy search before this slice (Capture_LegacyBaselineNumbers, + // three squares at 0/10.5/21, origin start, 0.15 line leads, no lead-out): every entry + // the arrival-nearest lower-left corner (0,0); rapids 0.000000, 10.500000, 10.500000; + // total 21.000000. The look-ahead start must not make the total worse and must face + // the next part. + private const double LegacyTotalRapids = 21.0; + + private static CuttingParameters Parameters() + { + var parameters = ExplicitContourTests.Parameters(); + parameters.ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 45 }; + parameters.ExternalLeadOut = new NoLeadOut(); + return parameters; + } + + private static Part Square(double x, CuttingParameters parameters, string name = "sq") + { + var part = new Part(new Drawing(name, LeadPathValidationTests.Rectangle(0, 0, 10, 10)), + new Vector(x, 0)); + part.CuttingParameters = parameters; + return part; + } + + private static CuttingPlanRequest Request(Part[] parts, CuttingParameters parameters, int budget = 20000) => + new(parts, Vector.Zero, budget, parameters); + + /// Actual air moves: each rapid's distance from the previous motion's end to the following cut end. + private static List Rapids(CuttingPlanResult result) + { + var rapids = new List(); + var position = Vector.Zero; + foreach (var placement in result.ProposedOrder) + { + var motions = placement.Execution.Motions; + for (var i = 0; i < motions.Count; i++) + { + if (!motions[i].Rapid) + continue; + var next = motions.Skip(i + 1).First(m => !m.Rapid); + if (position.DistanceTo(motions[i].End) > 1e-9) + rapids.Add(position.DistanceTo(next.End)); + position = next.End; + } + position = placement.Execution.DeparturePoint; + } + return rapids; + } + + [Fact] + public void ThreePartsAlongX_LeftToRightReadyWithFirstStartFacingTheNextPart() + { + var parameters = Parameters(); + var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) }; + + var result = CuttingPlanService.Plan(Request(parts, parameters)); + + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(3, result.ProposedOrder.Count); + + var first = result.ProposedOrder[0]; + var entry = Assert.Single(first.ContourChoices).Point + first.Location; + // Facing the next part (centre 15.5, 5) means the +X side of the first sheet, + // not the legacy arrival-nearest lower-left corner. + Assert.True(entry.X >= 10.0 - 1e-9, $"first entry {entry} faces away from the next part"); + } + + [Fact] + public void ManyVertices_FacingCornerSurvivesBeyondTheDrawingOrderEntryCap() + { + // Start at the leftmost vertex and travel clockwise. The initial sixteen + // drawing-order catalogue entries only cover the upper-left part of the ring; + // the useful lower-right corner appears later and must survive until ranking. + var source = new Program(); + var vertices = Enumerable.Range(0, 32).Select(i => + { + var angle = System.Math.PI - i * 2 * System.Math.PI / 32; + return new Vector(10 + 10 * System.Math.Cos(angle), 10 + 10 * System.Math.Sin(angle)); + }).ToArray(); + source.MoveTo(vertices[0].X, vertices[0].Y); + foreach (var point in vertices.Skip(1).Append(vertices[0])) + source.LineTo(point.X, point.Y); + var parameters = Parameters(); + var polygon = new Part(new Drawing("many-vertex", source)) { CuttingParameters = parameters }; + var next = Square(25, parameters); + var before = ExplicitContourTests.Fingerprint(polygon.Program); + + var result = CuttingPlanService.Plan(Request([polygon, next], parameters)); + + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal)); + var chosen = result.ProposedOrder[0]; + var entry = Assert.Single(chosen.ContourChoices).Point + chosen.Location; + Assert.True(entry.X > 15 && entry.Y < 5, $"entry {entry} lost the lower-right facing corner to an early cap"); + Assert.Contains(vertices, vertex => vertex.DistanceTo(entry) < 1e-6); + Assert.Equal(before, ExplicitContourTests.Fingerprint(polygon.Program)); + } + + [Fact] + public void EmittedRapidsNoWorseThanTheMeasuredLegacyLayout() + { + var parameters = Parameters(); + var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) }; + + var result = CuttingPlanService.Plan(Request(parts, parameters)); + + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(Rapids(result).Sum() <= LegacyTotalRapids + 1e-6, + $"total rapids {Rapids(result).Sum():F6} exceed legacy {LegacyTotalRapids:F6}"); + } + + [Fact] + public void SecondPartFacesThird_AndLastPartFacesArrivalNotOrigin() + { + var parameters = Parameters(); + var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) }; + + var result = CuttingPlanService.Plan(Request(parts, parameters)); + + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + var ordered = result.ProposedOrder.OrderBy(p => p.SourceOrdinal).ToArray(); + var secondEntry = Assert.Single(ordered[1].ContourChoices).Point + ordered[1].Location; + // Facing the third part (centre 26.0, 5): +X side of the middle sheet. + Assert.True(secondEntry.X >= 10.5 + 10.0 - 1e-9, $"second entry {secondEntry} faces away from the third part"); + // Last part: no target — its entry is chosen near the arrival point, never pulled + // toward the plate origin. + var arrival = ordered[1].Execution.DeparturePoint; + var lastEntry = Assert.Single(ordered[2].ContourChoices).Point + ordered[2].Location; + Assert.True(arrival.DistanceTo(lastEntry) < Vector.Zero.DistanceTo(lastEntry), + $"last entry {lastEntry} is nearer the plate origin than the arrival {arrival}"); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(100, -50)] + public void LastCircleIncludesTheExactClosestArrivalFallback(double x, double y) + { + var source = new Program(); + source.MoveTo(2, 0); + source.ArcTo(2, 0, 0, 0, RotationType.CW); + var location = new Vector(x, y); + var part = new Part(new Drawing("circle", source), location); + var parameters = Parameters(); + parameters.RoundLeadInAngles = false; + parameters.PierceClearance = 0; + parameters.ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }; + var arrival = location + new Vector(20, 8); + var before = ExplicitContourTests.Fingerprint(part.Program); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], arrival, + confirmedParameters: parameters, preservePartOrder: true)); + var captured = snapshot.Placements[0]; + var prepared = captured.Prepared; + var closest = prepared.ClosestEntry(0, arrival - location); + Assert.True(closest.Point.DistanceTo(new Vector(2, 0)) > 0.5); + var adapter = new ContourEntryFeasibility(prepared, location, captured.Material, []); + Assert.True(adapter.Check(closest).IsClear); + var witnessProgram = prepared.Emit([closest]); + var execution = ExecutionMotionReader.Read(witnessProgram, location, arrival, default); + var witness = CuttingPlanService.ReplayPrograms(snapshot, + [captured.Propose(witnessProgram, execution, [closest])], 0, default); + Assert.Equal(CuttingPlanStatus.Ready, witness.Status); + Assert.True(witness.IndependentlyReplayed); + + var result = CuttingPlanService.Plan(snapshot); + + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + var chosen = Assert.Single(Assert.Single(result.ProposedOrder).ContourChoices); + Assert.True(chosen.Point.DistanceTo(closest.Point) < 1e-6, + $"last entry {chosen.Point} omitted the feasible closest-arrival point {closest.Point}"); + Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program)); + } + + [Fact] + public void RotatedAndTranslatedLayout_FacingIsInGlobalSpace() + { + var parameters = Parameters(); + var sheet = Square(0, parameters); + sheet.Rotate(System.Math.PI / 4); // about the origin: the diamond spans x -7.07..7.07 + var parts = new[] { sheet, Square(25, parameters) }; + + var result = CuttingPlanService.Plan(Request(parts, parameters)); + + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + var rotated = result.ProposedOrder.Single(p => p.SourceOrdinal == 0); + var entry = rotated.ContourChoices.Single().Point + rotated.Location; + // The diamond's +X half faces the next part at x=25; the legacy arrival-nearest + // point would be the origin corner (0,0) or below. + Assert.True(entry.X > 3.0, $"rotated first entry {entry} faces away from the next part"); + } + + [Fact] + public void BlockedPreferredStart_CertifiedFallbackStillReady() + { + // A sheet hugging the right side of part 1 (0.05 gap) blocks every +X-facing lead; + // the planner must fall back to a candidate whose emitted leads certify clear. + var parameters = Parameters(); + var blocker = new Part(new Drawing("block", LeadPathValidationTests.Rectangle(0, -2, 9.9, 12)), Vector.Zero); + blocker.Location = new Vector(10.05, 0); + blocker.CuttingParameters = parameters; + var parts = new[] { Square(0, parameters), blocker, Square(20.4, parameters) }; + + var result = CuttingPlanService.Plan(Request(parts, parameters)); + + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + var first = result.ProposedOrder.Single(p => p.SourceOrdinal == 0); + var entry = first.ContourChoices.Single().Point + first.Location; + var material = LeadMaterialSnapshot.Capture( + LeadPathValidationTests.Rectangle(0, 0, 10, 10), Vector.Zero); + var execution = ExecutionMotionReader.Read(first.CopyProgram(), first.Location, null, default); + var leads = LeadPathValidator.Check(execution, material, []); + Assert.True(leads.IsClear, leads.Reason); + } + + [Fact] + public void NoCandidateFits_SurfacesContourFindingNotBudget() + { + // Shrink-wrap material 0.05 around part 1: every external lead enters it, so the + // finite candidate catalogue PROVES no lead-in fits; the search must say that, not + // hide behind the expansion budget. + var parameters = Parameters(); + var wrap = new Part(new Drawing("wrap", LeadPathValidationTests.Rectangle(-0.05, -0.05, 10.05, 10.05)), + Vector.Zero); + wrap.CuttingParameters = parameters; + var square = Square(0, parameters); + var request = new CuttingPlanRequest([square, wrap], Vector.Zero, 20000, parameters); + + var result = CuttingPlanService.Plan(request); + + Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status); + Assert.Contains(result.Findings, + f => (f.Message ?? string.Empty).Contains("No tested lead-in fits", StringComparison.Ordinal)); + Assert.DoesNotContain(result.Findings, + f => (f.Message ?? string.Empty).Contains("Expansion budget", StringComparison.Ordinal)); + } + + [Fact] + public void BudgetOne_ReportsBudgetNotImpossibility() + { + var parameters = Parameters(); + var parts = new[] { Square(0, parameters), Square(10.5, parameters) }; + + var result = CuttingPlanService.Plan(Request(parts, parameters, budget: 1)); + + Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status); + Assert.Contains(result.Findings, + f => (f.Message ?? string.Empty).Contains("budget", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(result.Findings, + f => (f.Message ?? string.Empty).Contains("No tested lead-in fits", StringComparison.Ordinal)); + } + + [Fact] + public void CancellationIsHonouredMidSearch() + { + var parameters = Parameters(); + var parts = Enumerable.Range(0, 6).Select(i => Square(i * 10.5, parameters)).ToArray(); + using var cancel = new CancellationTokenSource(); + cancel.Cancel(); + + var result = CuttingPlanService.Plan(Request(parts, parameters), cancel.Token); + + Assert.Equal(CuttingPlanStatus.Cancelled, result.Status); + } + + [Fact] + public void LockedProgramFingerprintIsUntouchedByLookAhead() + { + var parameters = Parameters(); + var locked = Square(0, parameters); + var prepared = PreparedContours.Capture(locked.Program, parameters); + var emitted = prepared.Emit([prepared.ClosestEntry(0, Vector.Zero)]); + Assert.True(locked.RestoreLeadInProgram(emitted, false)); + locked.LeadInsLocked = true; + var before = ExplicitContourTests.Fingerprint(locked.Program); + + var result = CuttingPlanService.Plan(Request([locked, Square(10.5, parameters)], parameters)); + + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.Equal(before, ExplicitContourTests.Fingerprint(locked.Program)); + var placement = result.ProposedOrder.Single(p => p.SourceOrdinal == 0); + Assert.False(placement.IsRegenerated); // locked programs never gain automatic choices + } + + [Fact] + public void PreservePartOrder_RightToLeftFirstStartFacesLeft() + { + // Right-to-left supplied sequence: the x=20.2 sheet is cut first and must face the + // next part on its -X side. + var parameters = Parameters(); + var parts = new[] { Square(21.0, parameters), Square(10.5, parameters), Square(0, parameters) }; + var request = new CuttingPlanRequest(parts, Vector.Zero, 20000, parameters, preservePartOrder: true); + + var result = CuttingPlanService.Plan(request); + + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + var first = result.ProposedOrder[0]; + Assert.Equal(0, first.SourceOrdinal); + var entry = first.ContourChoices.Single().Point + first.Location; + // Facing the next part (centre 15.5, 5) means the -X side of the x=21 sheet. + Assert.True(entry.X <= 21.0 + 1e-9, $"first entry {entry} faces away from the next part"); + + // Last part (x=0..10): no target — arrival-nearest means its +X side (the tool + // arrives from the middle sheet), NOT the plate-origin corner at (0,0). + var last = result.ProposedOrder[2]; + var lastEntry = last.ContourChoices.Single().Point + last.Location; + Assert.True(lastEntry.X >= 10.0 - 1e-9, + $"last entry {lastEntry} faces the plate origin instead of the arrival"); + } + + [Fact] + public void FullFallbackSearch_TargetsNearestReadyPart() + { + // Middle-first geometry: the leftmost part is blocked until the middle one moves + // (forced by a shrink-wrap on its other side), so the learned-order replay must + // recompute look-ahead after replanning — the middle part must face whichever part + // the new sequence cuts after it, never a stale target. + var parameters = Parameters(); + var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) }; + + var result = CuttingPlanService.Plan(Request(parts, parameters)); + + // Sanity: normal Ready; the facing entry of each non-last part faces its successor + // in the RESULTING order, not merely the request order. + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + var sequence = result.ProposedOrder.Select(p => p.SourceOrdinal).ToArray(); + for (var i = 0; i < sequence.Length - 1; i++) + { + var entry = result.ProposedOrder[i].ContourChoices.Single().Point + result.ProposedOrder[i].Location; + var nextCentre = Centre(result.ProposedOrder[i + 1]); + var here = result.ProposedOrder[i].Location; + // The opposite side of the current sheet from the next part: the facing check + // is "entry is closer to the next centre than the wrong-side point is". + var wrongSide = nextCentre.X >= here.X + 5 + ? new Vector(here.X - 5, here.Y + 5) + : new Vector(here.X + 15, here.Y + 5); + Assert.True(entry.DistanceTo(nextCentre) < wrongSide.DistanceTo(nextCentre), + $"order slot {i} entry {entry} faces away from next centre {nextCentre}"); + } + } + + private static Vector Centre(FixedProgramPlacement placement) + { + var cuts = placement.Execution.Motions + .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null) + .Select(m => m.Curve.ToEntity().BoundingBox).ToList(); + return cuts.Count == 0 ? placement.Execution.DeparturePoint : cuts.GetBoundingBox().Center; + } + + private static string Describe(CuttingPlanResult result) => + $"{result.Status}: {string.Join("; ", result.Findings.Take(5).Select(f => f.Message))}"; +} diff --git a/OpenNest.Tests/CuttingPlanning/PreferredContourEntriesTests.cs b/OpenNest.Tests/CuttingPlanning/PreferredContourEntriesTests.cs new file mode 100644 index 0000000..f8476ae --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/PreferredContourEntriesTests.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// The preferred hole-entry proposal: walk the proposed hole route BACKWARD from the chosen +/// perimeter entry, each hole facing the downstream contour's ACTUAL emitted pierce (native +/// rounding included), corners outranking midpoints, with an explicit no-preference result +/// when a hole cannot flow. Proposal only — nothing installed, nothing certified here. +/// (Contour layout: holes take ordinals 0..N-1, the perimeter is the last ordinal.) +/// +public class PreferredContourEntriesTests +{ + private static readonly Vector Arrival = new(0, 5); + + private static Program CirclesProgram(params (double X, double Y)[] centres) + { + var p = ExplicitContourTests.Square(false); + foreach (var (x, y) in centres) + { + // Unit circle centred at (x, y): two half-arcs, CCW. + p.MoveTo(x + 1, y); + p.ArcTo(new Vector(x - 1, y), new Vector(x, y), RotationType.CCW); + p.ArcTo(new Vector(x + 1, y), new Vector(x, y), RotationType.CCW); + } + return p; + } + + private static Program RectHolesProgram(params (double X1, double Y1, double X2, double Y2)[] boxes) + { + var p = ExplicitContourTests.Square(false); + foreach (var (x1, y1, x2, y2) in boxes) + { + p.MoveTo(x1, y1); + p.LineTo(x2, y1); p.LineTo(x2, y2); p.LineTo(x1, y2); p.LineTo(x1, y1); + } + return p; + } + + private static (PreparedContours Prepared, ContourEntryFeasibility Feasibility) Capture(Program clean, + Vector location) + { + var prepared = PreparedContours.Capture(clean, ExplicitContourTests.Parameters()); + var material = LeadMaterialSnapshot.Capture(clean, location); + var feasibility = new ContourEntryFeasibility(prepared, location, material, []); + return (prepared, feasibility); + } + + private static bool At(Vector point, double x, double y) => + point.DistanceTo(new Vector(x, y)) < 1e-6; + + private static Vector Pierce(PreparedContours prepared, ContourChoice choice) + { + var program = prepared.EmitCandidateForValidation(choice); + var execution = ExecutionMotionReader.Read(program, Vector.Zero, null, default); + var first = execution.Motions.First(m => !m.Rapid); + return first.Start ?? first.End; + } + + /// The owned compass candidate of a circle contour at an exact point. + private static ContourChoice Compass(PreparedContours prepared, int contour, double x, double y, + Vector lookAhead) + { + var match = prepared.AutomaticEntryCandidatesWithFallbacks(contour, lookAhead) + .FirstOrDefault(c => At(c.Choice.Point, x, y)); + Assert.NotNull(match); + return match.Choice; + } + + [Fact] + public void TwoCircularHoles_FlowToTheSelectedOutsideEntry() + { + var clean = CirclesProgram((3, 3), (7, 3)); + var (prepared, feasibility) = Capture(clean, Vector.Zero); + var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5)); // outside right edge + Assert.True(At(perimeterEntry.Point, 10, 5)); + + // Route: arrival (0,5) -> left circle -> right circle -> the outside entry. + var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) }; + var route = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival, + Pierce(prepared, perimeterEntry)); + var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, route, + centres, Arrival, c => feasibility.Check(c.Choice)); + + Assert.True(proposal.IsPreferred, proposal.Reason); + Assert.Equal(new[] { 0, 1 }, proposal.HoleChoices.Select(c => c.ContourOrdinal)); + + // The last hole's ACTUAL pierce flows to the perimeter pierce: strictly closer to + // it than the opposite (west) compass pierce would be. + var perimeterPierce = Pierce(prepared, perimeterEntry); + var last = Pierce(prepared, proposal.HoleChoices[1]); + var opposite = Pierce(prepared, Compass(prepared, 1, 6, 3, new Vector(6, 3))); + Assert.True(last.DistanceTo(perimeterPierce) < opposite.DistanceTo(perimeterPierce), + $"right hole pierce {last} does not flow to {perimeterPierce} (west pierce {opposite})"); + + // The first hole faces the SECOND hole's actual pierce — not the perimeter and not + // the downstream nominal point: closer to it than the west compass pierce would be. + var downstream = Pierce(prepared, proposal.HoleChoices[1]); + var first = Pierce(prepared, proposal.HoleChoices[0]); + var firstWest = Pierce(prepared, Compass(prepared, 0, 2, 3, new Vector(2, 3))); + Assert.True(first.DistanceTo(downstream) < firstWest.DistanceTo(downstream), + $"left hole pierce {first} faces away from downstream {downstream} (west {firstWest})"); + } + + [Fact] + public void RectangularHoles_CornersOutrankMidpoints() + { + var clean = RectHolesProgram((3, 3, 5, 5), (7, 3, 9, 5)); + var (prepared, _) = Capture(clean, Vector.Zero); + var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 4)); + var centres = new[] { new Vector(4, 4), new Vector(8, 4), new Vector(5, 5) }; + + // All-clear evaluation isolates pure ranking: a corner on a facing side wins over + // any midpoint, walking backward from the perimeter entry. + var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 }, + centres, Arrival, _ => new(ContourFeasibilityStatus.Clear, null)); + + Assert.True(proposal.IsPreferred, proposal.Reason); + // Right hole faces the perimeter: a RIGHT-edge corner of the (7,3)-(9,5) hole, + // never the (9,4) midpoint nor a left-edge corner. + var right = proposal.HoleChoices[1].Point; + Assert.True(At(right, 9, 3) || At(right, 9, 5), + $"right hole entry {right} is not a right-facing corner"); + // Left hole faces the right hole's actual pierce (eastward): a right-edge corner of + // the (3,3)-(5,5) hole, never its (5,4) midpoint. + var left = proposal.HoleChoices[0].Point; + Assert.True(At(left, 5, 3) || At(left, 5, 5), + $"left hole entry {left} is not a right-facing corner"); + } + + [Fact] + public void BlockedIdealLead_TakesTheNextFeasibleCandidate() + { + var clean = CirclesProgram((3, 3), (7, 3)); + var (prepared, feasibility) = Capture(clean, Vector.Zero); + var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5)); + var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) }; + + // The ideal east compass point of the right circle is refused: fall back to its + // next-ranked candidate rather than fail or skip the hole. + ContourFeasibilityVerdict Evaluate(ContourEntryCandidate c) => + c.Choice.ContourOrdinal == 1 && At(c.Choice.Point, 8, 3) + ? new(ContourFeasibilityStatus.Blocked, "test block") + : feasibility.Check(c.Choice); + var route = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival, Pierce(prepared, perimeterEntry)); + var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, route, centres, Arrival, Evaluate); + + Assert.True(proposal.IsPreferred, proposal.Reason); + Assert.Equal(1, proposal.HoleChoices[1].ContourOrdinal); // never skipped or swapped + Assert.False(At(proposal.HoleChoices[1].Point, 8, 3), "the blocked candidate was used anyway"); + Assert.True(proposal.HoleChoices[1].Point.X >= 7.0 - 1e-9, + $"fallback entry {proposal.HoleChoices[1].Point} abandoned the facing side entirely"); + } + + [Fact] + public void BlockedHole_GivesAnExplicitNoPreferenceFinding() + { + var clean = CirclesProgram((3, 3), (7, 3)); + var (prepared, _) = Capture(clean, Vector.Zero); + var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5)); + var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) }; + + ContourFeasibilityVerdict Evaluate(ContourEntryCandidate c) => + c.Choice.ContourOrdinal == 0 + ? new(ContourFeasibilityStatus.Blocked, "nothing fits") + : new(ContourFeasibilityStatus.Clear, null); + var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 }, + centres, Arrival, Evaluate); + + Assert.False(proposal.IsPreferred); + Assert.Empty(proposal.HoleChoices); + Assert.Equal(0, proposal.BlockedContour); + Assert.Contains("no preferred lead-feasible entry", proposal.Reason); + } + + [Fact] + public void EveryHoleExactlyOnceInRouteOrder() + { + var clean = CirclesProgram((2.5, 3), (5, 3), (7.5, 3)); + var (prepared, feasibility) = Capture(clean, Vector.Zero); + var perimeterEntry = prepared.Entry(3, 2, new Vector(10, 5)); // perimeter is last + var centres = new[] + { + new Vector(2.5, 3), new Vector(5, 3), new Vector(7.5, 3), new Vector(5, 5), + }; + var route = CuttingHoleOrder.Plan(new[] { 0, 1, 2 }, centres, Arrival, Pierce(prepared, perimeterEntry)); + + var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, route, centres, Arrival, + c => feasibility.Check(c.Choice)); + + Assert.True(proposal.IsPreferred, proposal.Reason); + Assert.Equal(route, proposal.HoleChoices.Select(c => c.ContourOrdinal)); + Assert.Equal(route.Count, proposal.HoleChoices.Select(c => c.ContourOrdinal).Distinct().Count()); + Assert.All(proposal.HoleChoices, c => Assert.NotEqual(3, c.ContourOrdinal)); // never the perimeter + } + + [Fact] + public void ScribesDoNotBecomeTheDownstreamPierceTarget() + { + var clean = RectHolesProgram((3, 3, 5, 5), (7, 3, 9, 5)); + var centres = new[] { new Vector(4, 4), new Vector(8, 4), new Vector(5, 5) }; + var prepared = PreparedContours.Capture(clean, ExplicitContourTests.Parameters()); + var entry = prepared.Entry(2, 2, new Vector(10, 4)); + var baseline = PreferredContourEntries.TryPlan(prepared, entry, new[] { 0, 1 }, + centres, Arrival, _ => new(ContourFeasibilityStatus.Clear, null)); + clean.MoveTo(-20, -20); + clean.Codes.Add(new LinearMove(-19, -19) { Layer = LayerType.Scribe }); + var marked = PreparedContours.Capture(clean, ExplicitContourTests.Parameters()); + var actual = PreferredContourEntries.TryPlan(marked, marked.Entry(2, 2, new Vector(10, 4)), + new[] { 0, 1 }, centres, Arrival, _ => new(ContourFeasibilityStatus.Clear, null)); + Assert.True(actual.IsPreferred, actual.Reason); + Assert.Equal(baseline.HoleChoices.Select(c => c.Point), actual.HoleChoices.Select(c => c.Point)); + } + + [Fact] + public void ChainingIsDeterministic() + { + // Whatever native rounding a nominal circle candidate emits, backward chaining must + // reproduce the same earlier-hole choice on a second run: the chain is a pure + // function of the ACTUAL pierce, not of iteration order or object identity. + var clean = CirclesProgram((3, 3), (7.4, 4.6)); + var (prepared, feasibility) = Capture(clean, Vector.Zero); + var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5)); + var centres = new[] { new Vector(3, 3), new Vector(7.4, 4.6), new Vector(5, 5) }; + + var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 }, + centres, Arrival, c => feasibility.Check(c.Choice)); + var again = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 }, + centres, Arrival, c => feasibility.Check(c.Choice)); + + Assert.True(proposal.IsPreferred, proposal.Reason); + Assert.Equal(proposal.HoleChoices.Select(c => c.Point), again.HoleChoices.Select(c => c.Point)); + } + + [Fact] + public void ForeignPerimeterChoiceIsRefused() + { + var (prepared, feasibility) = Capture(CirclesProgram((3, 3), (7, 3)), Vector.Zero); + var (other, _) = Capture(ExplicitContourTests.Square(false), Vector.Zero); + var foreign = other.Entry(0, 0, new Vector(0, 5)); + + Assert.Throws(() => PreferredContourEntries.TryPlan(prepared, foreign, + new[] { 0 }, new[] { new Vector(3, 3), new Vector(7, 3) }, Arrival, + c => feasibility.Check(c.Choice))); + } + + [Fact] + public void CancellationPropagates() + { + var (prepared, feasibility) = Capture(CirclesProgram((3, 3), (7, 3)), Vector.Zero); + var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5)); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + + Assert.ThrowsAny(() => PreferredContourEntries.TryPlan( + prepared, perimeterEntry, new[] { 0, 1 }, + new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) }, Arrival, + c => feasibility.Check(c.Choice), cancelled.Token)); + } + + [Fact] + public void SourceProgramAndChoicesAreUntouched() + { + var clean = CirclesProgram((3, 3), (7, 3)); + var before = ExplicitContourTests.Fingerprint(clean); + var (prepared, feasibility) = Capture(clean, Vector.Zero); + var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5)); + var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) }; + + var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 }, + centres, Arrival, c => feasibility.Check(c.Choice)); + + Assert.True(proposal.IsPreferred, proposal.Reason); + Assert.Equal(before, ExplicitContourTests.Fingerprint(clean)); // caller program untouched + Assert.All(proposal.HoleChoices, c => Assert.True(ReferenceEquals(c.Owner, prepared))); + Assert.NotSame(prepared.EmitCandidateForValidation(proposal.HoleChoices[0]), + prepared.EmitCandidateForValidation(proposal.HoleChoices[0])); // fresh programs + } +} diff --git a/OpenNest.Tests/CuttingPlanning/SideCoverageRegressionTests.cs b/OpenNest.Tests/CuttingPlanning/SideCoverageRegressionTests.cs new file mode 100644 index 0000000..9224cbd --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/SideCoverageRegressionTests.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class SideCoverageRegressionTests +{ + [Fact] + public void DefaultCap_ReusesClearBottomCandidatePassedWhileSeekingLeft() + { + var clean = new Program(); + clean.MoveTo(0, 0); + clean.LineTo(0, 10); + for (var i = 1; i <= 12; i++) + clean.LineTo(i * 10.0 / 12, 10); + for (var i = 1; i <= 12; i++) + clean.LineTo(10, 10 - i * 10.0 / 12); + clean.LineTo(0, 0); + var parameters = Parameters(0.15, 90, 0); + var prepared = PreparedContours.Capture(clean, parameters); + var blockers = new[] + { + Rectangle(-0.5, -0.5, 1.5, 0.4), Rectangle(9, -0.5, 1.05, 0.4), + Rectangle(10.1, -0.3, 0.5, 0.6), Rectangle(-0.5, 9.8, 0.4, 0.4), + }; + var target = new Vector(20, 20); + var ranked = prepared.AutomaticEntryCandidatesWithFallbacks(0, target) + .RankTowardNextCut(target, new Vector(10, -2)); + var adapter = Adapter(prepared, clean, blockers); + var calls = new HashSet<(long, long)>(); + + var selected = ContourEntrySelection.Select(ranked, candidate => + { + Assert.True(calls.Add(candidate.GeometryKey), "A candidate was evaluated twice."); + return adapter.Check(candidate.Choice); + }); + + Assert.Equal(16, selected.Choices.Count); + var bottom = ranked.Single(c => c.Choice.Point.DistanceTo(new Vector(5, 0)) < 1e-9); + Assert.Equal(32, ranked.ToList().IndexOf(bottom)); + Assert.True(adapter.Check(bottom.Choice).IsClear); + Assert.Equal(new[] { 0, 1, 2, 3 }, ClearSides(ranked, adapter)); + Assert.Equal(new[] { 0, 1, 2, 3 }, SelectedSides(selected, ranked)); + Assert.Contains(selected.Choices, c => c.Point.DistanceTo(bottom.Choice.Point) < 1e-9); + Assert.All(selected.Choices, c => Assert.True(adapter.Check(c).IsClear)); + Assert.Equal(ContourSelectionShortfall.None, selected.Shortfall); + AssertGlobalOrder(selected, ranked); + Assert.Equal(calls.Count, selected.EvaluatedCount); + } + + [Fact] + public void SupportedCapFour_ReusesClearTopCandidatePassedWhileSeekingBottom() + { + var clean = Polygon(); + var prepared = PreparedContours.Capture(clean, Parameters()); + var target = new Vector(100 * System.Math.Cos(System.Math.PI), 100 * System.Math.Sin(System.Math.PI)); + var arrival = new Vector(40 * System.Math.Cos(3 * System.Math.PI / 2), 40 * System.Math.Sin(3 * System.Math.PI / 2)); + var ranked = prepared.AutomaticEntryCandidatesWithFallbacks(0, target).RankTowardNextCut(target, arrival); + var adapter = Adapter(prepared, clean, []); + + var selected = ContourEntrySelection.Select(ranked, c => adapter.Check(c.Choice), 4); + + Assert.Equal(4, selected.Choices.Count); + Assert.True(adapter.Check(ranked[4].Choice).IsClear); + Assert.Contains(3, Sides(ranked[4].Choice.Point, ranked)); + Assert.Equal(new[] { 0, 1, 2, 3 }, ClearSides(ranked, adapter)); + Assert.Equal(new[] { 0, 1, 2, 3 }, SelectedSides(selected, ranked)); + Assert.All(selected.Choices, c => Assert.True(adapter.Check(c).IsClear)); + AssertGlobalOrder(selected, ranked); + } + + [Theory] + [InlineData(4)] + [InlineData(16)] + public void FullCheckedRoute_RetainsTopDepartureToLockedMarkedSquare(int cap) + { + var parameters = Parameters(); + var first = new Part(new Drawing("convex polygon", Polygon())); + var nextClean = Rectangle(0, 0, 2, 2); + nextClean.MoveTo(101, 21); + nextClean.Codes.Add(new LinearMove(102, 21) { Layer = LayerType.Scribe }); + var next = new Part(new Drawing("locked marked square", nextClean), new Vector(-101, -1)); + var nextPrepared = PreparedContours.Capture(nextClean, parameters); + Assert.True(next.RestoreLeadInProgram(nextPrepared.Emit([nextPrepared.ClosestEntry(0, new Vector(3, 1))]), true)); + var before = new[] { ExplicitContourTests.Fingerprint(first.Program), ExplicitContourTests.Fingerprint(next.Program) }; + var start = new Vector(-7.347880794884118e-15, -40); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, next], start, + confirmedParameters: parameters, preservePartOrder: true, maxEntries: cap)); + var source = snapshot.Placements[0]; + var prepared = source.Prepared; + var witness = prepared.AutomaticEntryCandidatesWithFallbacks(0, new Vector(-100, 0)) + .First(c => c.Choice.Point.DistanceTo(new Vector(-4.99220639970189, 8.664749001718139)) < 1e-9); + var witnessProgram = prepared.Emit([witness.Choice]); + var witnessExecution = ExecutionMotionReader.Read(witnessProgram, source.Location, start, default); + var witnessProposal = source.Propose(witnessProgram, witnessExecution, [witness.Choice], default); + var witnessReplay = CuttingPlanService.ReplayPrograms(snapshot, [witnessProposal, snapshot.Placements[1]], 0, default); + Assert.Equal(CuttingPlanStatus.Ready, witnessReplay.Status); + Assert.True(witnessReplay.IndependentlyReplayed); + + var result = CuttingPlanService.Plan(snapshot); + + Assert.True(result.Status == CuttingPlanStatus.Ready, + $"{result.Status}, {result.Expansions} expansions: {string.Join("; ", result.Findings.Select(f => f.Message))}"); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal)); + Assert.Single(result.ProposedOrder[0].ContourChoices); + Assert.False(result.ProposedOrder[1].IsRegenerated); + var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default); + Assert.Equal(CuttingPlanStatus.Ready, replay.Status); + Assert.True(replay.IndependentlyReplayed); + Assert.Equal(before, new[] { ExplicitContourTests.Fingerprint(first.Program), ExplicitContourTests.Fingerprint(next.Program) }); + } + + [Fact] + public void CorrectiveRescan_PreservesGlobalRankAndLeavesTailUnevaluated() + { + var candidates = CorrectiveCandidates(); + var calls = new HashSet<(long, long)>(); + var result = ContourEntrySelection.Select(candidates, c => + { + Assert.True(calls.Add(c.GeometryKey)); + return new(ContourFeasibilityStatus.Clear, null); + }, 4); + + Assert.Equal(new[] { candidates[0].Choice, candidates[1].Choice, candidates[4].Choice, candidates[5].Choice }, result.Choices); + Assert.Equal(6, result.EvaluatedCount); + Assert.DoesNotContain(candidates[6].GeometryKey, result.EvaluatedKeys); + Assert.Equal(ContourSelectionShortfall.None, result.Shortfall); + } + + [Theory] + [InlineData(ContourFeasibilityStatus.Blocked)] + [InlineData(ContourFeasibilityStatus.Incomplete)] + public void CorrectiveRescan_DoesNotPromoteUnclearSide(ContourFeasibilityStatus status) + { + var candidates = CorrectiveCandidates(); + var calls = new HashSet<(long, long)>(); + var result = ContourEntrySelection.Select(candidates, c => + { + Assert.True(calls.Add(c.GeometryKey)); + return c.GeometryKey == candidates[4].GeometryKey + ? new(status, "unclear bottom") : new(ContourFeasibilityStatus.Clear, null); + }, 4); + + Assert.DoesNotContain(candidates[4].Choice, result.Choices); + Assert.Equal(4, result.Choices.Count); + Assert.Equal(status == ContourFeasibilityStatus.Incomplete ? ContourSelectionShortfall.Incomplete : ContourSelectionShortfall.None, result.Shortfall); + if (status == ContourFeasibilityStatus.Incomplete) + { + Assert.Contains(candidates[4].Choice, result.UncertainChoices); + Assert.DoesNotContain("No tested lead-in fits", result.Reason); + } + Assert.Equal(calls.Count, result.EvaluatedCount); + } + + [Fact] + public void CorrectiveRescan_HonoursCancellationBeforeReusingCachedVerdict() + { + var candidates = CorrectiveCandidates(); + using var cancellation = new CancellationTokenSource(); + Assert.ThrowsAny(() => ContourEntrySelection.Select(candidates, c => + { + if (c.GeometryKey == candidates[5].GeometryKey) + cancellation.Cancel(); + return new(ContourFeasibilityStatus.Clear, null); + }, 4, cancellation.Token)); + } + + private static ContourEntryCandidate[] CorrectiveCandidates() + { + var prepared = PreparedContours.Capture(Rectangle(0, 0, 10, 10), Parameters()); + return new[] { (10, 5), (5, 10), (9, 5), (5, 9), (5, 0), (0, 5), (6, 1) } + .Select(p => new ContourEntryCandidate(new ContourChoice(0, 0, new Vector(p.Item1, p.Item2)) { Owner = prepared }, AutomaticEntryKind.ConvexCorner)).ToArray(); + } + + private static ContourEntryFeasibility Adapter(PreparedContours prepared, Program clean, Program[] blockers) + { + var own = LeadMaterialSnapshot.Capture(clean, Vector.Zero); + var others = blockers.Select(p => LeadMaterialSnapshot.Capture(p, Vector.Zero)).ToArray(); + Assert.True(own.IsComplete, own.Reason); + Assert.All(others, m => Assert.True(m.IsComplete, m.Reason)); + return new(prepared, Vector.Zero, own, others); + } + + private static int[] ClearSides(IReadOnlyList ranked, ContourEntryFeasibility adapter) => + ranked.Where(c => adapter.Check(c.Choice).IsClear).SelectMany(c => Sides(c.Choice.Point, ranked)).Distinct().Order().ToArray(); + + private static int[] SelectedSides(ContourSelectionResult selected, IReadOnlyList ranked) => + selected.Choices.SelectMany(c => Sides(c.Point, ranked)).Distinct().Order().ToArray(); + + private static IEnumerable Sides(Vector point, IReadOnlyList ranked) + { + var gaps = new[] + { + point.X - ranked.Min(c => c.Choice.Point.X), ranked.Max(c => c.Choice.Point.X) - point.X, + point.Y - ranked.Min(c => c.Choice.Point.Y), ranked.Max(c => c.Choice.Point.Y) - point.Y, + }; + return Enumerable.Range(0, 4).Where(side => gaps[side] <= gaps.Min() + 1e-6); + } + + private static void AssertGlobalOrder(ContourSelectionResult selected, IReadOnlyList ranked) + { + var ranks = selected.Choices.Select(c => ranked.ToList().FindIndex(candidate => ReferenceEquals(candidate.Choice, c))).ToArray(); + Assert.Equal(ranks.Order(), ranks); + } + + private static Program Polygon() + { + var points = Enumerable.Range(0, 16).Select(i => new Vector( + 10 * System.Math.Cos(0.13 - i * 2 * System.Math.PI / 16), + 10 * System.Math.Sin(0.13 - i * 2 * System.Math.PI / 16))).ToArray(); + var program = new Program(); + program.MoveTo(points[0].X, points[0].Y); + foreach (var point in points.Skip(1).Append(points[0])) + program.LineTo(point.X, point.Y); + return program; + } + + private static Program Rectangle(double x, double y, double width, double height) + { + var program = new Program(); + program.MoveTo(x, y); + program.LineTo(x, y + height); + program.LineTo(x + width, y + height); + program.LineTo(x + width, y); + program.LineTo(x, y); + return program; + } + + private static CuttingParameters Parameters(double length = 0.3, double angle = 45, double clearance = 0.05) => new() + { + ExternalLeadIn = new LineLeadIn { Length = length, ApproachAngle = angle }, + InternalLeadIn = new LineLeadIn { Length = length, ApproachAngle = angle }, + ArcCircleLeadIn = new LineLeadIn { Length = length }, + ExternalLeadOut = new NoLeadOut(), + InternalLeadOut = new NoLeadOut(), + TabsEnabled = false, + RoundLeadInAngles = false, + PierceClearance = clearance, + }; +} diff --git a/OpenNest.Tests/CuttingStrategy/AutomaticCornerClassificationTests.cs b/OpenNest.Tests/CuttingStrategy/AutomaticCornerClassificationTests.cs new file mode 100644 index 0000000..3976f84 --- /dev/null +++ b/OpenNest.Tests/CuttingStrategy/AutomaticCornerClassificationTests.cs @@ -0,0 +1,282 @@ +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingStrategy; + +/// +/// The shared classification query for automatic start-point planning reuses the emitter's +/// own corner geometry and winding derivation: convex, reflex, smooth and cusp vertices +/// classify identically from either adjacent entity, in both windings, and rotations of the +/// shape do not change the kind. Midpoints are not corners. +/// +public class AutomaticCornerClassificationTests +{ + private const double ToleranceDegrees = 1e-6; + + // --- fixtures ------------------------------------------------------------- + + private static Shape ClosedShape(params Entity[] entities) + { + var shape = new Shape(); + shape.Entities.AddRange(entities); + Assert.True(shape.IsClosed()); + return shape; + } + + private static Shape Square(Vector[] corners) + { + var e = new Entity[corners.Length]; + for (var i = 0; i < corners.Length; i++) + e[i] = new Line(corners[i], corners[(i + 1) % corners.Length]); + return ClosedShape(e); + } + + /// CCW square; the shape's own winding derivation says so. + private static Shape CcwSquare(double size = 10) => + Square(new[] + { + new Vector(0, 0), new Vector(size, 0), new Vector(size, size), new Vector(0, size), + }); + + /// Same square traversed CW. + private static Shape CwSquare(double size = 10) + { + var s = CcwSquare(size); + s.Reverse(); + return s; + } + + /// CCW L-shape: the concave vertex (5,5) turns right — reflex. + private static Shape Notched() + { + return Square(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), + }); + } + + /// CCW square whose top side is a half-circle bump; the tangent joints are at (0,10) and (10,10). + private static Shape ArcBumpSquare() + { + return ClosedShape( + new Line(new Vector(0, 0), new Vector(10, 0)), + new Line(new Vector(10, 0), new Vector(10, 10)), + new Arc(new Vector(5, 10), 5, 0, System.Math.PI), + new Line(new Vector(0, 10), new Vector(0, 0))); + } + + // --- positive cases -------------------------------------------------------- + + public static IEnumerable CcwSquareCorners() + { + var corners = new[] + { + new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10), + }; + for (var i = 0; i < corners.Length; i++) + { + yield return new object[] { corners[i], i }; + yield return new object[] { corners[i], (i + corners.Length - 1) % corners.Length }; + } + } + + [Theory] + [MemberData(nameof(CcwSquareCorners))] + public void SquareCorners_AreConvexFromEitherEdge(Vector corner, int entityIndex) + { + var shape = CcwSquare(); + + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner( + shape, corner, shape.Entities[entityIndex], out var found)); + Assert.Equal(ContourCuttingStrategy.CornerKind.Convex, found.Kind); + } + + [Theory] + [MemberData(nameof(CcwSquareCorners))] + public void TraversedBackwards_SquareCornersStayConvex(Vector corner, int entityIndex) + { + var shape = CwSquare(); + // Same geometric corners; entity order is reversed, so look the vertex up by position + // and alternate between the two entities adjacent to it. + var (entity, point) = AdjacentToVertex(shape, corner, entityIndex); + + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner( + shape, point, entity, out var found)); + Assert.Equal(ContourCuttingStrategy.CornerKind.Convex, found.Kind); + } + + [Fact] + public void NotchVertex_IsReflexFromEitherEdge() + { + var shape = Notched(); + var notch = new Vector(5, 5); + var incoming = shape.Entities.Single(e => EndOf(e) == notch); + var outgoing = shape.Entities.Single(e => StartOf(e) == notch); + + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, notch, incoming, out var a)); + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, notch, outgoing, out var b)); + Assert.Equal(ContourCuttingStrategy.CornerKind.Reflex, a.Kind); + Assert.Equal(a.Kind, b.Kind); + Assert.Equal(ContourCuttingStrategy.CornerKind.Reflex, + ClassifyAtVertex(CwShaped(Notched()), notch)); + } + + [Theory] + [InlineData(0, 10)] + [InlineData(10, 10)] + public void TangentLineArcJoints_AreSmoothNotCorners(double x, double y) + { + var shape = ArcBumpSquare(); + var point = new Vector(x, y); + var incoming = shape.Entities.Single(e => EndOf(e) == point); + var outgoing = shape.Entities.Single(e => StartOf(e) == point); + + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, point, incoming, out var a)); + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, point, outgoing, out var b)); + Assert.Equal(ContourCuttingStrategy.CornerKind.Smooth, a.Kind); + Assert.Equal(b.Kind, a.Kind); + } + + [Fact] + public void ReversalVertex_IsCusp() + { + // The emitter's own rule: equal-and-opposite travel tangents (turn ≈ 0, dot < 0). + var shape = Square(new[] + { + new Vector(0, 0), new Vector(5, 0), new Vector(0, 0), new Vector(0, 10), new Vector(10, 10), + new Vector(10, 0), + }); + var cusp = new Vector(0, 0); + var entity = shape.Entities.First(e => StartOf(e) == cusp); + + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, cusp, entity, out var found)); + Assert.Equal(ContourCuttingStrategy.CornerKind.Cusp, found.Kind); + } + + // --- invariants ------------------------------------------------------------- + + [Theory] + [InlineData(0.37)] + [InlineData(1.9)] + [InlineData(4.71)] + public void RotatingTheShape_DoesNotChangeTheKind(double angle) + { + var shape = Notched(); + var notch = new Vector(5, 5); + var entity = shape.Entities.Single(e => StartOf(e) == notch); + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, notch, entity, out var before)); + + shape.Rotate(angle); + var (rotatedEntity, rotatedPoint) = NearestVertex(shape, notch.Rotate(angle)); + + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner( + shape, rotatedPoint, rotatedEntity, out var after)); + Assert.Equal(before.Kind, after.Kind); + } + + [Fact] + public void Midpoint_IsNotACorner() + { + var shape = CcwSquare(); + var edge = Assert.IsType(shape.Entities[1]); + + Assert.False(ContourCuttingStrategy.TryClassifyAutomaticStartCorner( + shape, edge.MidPoint, edge, out _)); + } + + [Fact] + public void OpenContour_HasNoClassifiableCorners() + { + var shape = CcwSquare(); + shape.Entities.RemoveAt(2); + Assert.False(shape.IsClosed()); + + Assert.False(ContourCuttingStrategy.TryClassifyAutomaticStartCorner( + shape, new Vector(10, 10), shape.Entities[1], out _)); + } + + [Fact] + public void KindMatchesTheExistingEmitterClassification() + { + // Cross-check against the lead-in path actually used by emission: an outside + // square corner is where ResolveLeadIn extends the outgoing edge (convex), and + // the notch is where the internal-style lead bisects (reflex). The wrapper must + // agree with what EmitContour/ResolveLeadIn already do, not invent a third rule. + var square = CcwSquare(); + var corner = new Vector(10, 0); + var outgoing = square.Entities.Single(e => StartOf(e) == corner); + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner( + square, corner, outgoing, out var kind)); + Assert.Equal(ContourCuttingStrategy.CornerKind.Convex, kind.Kind); + + // Tangents are travel directions of the two joined edges. + Assert.Equal(1.0, kind.TangentIn.X, 9); + Assert.Equal(0.0, kind.TangentIn.Y, 9); + Assert.Equal(0.0, kind.TangentOut.X, 9); + Assert.Equal(1.0, kind.TangentOut.Y, 9); + } + + // --- helpers ---------------------------------------------------------------- + + private static Shape CwShaped(Shape shape) + { + shape.Reverse(); + return shape; + } + + private static ContourCuttingStrategy.CornerKind? ClassifyAtVertex(Shape shape, Vector vertex) + { + var (entity, point) = NearestVertex(shape, vertex); + return ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, point, entity, out var found) + ? found.Kind + : null; + } + + private static (Entity Entity, Vector Point) AdjacentToVertex(Shape shape, Vector vertex, int entityIndex) + { + // The two entities of the reversed contour that touch this geometric vertex; + // entityIndex alternates between them so both edge selections are covered. + var adjacent = shape.Entities + .Where(e => StartOf(e).DistanceTo(vertex) <= 1e-9 || EndOf(e).DistanceTo(vertex) <= 1e-9) + .ToList(); + Assert.Equal(2, adjacent.Count); + return (adjacent[entityIndex % 2], vertex); + } + + private static (Entity Entity, Vector Point) NearestVertex(Shape shape, Vector approximate) + { + Entity? best = null; + var bestPoint = Vector.Zero; + var bestDistance = double.MaxValue; + foreach (var entity in shape.Entities) + { + foreach (var point in new[] { StartOf(entity), EndOf(entity) }) + { + var d = point.DistanceTo(approximate); + if (d < bestDistance) + { + bestDistance = d; + best = entity; + bestPoint = point; + } + } + } + Assert.NotNull(best); + return (best!, bestPoint); + } + + private static Vector StartOf(Entity entity) => entity switch + { + Line line => line.StartPoint, + Arc arc => arc.StartPoint(), + _ => throw new NotSupportedException(), + }; + + private static Vector EndOf(Entity entity) => entity switch + { + Line line => line.EndPoint, + Arc arc => arc.EndPoint(), + _ => throw new NotSupportedException(), + }; +} diff --git a/OpenNest.Tests/PlateDisplayNumberingTests.cs b/OpenNest.Tests/PlateDisplayNumberingTests.cs new file mode 100644 index 0000000..105735a --- /dev/null +++ b/OpenNest.Tests/PlateDisplayNumberingTests.cs @@ -0,0 +1,138 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class PlateDisplayNumberingTests +{ + private static Nest CreateNest() => new("test"); + + private static Part MakePart() + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + return new Part(new Drawing("test", pgm)); + } + + [Fact] + public void PopulatedThenSentinel_TotalIsOneAndRealPlateDisplaysAsOne() + { + var nest = CreateNest(); + var real = nest.CreatePlate(); + real.Parts.Add(MakePart()); + nest.CreatePlate(); // The trailing empty sentinel. + + Assert.Equal(1, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates)); + Assert.Equal(1, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 0)); + Assert.Equal("Plate 1 of 1", PlateDisplayNumbering.FormatHeader(nest.Plates, 0, null)); + } + + [Fact] + public void Sentinel_IsLabeledAsNewPlateNotNumbered() + { + var nest = CreateNest(); + var real = nest.CreatePlate(); + real.Parts.Add(MakePart()); + nest.CreatePlate(); + + var sentinel = nest.Plates.Count - 1; + Assert.True(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, sentinel)); + Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, sentinel)); + Assert.Equal("New plate (empty)", PlateDisplayNumbering.FormatHeader(nest.Plates, sentinel, null)); + } + + [Fact] + public void TwoRealPlatesThenSentinel_TotalIsTwo() + { + var nest = CreateNest(); + nest.Plates.Add(WithPart()); + nest.Plates.Add(WithPart()); + nest.CreatePlate(); // sentinel + + Assert.Equal(2, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates)); + Assert.Equal(1, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 0)); + Assert.Equal(2, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 1)); + Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 2)); + } + + [Fact] + public void NoPlates_TotalIsZeroAndHeaderSaysNoPlates() + { + var nest = CreateNest(); + + Assert.Equal(0, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates)); + Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 0)); + Assert.Equal("No plates", PlateDisplayNumbering.FormatHeader(nest.Plates, 0, null)); + } + + [Fact] + public void SingleEmptyPlate_IsTheNewPlateView_NotNumbered() + { + var nest = CreateNest(); + nest.CreatePlate(); // only the sentinel + + Assert.Equal(0, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates)); + Assert.True(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, 0)); + Assert.Equal("New plate (empty)", PlateDisplayNumbering.FormatHeader(nest.Plates, 0, null)); + } + + [Fact] + public void InteriorEmptyPlate_KeepsItsSlotAndNumber() + { + var nest = CreateNest(); + nest.Plates.Add(WithPart()); + nest.CreatePlate(); // An empty interior plate: EnsureSentinel only trims at the tail. + nest.Plates.Add(WithPart()); + nest.CreatePlate(); // sentinel + + Assert.Equal(3, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates)); + Assert.Equal(2, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 1)); + Assert.Equal(3, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 2)); + Assert.False(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, 1)); + } + + [Fact] + public void LastPlateWithParts_IsNotASentinel() + { + var nest = CreateNest(); + nest.Plates.Add(WithPart()); + + Assert.Equal(1, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates)); + Assert.False(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, 0)); + } + + [Fact] + public void IndexOutsideCollection_HasNoNumberAndIsNotASentinel() + { + var nest = CreateNest(); + nest.Plates.Add(WithPart()); + + Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 5)); + Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, -1)); + Assert.False(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, 5)); + } + + [Fact] + public void Helper_DoesNotMutateTheNest() + { + var nest = CreateNest(); + nest.Plates.Add(WithPart()); + nest.CreatePlate(); + var before = nest.Plates.Count; + + PlateDisplayNumbering.DisplayedPlateCount(nest.Plates); + PlateDisplayNumbering.FormatHeader(nest.Plates, 1, "100 x 100"); + + Assert.Equal(before, nest.Plates.Count); + } + + private static Plate WithPart() + { + var plate = new Plate(); + plate.Parts.Add(MakePart()); + return plate; + } +} diff --git a/OpenNest.WinForms.Tests/Fixtures/absolute-coordinate-parts.nest b/OpenNest.WinForms.Tests/Fixtures/absolute-coordinate-parts.nest new file mode 100644 index 0000000..8de708c Binary files /dev/null and b/OpenNest.WinForms.Tests/Fixtures/absolute-coordinate-parts.nest differ diff --git a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs index c405d57..deb5337 100644 --- a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs @@ -202,6 +202,31 @@ public class CuttingPlanFormTests Assert.All(first.Parts, part => Assert.False(part.HasManualLeadIns)); }); + [Fact] + public void OnePlateDialog_ExcludesTheSentinelFromTheShownTotal() => RunSta(() => + { + var (nest, view) = CreateView(Square("a", 1, 1)); + nest.CreatePlate(); // The editor's trailing empty new-plate sentinel. + using var editor = view; + using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters()); + + Assert.StartsWith("Plate 1 of 1, numbered in cutting order:", Control