mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 23:34:14 -04:00
merge: integrate reviewed cutting-start planner fixes (#10)
Preserve reviewed cutting-start and plate-label commits; operator accepted A1-A4 and B1-B7. Excludes best-effort cutting and color trials.
This commit is contained in:
37 files changed
+5353
-78
No files matched your search
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Automatic start preference, ordered: a lower value is more preferred.</summary>
|
||||
internal enum AutomaticEntryKind
|
||||
{
|
||||
/// <summary>A convex turn of the contour's own travel, classified exactly as emission classifies it.</summary>
|
||||
ConvexCorner = 0,
|
||||
|
||||
/// <summary>The midpoint of a straight edge.</summary>
|
||||
StraightMidpoint = 1,
|
||||
|
||||
/// <summary>A tangent line/arc joint. A collinear line/line split is not a joint and never appears.</summary>
|
||||
TangentJoint = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
NearCorner = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
TargetFacing = 4,
|
||||
|
||||
/// <summary>Tier 3 (fallback): the native midpoint of an arc entity.</summary>
|
||||
ArcMidpoint = 5,
|
||||
|
||||
/// <summary>Tier 3 (fallback): one of the eight compass points of a whole circle.</summary>
|
||||
CircleCompass = 6,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
|
||||
/// equal points rank together regardless of the entity that produced them.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class ContourEntryRanking
|
||||
{
|
||||
/// <summary>
|
||||
/// Orders <paramref name="candidates"/> for one contour in local coordinates. With a
|
||||
/// <paramref name="target"/> (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 <paramref name="arrival"/> — never toward the plate origin. The
|
||||
/// input list is returned untouched as a new list; entity order is never meaningful.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<ContourEntryCandidate> RankTowardNextCut(
|
||||
this IReadOnlyList<ContourEntryCandidate> 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<ContourEntryCandidate>();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ranking tier — coarser than the preference kind: outside corners first, then
|
||||
/// straight midpoints and tangent joints as peers, then every fallback kind.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -156,6 +156,268 @@ public sealed class PreparedContours
|
||||
return choice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 — <see cref="AutomaticEntryCandidatesWithFallbacks"/> supplies
|
||||
/// those. Manual entry through <see cref="Entry"/> / <see cref="ClosestEntry"/> is
|
||||
/// unaffected.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ContourEntryCandidate> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The complete uncapped automatic start catalogue: the preferred points of
|
||||
/// <see cref="AutomaticEntryCandidates"/> 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
|
||||
/// <paramref name="lookAhead"/> (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.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
||||
int contourOrdinal,
|
||||
Vector? lookAhead = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
var all = IsSingleCircle(shape)
|
||||
? new List<ContourEntryCandidate>()
|
||||
: 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;
|
||||
|
||||
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
|
||||
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
|
||||
=> CataloguePoints(shape, token)
|
||||
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <paramref name="lookAhead"/>. No ranking and no lead-safety verdict here.
|
||||
/// </summary>
|
||||
private List<ContourEntryCandidate> FallbackCandidates(
|
||||
Shape shape,
|
||||
int contourOrdinal,
|
||||
Vector? lookAhead,
|
||||
CancellationToken token)
|
||||
{
|
||||
var fallbacks = new List<ContourEntryCandidate>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ContourEntryCandidate> MergeByGeometry(List<ContourEntryCandidate> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="point"/> sits on a vertex of the contour that is reflex or
|
||||
/// cusp from either travel direction — a point automatic selection must never emit.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private double ApplicableLeadInLength(int contourOrdinal)
|
||||
=> (contourOrdinal == PerimeterOrdinal
|
||||
? parameters.ExternalLeadIn
|
||||
: parameters.InternalLeadIn) switch
|
||||
{
|
||||
LineLeadIn line => line.Length,
|
||||
LineLineLeadIn lineLine => lineLine.Length1,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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];
|
||||
}
|
||||
|
||||
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
||||
public Program Emit(IReadOnlyList<ContourChoice> choices)
|
||||
{
|
||||
@@ -163,6 +425,25 @@ public sealed class PreparedContours
|
||||
return EmitPrefix(choices);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Emit"/> and
|
||||
/// <see cref="EmitPrefix"/> keep the perimeter-last rule untouched.
|
||||
/// </summary>
|
||||
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<ContourChoice> 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];
|
||||
|
||||
/// <summary>
|
||||
/// Representative POINTS per contour ordinal (bounding-box centre) for hole routing
|
||||
/// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the
|
||||
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<Vector?> 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();
|
||||
|
||||
|
||||
@@ -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<Entity> 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<CuttingPlanning.ContourChoice> 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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="EmitContour"/> derives it, so the kind matches actual emission.
|
||||
/// </summary>
|
||||
internal readonly record struct AutomaticCorner(CornerKind Kind, Vector TangentIn, Vector TangentOut);
|
||||
|
||||
/// <summary>
|
||||
/// Classification query shared with entry planning: the turn at <paramref name="point"/>
|
||||
/// on a closed line/arc contour, using the same corner geometry and winding derivation
|
||||
/// as emission. False when <paramref name="point"/> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>A contour vertex: the entity cut into it and the one cut away from it.</summary>
|
||||
private readonly record struct ContourCorner(
|
||||
Entity Incoming,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class PlateDisplayNumbering
|
||||
{
|
||||
/// <summary>
|
||||
/// The number shown for the plate at <paramref name="storageIndex"/>: real collection
|
||||
/// positions count as 1-based plate numbers. Returns null for the trailing "new plate"
|
||||
/// sentinel and for indexes outside the collection.
|
||||
/// </summary>
|
||||
public static int? DisplayedPlateNumber(IList<Plate> plates, int storageIndex)
|
||||
{
|
||||
if (plates == null || storageIndex < 0 || storageIndex >= plates.Count)
|
||||
return null;
|
||||
if (IsTrailingSentinel(plates, storageIndex))
|
||||
return null;
|
||||
return storageIndex + 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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").
|
||||
/// </summary>
|
||||
public static int DisplayedPlateCount(IList<Plate> 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);
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="storageIndex"/> is the trailing empty new-plate sentinel.</summary>
|
||||
public static bool IsTrailingSentinel(IList<Plate> 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;
|
||||
}
|
||||
|
||||
/// <summary>Header text for the plate at <paramref name="storageIndex"/>.</summary>
|
||||
public static string FormatHeader(IList<Plate> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>The three verdicts a candidate entry can receive from the shared lead validator.</summary>
|
||||
public enum ContourFeasibilityStatus
|
||||
{
|
||||
/// <summary>Every emitted lead of this contour is complete AND clear.</summary>
|
||||
Clear,
|
||||
|
||||
/// <summary>A complete check found a lead contact/overlap — the reason is preserved.</summary>
|
||||
Blocked,
|
||||
|
||||
/// <summary>The check could not complete (incomplete material, malformed emission) — never clear.</summary>
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
/// <summary>Verdict for one candidate entry. Complete/blocked/incomplete stay distinct.</summary>
|
||||
public sealed record ContourFeasibilityVerdict(ContourFeasibilityStatus Status, string? Reason)
|
||||
{
|
||||
public bool IsClear => Status == ContourFeasibilityStatus.Clear;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feasibility adapter over the EXISTING <see cref="LeadPathValidator"/>: 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 <see cref="Check"/> is ever emitted or validated, and
|
||||
/// <see cref="EvaluationCount"/> counts validator executions (not cache hits) for cost tests.
|
||||
/// </summary>
|
||||
public sealed class ContourEntryFeasibility
|
||||
{
|
||||
private readonly PreparedContours prepared;
|
||||
private readonly Vector location;
|
||||
private readonly LeadMaterialSnapshot ownMaterial;
|
||||
private readonly LeadMaterialSnapshot[] materials;
|
||||
private readonly Dictionary<Key, ContourFeasibilityVerdict> cache = new();
|
||||
|
||||
public ContourEntryFeasibility(PreparedContours prepared, Vector location,
|
||||
LeadMaterialSnapshot ownMaterial, IReadOnlyList<LeadMaterialSnapshot> 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<LeadMaterialSnapshot> { 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();
|
||||
}
|
||||
|
||||
/// <summary>Validator executions performed (cache misses only) during this attempt.</summary>
|
||||
public int EvaluationCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Why a selection ended the way it did. Honest metadata, never a geometric overclaim.</summary>
|
||||
internal enum ContourSelectionShortfall
|
||||
{
|
||||
/// <summary>Selection ended at the cap with the side scan satisfied: a complete verdict.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Exhausted,
|
||||
|
||||
/// <summary>
|
||||
/// At least one check could not complete (incomplete material or emission). Never
|
||||
/// present this as geometric impossibility and never as "nothing fits".
|
||||
/// </summary>
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed record ContourSelectionResult(
|
||||
IReadOnlyList<ContourChoice> Choices,
|
||||
int EvaluatedCount,
|
||||
IReadOnlyList<(long, long)> EvaluatedKeys,
|
||||
IReadOnlyList<ContourChoice> UncertainChoices,
|
||||
ContourSelectionShortfall Shortfall,
|
||||
string? Reason);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class ContourEntrySelection
|
||||
{
|
||||
internal const int DefaultMaxEntries = 16;
|
||||
|
||||
/// <summary>Below this cap all-side coverage is not promised; the cap and ranking bind first (caps 1-3).</summary>
|
||||
internal const int SideCoverageMinCap = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Selects up to <paramref name="maxEntries"/> feasible choices from one contour's
|
||||
/// globally ranked catalogue. <paramref name="evaluate"/> is the S07 adapter verdict,
|
||||
/// called lazily at most once per distinct candidate.
|
||||
/// </summary>
|
||||
internal static ContourSelectionResult Select(
|
||||
IReadOnlyList<ContourEntryCandidate> rankedCandidates,
|
||||
Func<ContourEntryCandidate, ContourFeasibilityVerdict> 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<ContourEntryCandidate>();
|
||||
var seen = new HashSet<(long, long)>();
|
||||
foreach (var candidate in rankedCandidates)
|
||||
if (seen.Add(candidate.GeometryKey))
|
||||
candidates.Add(candidate);
|
||||
|
||||
var evaluated = new List<ContourEntryCandidate>();
|
||||
var verdicts = new Dictionary<(long, long), ContourFeasibilityVerdict>();
|
||||
var box = Box(candidates);
|
||||
|
||||
var selected = new List<ContourEntryCandidate>();
|
||||
var uncertain = new List<ContourEntryCandidate>();
|
||||
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<ContourEntryCandidate> 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);
|
||||
}
|
||||
|
||||
/// <summary>Sides 0 left, 1 right, 2 bottom, 3 top of the candidate bounding rectangle that the point sits on.</summary>
|
||||
private static List<int> 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<int>(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;
|
||||
}
|
||||
|
||||
/// <summary>Appends when a slot remains, else replaces the worst (latest-ranked) candidate whose removal preserves every other covered side.</summary>
|
||||
private static void TryPlace(List<ContourEntryCandidate> 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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="CuttingPartOrder"/>'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.
|
||||
/// </summary>
|
||||
internal static class CuttingHoleOrder
|
||||
{
|
||||
/// <summary>
|
||||
/// Orders <paramref name="holes"/> (stable ordinals, each appearing exactly once in the
|
||||
/// result, never the perimeter) to minimize arrival -> holes -> <paramref
|
||||
/// name="perimeterEntry"/> distance. <paramref name="centresByOrdinal"/> supplies one
|
||||
/// representative point per hole ordinal. Empty holes give an empty order.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<int> Plan(IReadOnlyList<int> holes,
|
||||
IReadOnlyList<Vector> 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<int>();
|
||||
|
||||
var none = Array.Empty<int>();
|
||||
var prerequisites = holes.Select(_ => (IReadOnlyCollection<int>)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();
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,13 @@ internal static class CuttingPartOrder
|
||||
/// <param name="centres">One representative point per part ordinal.</param>
|
||||
/// <param name="start">The modeled tool position before the first part.</param>
|
||||
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
|
||||
/// <param name="endpoint">
|
||||
/// 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.
|
||||
/// </param>
|
||||
internal static int[] Plan(IReadOnlyList<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token)
|
||||
IReadOnlyList<IReadOnlyCollection<int>> 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<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, int[] position, CancellationToken token)
|
||||
IReadOnlyList<IReadOnlyCollection<int>> 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<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, int[] position, CancellationToken token)
|
||||
IReadOnlyList<IReadOnlyCollection<int>> 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]]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -10,8 +10,9 @@ using OpenNest.Geometry;
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="CuttingPartOrder"/>;
|
||||
/// 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 <see cref="CuttingPartOrder"/>;
|
||||
/// 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
|
||||
/// </summary>
|
||||
internal const int StallExpansionsPerEntry = 8;
|
||||
|
||||
/// <summary>LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.</summary>
|
||||
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
|
||||
IReadOnlyList<CuttingPlanFinding> Findings, int Expansions);
|
||||
IReadOnlyList<CuttingPlanFinding> 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<int>(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<CuttingPlanFinding> 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<int, ContourEntryFeasibility> feasibility = [];
|
||||
private readonly HashSet<int> reportedNoFit = [];
|
||||
|
||||
internal int Expansions { get; private set; }
|
||||
|
||||
/// <summary>Lead precheck evaluations, tracked apart from DFS expansions: they are work, not free.</summary>
|
||||
internal int LeadPrechecks { get; private set; }
|
||||
|
||||
internal IReadOnlyList<CuttingPlanFinding> Rejected => rejected.Distinct().ToArray();
|
||||
|
||||
internal Outcome Ready(IReadOnlyList<FixedProgramPlacement> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
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<Edge> Expand(Node node, int[] sequence, Attempt attempt)
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Edge> Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
|
||||
{
|
||||
IEnumerable<FixedProgramPlacement> 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<ContourChoice> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private IReadOnlyList<ContourChoice> 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<ContourChoice> 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<int, ContourChoice>();
|
||||
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<int, ContourChoice> Entries,
|
||||
IReadOnlyDictionary<int, Vector> 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);
|
||||
/// <summary>A search state; Previous links a whole-part boundary to the boundary before it.</summary>
|
||||
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);
|
||||
/// <summary>Rank is the automatic selection slot (entry order) inside its contour stage; int.MinValue for legacy children.</summary>
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class PreferredContourEntries
|
||||
{
|
||||
/// <summary>Proposed hole choices in cut order, or an explicit no-preference verdict.</summary>
|
||||
internal sealed record Proposal(
|
||||
IReadOnlyList<ContourChoice> HoleChoices,
|
||||
ContourSelectionShortfall Shortfall,
|
||||
int? BlockedContour,
|
||||
string? Reason)
|
||||
{
|
||||
public bool IsPreferred => Shortfall != ContourSelectionShortfall.Incomplete
|
||||
&& HoleChoices.Count > 0 && BlockedContour is null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves one preferred entry per hole on <paramref name="holeRoute"/> (cut order,
|
||||
/// from <see cref="CuttingHoleOrder"/>), ending at <paramref name="perimeterChoice"/>.
|
||||
/// 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. <paramref name="evaluate"/> is the S07 adapter
|
||||
/// verdict; candidates it refuses or leaves uncertain are not preferred.
|
||||
/// </summary>
|
||||
internal static Proposal TryPlan(
|
||||
PreparedContours prepared,
|
||||
ContourChoice perimeterChoice,
|
||||
IReadOnlyList<int> holeRoute,
|
||||
IReadOnlyList<Vector> centresByOrdinal,
|
||||
Vector arrival,
|
||||
Func<ContourEntryCandidate, ContourFeasibilityVerdict> 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<ContourChoice>(), 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<ContourChoice>(), 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ContourCandidateEmissionTests
|
||||
{
|
||||
private static PreparedContours Capture(Program program, CuttingParameters? parameters = null) =>
|
||||
PreparedContours.Capture(program, parameters ?? ExplicitContourTests.Parameters());
|
||||
|
||||
// --- fixtures ---------------------------------------------------------------
|
||||
|
||||
/// <summary>Square perimeter with two circular holes; Capture lists holes 0-1, perimeter 2.</summary>
|
||||
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<ArgumentException>(() => prepared.Emit(new[] { corner }));
|
||||
Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => prepared.EmitCandidateForValidation(forgedContour));
|
||||
var forgedEntity = other.Entry(0, 0, new Vector(3.5, 3)) with { Owner = prepared, EntityOrdinal = 99 };
|
||||
Assert.Throws<ArgumentException>(() => 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<ExecutionMotion> NonRapid(Program program) =>
|
||||
ExecutionMotionReader.Read(program, Vector.Zero, null, default).Motions
|
||||
.Where(m => !m.Rapid).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <summary>The complete program's LAST contour is the perimeter: assert the isolated
|
||||
/// probe's blocks equal the trailing block sequence of the complete program.</summary>
|
||||
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<string> BlockHashes(Program program)
|
||||
{
|
||||
var motions = NonRapid(program).Where(m => m.Layer != LayerType.Scribe).ToList();
|
||||
var blocks = new List<List<ExecutionMotion>>();
|
||||
var current = new List<ExecutionMotion>();
|
||||
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<ExecutionMotion>();
|
||||
}
|
||||
current.Add(motion);
|
||||
if (!isLead)
|
||||
sawCut = true;
|
||||
}
|
||||
if (current.Count > 0)
|
||||
blocks.Add(current);
|
||||
return blocks.Select(Hash).ToList();
|
||||
}
|
||||
|
||||
private static string Hash(List<ExecutionMotion> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ContourEntryCandidateTests
|
||||
{
|
||||
private static PreparedContours Capture(Program program) =>
|
||||
PreparedContours.Capture(program, ExplicitContourTests.Parameters());
|
||||
|
||||
// --- fixtures ---------------------------------------------------------------
|
||||
|
||||
private static Program ClosedContour(IEnumerable<Vector> 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;
|
||||
}
|
||||
|
||||
/// <summary>Square perimeter with an L-notch hole slug; its concave vertex (3,3.5) is reflex for the slug's own travel.</summary>
|
||||
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<ArgumentException>(() => 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<OperationCanceledException>(() =>
|
||||
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<ArgumentException>(() => 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<ContourEntryCandidate> 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<ArgumentException>(() => 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<OperationCanceledException>(() =>
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ArgumentException>(() => 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<OperationCanceledException>(() => 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<OperationCanceledException>(() => 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ContourEntryRankingTests
|
||||
{
|
||||
private static IReadOnlyList<ContourEntryCandidate> Catalogue(Program program) =>
|
||||
PreparedContours.Capture(program, ExplicitContourTests.Parameters())
|
||||
.AutomaticEntryCandidatesWithFallbacks(0);
|
||||
|
||||
// --- fixtures ---------------------------------------------------------------
|
||||
|
||||
/// <summary>Square 0..10 in travel order, optionally cyclically reindexed or reversed.</summary>
|
||||
private static Program Square(IEnumerable<Vector> 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<ContourEntryCandidate> 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<ArgumentException>(() =>
|
||||
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<ContourEntryCandidate>();
|
||||
Assert.Empty(empty.RankTowardNextCut(new Vector(3, 4), new Vector(-1, -1)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ContourEntryCandidate, ContourFeasibilityVerdict> 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<ContourEntryCandidate, ContourFeasibilityVerdict> 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<ContourEntryCandidate>();
|
||||
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<ContourEntryCandidate>
|
||||
{
|
||||
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<ContourEntryCandidate>
|
||||
{
|
||||
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<ContourEntryCandidate>();
|
||||
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<ContourEntryCandidate>
|
||||
{
|
||||
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<ContourEntryCandidate>
|
||||
{
|
||||
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<ArgumentException>(() =>
|
||||
ContourEntrySelection.Select(Array.Empty<ContourEntryCandidate>(), _ => null!, 0));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
ContourEntrySelection.Select(Array.Empty<ContourEntryCandidate>(), _ => 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<ContourEntryCandidate>(), _ => 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<OperationCanceledException>(() =>
|
||||
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<ContourEntryCandidate>
|
||||
{
|
||||
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<ContourEntryCandidate>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<int> route, IReadOnlyList<Vector> 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<int> holes, IReadOnlyList<Vector> centres,
|
||||
Vector arrival, Vector? endpoint)
|
||||
{
|
||||
double Best(IEnumerable<int> 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<int>(), 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<int>)Array.Empty<int>()).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<int>();
|
||||
|
||||
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<OperationCanceledException>(() =>
|
||||
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<int>();
|
||||
var prerequisites = centres.Select(_ => (IReadOnlyCollection<int>)none).ToList();
|
||||
|
||||
var legacy = CuttingPartOrder.Plan(centres, Arrival, prerequisites, default);
|
||||
var extended = CuttingPartOrder.Plan(centres, Arrival, prerequisites, default, endpoint: null);
|
||||
|
||||
Assert.Equal(legacy, extended);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Actual air moves: each rapid's distance from the previous motion's end to the following cut end.</summary>
|
||||
private static List<double> Rapids(CuttingPlanResult result)
|
||||
{
|
||||
var rapids = new List<double>();
|
||||
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))}";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.)
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The owned compass candidate of a circle contour at an exact point.</summary>
|
||||
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<ArgumentException>(() => 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<OperationCanceledException>(() => 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
|
||||
}
|
||||
}
|
||||
@@ -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<OperationCanceledException>(() => 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<ContourEntryCandidate> 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<ContourEntryCandidate> ranked) =>
|
||||
selected.Choices.SelectMany(c => Sides(c.Point, ranked)).Distinct().Order().ToArray();
|
||||
|
||||
private static IEnumerable<int> Sides(Vector point, IReadOnlyList<ContourEntryCandidate> 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<ContourEntryCandidate> 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingStrategy;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>CCW square; the shape's own winding derivation says so.</summary>
|
||||
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),
|
||||
});
|
||||
|
||||
/// <summary>Same square traversed CW.</summary>
|
||||
private static Shape CwSquare(double size = 10)
|
||||
{
|
||||
var s = CcwSquare(size);
|
||||
s.Reverse();
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>CCW L-shape: the concave vertex (5,5) turns right — reflex.</summary>
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>CCW square whose top side is a half-circle bump; the tangent joints are at (0,10) and (10,10).</summary>
|
||||
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<object[]> 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<Line>(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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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<Label>(form, "previewLabel").Text);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void SentinelActivePlate_IsLabeledNotNumberedBeyondTheTotal() => RunSta(() =>
|
||||
{
|
||||
var (nest, _) = CreateView(Square("a", 1, 1));
|
||||
var sentinel = nest.CreatePlate(); // trailing empty new-plate sentinel
|
||||
using var view = new PlateView { Plate = sentinel };
|
||||
using var editor = view;
|
||||
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
|
||||
|
||||
var text = Control<Label>(form, "previewLabel").Text;
|
||||
Assert.StartsWith("New plate (empty)", text);
|
||||
Assert.DoesNotContain("Plate 2", text);
|
||||
});
|
||||
|
||||
private static (Nest Nest, PlateView View) CreateView(params Part[] parts)
|
||||
{
|
||||
var nest = new Nest();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Reflection;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Forms;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.WinForms.Tests.Forms;
|
||||
|
||||
public class PlateHeaderNumberingTests
|
||||
{
|
||||
[Fact]
|
||||
public void OnePopulatedPlateWithSentinel_HeaderSaysOneOfOne() => RunSta(() =>
|
||||
{
|
||||
var nest = new Nest("one plate");
|
||||
var plate = nest.CreatePlate();
|
||||
plate.Parts.Add(Square("a"));
|
||||
using var form = new EditNestForm(nest);
|
||||
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
|
||||
form.Show();
|
||||
|
||||
// The constructor's EnsureSentinel keeps the trailing empty new-plate workspace.
|
||||
Assert.Equal(2, nest.Plates.Count);
|
||||
Assert.Equal("Plate 1 of 1", Header(form).Split(" | ")[0]);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void NavigatingOntoTheSentinelAndBack_RelabelsWithoutTouchingNavigation() => RunSta(() =>
|
||||
{
|
||||
var nest = new Nest("sentinel navigation");
|
||||
var plate = nest.CreatePlate();
|
||||
plate.Parts.Add(Square("a"));
|
||||
using var form = new EditNestForm(nest);
|
||||
form.PlateView.SetOverlapAutoCheck(null);
|
||||
form.Show();
|
||||
|
||||
Assert.True(form.PlateManager.LoadNext()); // onto the sentinel
|
||||
Assert.Equal("New plate (empty)", Header(form));
|
||||
Assert.Equal(1, form.PlateManager.CurrentIndex); // navigation itself is unchanged
|
||||
|
||||
Assert.True(form.PlateManager.LoadPrevious());
|
||||
Assert.Equal("Plate 1 of 1", Header(form).Split(" | ")[0]);
|
||||
Assert.Equal(2, nest.Plates.Count); // the collection was never mutated for display
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void SingleEmptyPlate_EditorShowsTheNewPlateView() => RunSta(() =>
|
||||
{
|
||||
var nest = new Nest("empty");
|
||||
using var form = new EditNestForm(nest); // EnsureSentinel creates the single empty plate.
|
||||
form.PlateView.SetOverlapAutoCheck(null);
|
||||
form.Show();
|
||||
|
||||
Assert.Single(nest.Plates);
|
||||
Assert.Equal("New plate (empty)", Header(form));
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void InteriorEmptyPlate_KeepsItsNumberAndTheTotalCountsIt() => RunSta(() =>
|
||||
{
|
||||
var nest = new Nest("interior empty");
|
||||
nest.CreatePlate().Parts.Add(Square("a"));
|
||||
nest.CreatePlate(); // An empty interior plate survives EnsureSentinel (only the tail trims).
|
||||
nest.CreatePlate().Parts.Add(Square("b"));
|
||||
using var form = new EditNestForm(nest);
|
||||
form.PlateView.SetOverlapAutoCheck(null);
|
||||
form.Show();
|
||||
|
||||
Assert.Equal(4, nest.Plates.Count); // three real + sentinel
|
||||
form.PlateManager.LoadAt(1);
|
||||
Assert.Equal("Plate 2 of 3", Header(form).Split(" | ")[0]);
|
||||
form.PlateManager.LoadAt(2);
|
||||
Assert.Equal("Plate 3 of 3", Header(form).Split(" | ")[0]);
|
||||
form.PlateManager.LoadAt(3);
|
||||
Assert.Equal("New plate (empty)", Header(form));
|
||||
Assert.Equal(4, nest.Plates.Count);
|
||||
});
|
||||
|
||||
private static string Header(EditNestForm form) =>
|
||||
((Label)typeof(EditNestForm)
|
||||
.GetField("plateInfoLabel", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(form)!).Text;
|
||||
|
||||
private static Part Square(string name)
|
||||
{
|
||||
var program = new Program();
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(0, 10);
|
||||
program.LineTo(10, 10);
|
||||
program.LineTo(10, 0);
|
||||
program.LineTo(0, 0);
|
||||
return new Part(new Drawing(name, program), new Vector(1, 1));
|
||||
}
|
||||
|
||||
private static void RunSta(System.Action action) =>
|
||||
StaTestThread.Run(action, TimeSpan.FromMinutes(3), "The STA test did not complete.");
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Controls;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.WinForms.Tests;
|
||||
|
||||
public class GraphicsHelperPlacementTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Mode.Absolute, false)]
|
||||
[InlineData(Mode.Absolute, true)]
|
||||
[InlineData(Mode.Incremental, false)]
|
||||
[InlineData(Mode.Incremental, true)]
|
||||
public void PlacementTranslatesEveryOutlineAndLeadPointWithoutChangingProgram(Mode mode, bool splitPaths)
|
||||
{
|
||||
var program = new CNC.Program(Mode.Absolute);
|
||||
program.Codes.AddRange(new ICode[]
|
||||
{
|
||||
new RapidMove(-1, 0),
|
||||
new LinearMove(0, 0) { Layer = LayerType.Leadin },
|
||||
new LinearMove(0, 4),
|
||||
new LinearMove(3, 4),
|
||||
new ArcMove(4, 3, 3, 3, RotationType.CW),
|
||||
new LinearMove(4, 0),
|
||||
new LinearMove(0, 0),
|
||||
new ArcMove(-1, -1, -1, 0, RotationType.CW) { Layer = LayerType.Leadout },
|
||||
new RapidMove(3, 2),
|
||||
new ArcMove(3, 2, 2, 2),
|
||||
});
|
||||
program.Mode = mode;
|
||||
var before = NestWriter.GetProgramText(program);
|
||||
var instructions = program.Codes.ToArray();
|
||||
using var local = program.GetGraphicsPath();
|
||||
program.GetGraphicsPaths(Vector.Zero, out var localCut, out var localLead);
|
||||
using (localCut)
|
||||
using (localLead)
|
||||
{
|
||||
Assert.True(local.PointCount > 0);
|
||||
Assert.True(localCut.PointCount > 0);
|
||||
Assert.True(localLead.PointCount > 0);
|
||||
foreach (var origin in new[] { new Vector(10.25, 6.5), new Vector(-7.5, 12.25), Vector.Zero })
|
||||
{
|
||||
if (!splitPaths)
|
||||
{
|
||||
using var placed = program.GetGraphicsPath(origin);
|
||||
AssertTranslated(local, placed, origin);
|
||||
}
|
||||
else
|
||||
{
|
||||
program.GetGraphicsPaths(origin, out var cut, out var lead);
|
||||
using (cut)
|
||||
using (lead)
|
||||
{
|
||||
AssertTranslated(localCut, cut, origin);
|
||||
AssertTranslated(localLead, lead, origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Assert.Equal(before, NestWriter.GetProgramText(program));
|
||||
Assert.Equal(instructions, program.Codes);
|
||||
Assert.Equal(mode, program.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementalSubprogramOffsetsStayRelativeToProgramOrigin()
|
||||
{
|
||||
var hole = new CNC.Program(Mode.Incremental);
|
||||
hole.Codes.AddRange(new ICode[]
|
||||
{
|
||||
new RapidMove(1, 0),
|
||||
new ArcMove(0, 0, -1, 0),
|
||||
});
|
||||
var program = new CNC.Program(Mode.Incremental);
|
||||
program.Codes.Add(new SubProgramCall(hole, 0) { Offset = new Vector(3, 4) });
|
||||
program.Codes.Add(new SubProgramCall(hole, 0) { Offset = new Vector(9, 4) });
|
||||
var before = NestWriter.GetSubProgramsText(program);
|
||||
using var local = program.GetGraphicsPath();
|
||||
using var placed = program.GetGraphicsPath(new Vector(20, 30));
|
||||
AssertTranslated(local, placed, new Vector(20, 30));
|
||||
Assert.Equal(new RectangleF(22, 33, 8, 2), placed.GetBounds());
|
||||
program.GetGraphicsPaths(new Vector(20, 30), out var cut, out var lead);
|
||||
using (cut)
|
||||
using (lead)
|
||||
{
|
||||
AssertTranslated(local, cut, new Vector(20, 30));
|
||||
Assert.Equal(0, lead.PointCount);
|
||||
}
|
||||
Assert.Same(hole, ((SubProgramCall)program.Codes[0]).Program);
|
||||
Assert.Same(hole, ((SubProgramCall)program.Codes[1]).Program);
|
||||
Assert.Equal(before, NestWriter.GetSubProgramsText(program));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void SavedAbsolutePartsFollowRepeatedMovesAndViewTransform(bool splitPaths) => StaTestThread.Run(() =>
|
||||
{
|
||||
var fixture = Path.Combine(AppContext.BaseDirectory, "Fixtures", "absolute-coordinate-parts.nest");
|
||||
var nest = new NestReader(fixture).Read();
|
||||
Assert.Equal(2, nest.Plates.Count);
|
||||
Assert.Equal(3, nest.Plates.Sum(plate => plate.Parts.Count));
|
||||
using var view = new PlateView();
|
||||
view.Matrix.Reset();
|
||||
view.Matrix.Scale(2, -2);
|
||||
view.Matrix.Translate(40, 80, MatrixOrder.Append);
|
||||
foreach (var part in nest.Plates.SelectMany(plate => plate.Parts))
|
||||
{
|
||||
Assert.Equal(Mode.Absolute, part.Program.Mode);
|
||||
part.HasManualLeadIns = splitPaths;
|
||||
var before = NestWriter.GetProgramText(part.Program);
|
||||
var initial = part.Location;
|
||||
var layout = LayoutPart.Create(part, view);
|
||||
try
|
||||
{
|
||||
foreach (var location in new[] { initial, new Vector(4.25, 10.75), new Vector(15.5, 3.25), initial })
|
||||
{
|
||||
part.Location = location;
|
||||
layout.Path.Dispose();
|
||||
layout.Update(view);
|
||||
using var expected = part.Program.GetGraphicsPath();
|
||||
using var translation = new Matrix();
|
||||
translation.Translate((float)location.X, (float)location.Y);
|
||||
expected.Transform(translation);
|
||||
expected.Transform(view.Matrix);
|
||||
AssertTranslated(expected, layout.Path, Vector.Zero);
|
||||
if (splitPaths)
|
||||
Assert.Equal(0, layout.LeadInPath.PointCount);
|
||||
Assert.Equal(before, NestWriter.GetProgramText(part.Program));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
layout.Path.Dispose();
|
||||
layout.LeadInPath?.Dispose();
|
||||
}
|
||||
}
|
||||
}, TimeSpan.FromMinutes(1), "Absolute-coordinate placement test did not complete.");
|
||||
|
||||
private static void AssertTranslated(GraphicsPath local, GraphicsPath placed, Vector offset)
|
||||
{
|
||||
Assert.Equal(local.PointCount, placed.PointCount);
|
||||
Assert.Equal(local.PathTypes, placed.PathTypes);
|
||||
var expected = local.PathPoints;
|
||||
var actual = placed.PathPoints;
|
||||
for (var i = 0; i < expected.Length; i++)
|
||||
{
|
||||
Assert.InRange(actual[i].X - (expected[i].X + offset.X), -0.00001, 0.00001);
|
||||
Assert.InRange(actual[i].Y - (expected[i].Y + offset.Y), -0.00001, 0.00001);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest\OpenNest.csproj" />
|
||||
<Content Include="Fixtures\absolute-coordinate-parts.nest" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -85,10 +85,15 @@ public partial class CuttingPlanForm : Form
|
||||
Text = "Plan Cutting — All Plates";
|
||||
applyButton.Text = "&Apply to All Plates";
|
||||
}
|
||||
var activeNumber = all.IndexOf(activePlate) + 1;
|
||||
previewText = plates.Contains(activePlate)
|
||||
? $"Plate {activeNumber} of {all.Count}, numbered in cutting order:"
|
||||
: $"Plate {activeNumber} has no parts; plates with parts are listed on the right.";
|
||||
// Display-only numbering: the trailing empty new-plate sentinel is excluded from the
|
||||
// "of M" total; plateNumbers keep real collection positions for the batch summary.
|
||||
var displayCount = PlateDisplayNumbering.DisplayedPlateCount(all);
|
||||
var activeNumber = PlateDisplayNumbering.DisplayedPlateNumber(all, all.IndexOf(activePlate));
|
||||
previewText = activeNumber == null
|
||||
? "New plate (empty) has no parts; plates with parts are listed on the right."
|
||||
: plates.Contains(activePlate)
|
||||
? $"Plate {activeNumber} of {displayCount}, numbered in cutting order:"
|
||||
: $"Plate {activeNumber} has no parts; plates with parts are listed on the right.";
|
||||
previewLabel.Text = previewText;
|
||||
ShowSettings();
|
||||
}
|
||||
|
||||
@@ -781,19 +781,10 @@ namespace OpenNest.Forms
|
||||
{
|
||||
var plate = PlateManager.CurrentPlate;
|
||||
|
||||
if (plate != null)
|
||||
{
|
||||
plateInfoLabel.Text = string.Format(
|
||||
"Plate {0} of {1} | {2}",
|
||||
PlateManager.CurrentIndex + 1,
|
||||
PlateManager.Count,
|
||||
plate.Size
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
plateInfoLabel.Text = "No plates";
|
||||
}
|
||||
// Display-only numbering: the trailing empty new-plate sentinel is excluded from the
|
||||
// total (and labeled as such); navigation and storage indexes are untouched.
|
||||
plateInfoLabel.Text = PlateDisplayNumbering.FormatHeader(
|
||||
Nest.Plates, PlateManager.CurrentIndex, plate.Size.ToString());
|
||||
|
||||
btnFirstPlate.Enabled = !PlateManager.IsFirst;
|
||||
btnPreviousPlate.Enabled = !PlateManager.IsFirst;
|
||||
|
||||
+28
-21
@@ -1,4 +1,4 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
@@ -20,10 +20,12 @@ namespace OpenNest
|
||||
|
||||
public static GraphicsPath GetGraphicsPath(this Program pgm, Vector origin)
|
||||
{
|
||||
var path = new GraphicsPath();
|
||||
var curpos = origin;
|
||||
|
||||
AddProgram(path, pgm, pgm.Mode, ref curpos);
|
||||
// Program coordinates are drawing-local in either mode. Translate the
|
||||
// finished path so absolute moves cannot discard the part placement.
|
||||
var path = pgm.GetGraphicsPath();
|
||||
using var translation = new Matrix();
|
||||
translation.Translate((float)origin.X, (float)origin.Y);
|
||||
path.Transform(translation);
|
||||
|
||||
return path;
|
||||
}
|
||||
@@ -100,9 +102,14 @@ namespace OpenNest
|
||||
{
|
||||
cutPath = new GraphicsPath();
|
||||
leadPath = new GraphicsPath();
|
||||
var curpos = origin;
|
||||
var curpos = Vector.Zero;
|
||||
|
||||
AddProgramSplit(cutPath, leadPath, pgm, pgm.Mode, ref curpos);
|
||||
|
||||
using var translation = new Matrix();
|
||||
translation.Translate((float)origin.X, (float)origin.Y);
|
||||
cutPath.Transform(translation);
|
||||
leadPath.Transform(translation);
|
||||
}
|
||||
|
||||
private static void AddProgramSplit(
|
||||
@@ -354,23 +361,23 @@ namespace OpenNest
|
||||
break;
|
||||
|
||||
case CodeType.SubProgramCall:
|
||||
{
|
||||
Flush();
|
||||
var tmpmode = mode;
|
||||
var subpgm = (SubProgramCall)code;
|
||||
|
||||
if (subpgm.Program != null)
|
||||
{
|
||||
curpos = new Vector(
|
||||
frameOrigin.X + subpgm.Offset.X,
|
||||
frameOrigin.Y + subpgm.Offset.Y
|
||||
);
|
||||
AddProgram(path, subpgm.Program, mode, ref curpos);
|
||||
}
|
||||
Flush();
|
||||
var tmpmode = mode;
|
||||
var subpgm = (SubProgramCall)code;
|
||||
|
||||
mode = tmpmode;
|
||||
break;
|
||||
}
|
||||
if (subpgm.Program != null)
|
||||
{
|
||||
curpos = new Vector(
|
||||
frameOrigin.X + subpgm.Offset.X,
|
||||
frameOrigin.Y + subpgm.Offset.Y
|
||||
);
|
||||
AddProgram(path, subpgm.Program, mode, ref curpos);
|
||||
}
|
||||
|
||||
mode = tmpmode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+82
-4
@@ -77,7 +77,14 @@ the final replay enforce them:
|
||||
With regeneration, the bounded deterministic search plans internal contour order
|
||||
and native entry candidates part by part along a whole-part order. Internal
|
||||
contours precede their own perimeter; parts remain contiguous. Backtracking can
|
||||
revisit an earlier entry when a later part cannot be reached safely.
|
||||
revisit an earlier entry when a later part cannot be reached safely. For multi-part
|
||||
requests containing regenerated holes, it first tries one ranked hole chain for
|
||||
each outside endpoint. This lets a later blocked approach change the previous
|
||||
part's departure without first exhausting combinations of its earlier holes.
|
||||
This preferred pass uses the same emitted-motion checks, shared expansion budget
|
||||
and stall limit as the retained search; on failure the full entry/hole-order
|
||||
backtracking pass remains available. Single-part and no-hole requests keep their
|
||||
existing search order.
|
||||
|
||||
A preserved order is followed as given. Otherwise the order is an open
|
||||
travelling-salesman path over part centres from the start point: nearest neighbour,
|
||||
@@ -100,6 +107,22 @@ and tab trimming happen during emission. Validation uses the actual emitted
|
||||
motions, never the nominal entry point alone. Existing lead styles are not
|
||||
shortened, disabled or substituted as a search fallback.
|
||||
|
||||
Whole-circle candidates rank by distance to their next-cut target; for a hole,
|
||||
that is the next contour's actual pierce. They do not reward a diagonal point as
|
||||
if it were a bounding-box corner. All eight
|
||||
compass options remain available, including the four polar points at 0°, 90°,
|
||||
180° and 270°, and every alternative still passes the emitted-lead and rapid checks.
|
||||
Without a next-cut target, the arrival-based rule is unchanged. Polygon corner
|
||||
preferences are unchanged.
|
||||
|
||||
`Round Lead-In Angles` remains an explicit cutting setting: a 90° increment snaps
|
||||
circular-hole starts to the four polar directions; 45° also permits diagonals.
|
||||
Snapping can increase reuse of identical hole subprograms and reduce output for
|
||||
posts that support that reuse, at the cost of a less direct departure. It is not
|
||||
a clearance exemption or a guaranteed file-size reduction. The planner does not
|
||||
silently enable rounding, change the increment, or bypass checks on the rounded
|
||||
motions.
|
||||
|
||||
Every candidate rapid is checked against contours already completed, including
|
||||
earlier holes in the same part. Future contours are not yet obstacles. Rapid
|
||||
checks skip completed contours whose extents are more than 0.001 clear of the
|
||||
@@ -121,13 +144,57 @@ tangent arc at a shared vertex, such as a fillet, is an ordinary joint: an exact
|
||||
contact that the native query rounds away is not uncertain at a line endpoint the
|
||||
other curve already touches, while a contact anywhere else on the line still refuses.
|
||||
|
||||
Candidates rank by actual modeled rapid distance with stable source/contour/entry
|
||||
ordinals. Hash values and drawing names are not tie breakers. The expansion budget
|
||||
Source parts rank by modeled travel (material-centre distance at a holed-part
|
||||
boundary); within a part, preferred contour order and facing-entry rank precede
|
||||
travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names are not tie breakers. The expansion budget
|
||||
counts rejected candidates and frontier ranking as well as accepted moves, before
|
||||
emission; it is not a wall-clock timeout. Callers can cancel. Exhaustion may occur
|
||||
before already-generated siblings are traversed; it returns a refusal, not an
|
||||
unranked fallback or a proof of geometric impossibility.
|
||||
|
||||
## Automatic outside entries and look-ahead
|
||||
|
||||
An unlocked part's outside entry is chosen automatically toward the NEXT cut: the ranker orders the native candidate
|
||||
catalogue by the facing side(s) of the next part's placed-material centre, and
|
||||
the shared lead validator certifies each emitted lead lazily until up to
|
||||
`maxEntries` feasible candidates remain. At caps of four or more, a corrective
|
||||
scan reconsiders memoized clear candidates for each missing side before evaluating
|
||||
more of the catalogue; a later side cannot lose a usable point merely because
|
||||
an earlier side's scan passed it. Replacements preserve other covered sides and
|
||||
global rank, never exceed the cap, and stop evaluating the tail once coverage
|
||||
settles. Smaller caps retain rank priority rather than promising all-side coverage.
|
||||
The next cut is the next unfinished part in a supplied order — recomputed
|
||||
after every learned-order replan — or, in the full fallback search, the nearest
|
||||
dependency-ready remaining part, stable-ordinal ties; the last part has no
|
||||
target and ranks by tier then distance to the tool's arrival. Between source
|
||||
parts the tour stays nearest-first; the look-ahead rank only orders the entries
|
||||
inside one part's contour stage, so distance sorting cannot undo the facing.
|
||||
Uncertain (numerically incomplete) validator answers are not geometric refusals:
|
||||
those candidates can fill remaining retained slots for emitted-prefix checking and
|
||||
complete replay. The total retained entry count stays within `maxEntries`. A
|
||||
part/contour with no fitting lead in its fully evaluated catalogue is reported
|
||||
as "No tested lead-in fits on part N, contour M"; budget exhaustion stays a
|
||||
budget finding and incomplete checks are never presented as geometric
|
||||
impossibility. Lead prechecks are reported separately and their requests also consume
|
||||
the shared expansion budget before native work.
|
||||
|
||||
For a holed part, the search chooses an outside endpoint before cutting any hole.
|
||||
Each endpoint branch builds an open hole-centre route from the original arrival to
|
||||
that endpoint, using bounded nearest-neighbour, 2-opt and Or-opt improvement.
|
||||
Preferred hole entries are then resolved backward from the outside's actual emitted
|
||||
pierce, ignoring scribe marks: each hole faces the following contour's actual pierce.
|
||||
Convex corners lead the preference tiers, then straight midpoints/tangent joints,
|
||||
then native fallbacks. Reflex/cusp corners remain manual-only.
|
||||
|
||||
This preference orders the search; it never certifies a rapid or prunes alternate
|
||||
retained entries or hole orders. A different outside endpoint recomputes its hole
|
||||
preference. Every standalone emitted prefix is replayed from the original part
|
||||
arrival and a copy of the checker from before that part, not from the previous
|
||||
prefix (which would double-consume holes and scribes). Holes are cut once, the
|
||||
outside last, and scribes once. Locked programs remain exact. A crossed preferred
|
||||
route must recover through checked backtracking or return a refusal, never unsafe
|
||||
`Ready`.
|
||||
|
||||
Selected programs are replayed from the beginning with a fresh checker and fresh
|
||||
lead validation, without regenerating them or trusting cached search verdicts.
|
||||
Before replay, expected-emission geometry is independently built from the owned
|
||||
@@ -228,6 +295,14 @@ plans every plate that has parts. Both open one dialog built on
|
||||
whole-part order; either change replans. The settings are confirmed parameters: every
|
||||
unlocked part's lead-ins are regenerated, and locked parts keep programs that must
|
||||
already pass the checks.
|
||||
- A missing or zero-length lead-in is reported directly, rather than as a search-limit
|
||||
failure. Open `Cutting Settings...`, choose a lead-in other than `None` with a nonzero
|
||||
length on the affected `External`, `Internal`, or `Arc / Circle` tab, then replan.
|
||||
Locked programs require manual lead editing or unlocking before regeneration.
|
||||
When a lead hits another part, the finding suggests more spacing or a shorter lead;
|
||||
when no tested entry fits, it suggests reducing lead-in length and, if neighbours
|
||||
obstruct it, spacing the parts farther apart. These are suggestions, not guaranteed
|
||||
fixes: replanning runs the same checks, and Apply stays blocked until every plate is ready.
|
||||
- Every plate is captured on the UI thread and checked and planned on a worker. Clean part
|
||||
material is checked for overlaps with the pre-post overlap analyzer; overlapping parts or
|
||||
an incomplete check (see [pre-post verification](post-verification.md)) block that plate
|
||||
@@ -252,7 +327,10 @@ plans every plate that has parts. Both open one dialog built on
|
||||
the thread it was created on rather than to whichever context is current.
|
||||
- `PlateView` follows `Plate.PartsReordered`: it redraws parts in the plate's order (the
|
||||
numbers it draws are the cutting order), rebuilds their graphics and marks the overlap
|
||||
check out of date.
|
||||
check out of date. Both the editor and preview build outlines and lead paths in the
|
||||
drawing-local frame, then apply the part placement once. Absolute (G90) programs
|
||||
therefore follow moves and clones just like incremental (G91) programs; displaying a
|
||||
part does not rewrite its program or coordinate mode.
|
||||
|
||||
## Remaining integration boundaries
|
||||
|
||||
|
||||
Reference in new issue
Block a user