mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-14 00:01:55 -04:00
287 lines
14 KiB
C#
287 lines
14 KiB
C#
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
|
|
}
|
|
}
|