mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-11 16:44:15 -04:00
Support direct contour piercing and correct planner geometry checks
This commit is contained in:
1 parent
c28ccd7568
commit
ca6966906f
22 files changed
+320
-86
No files matched your search
@@ -47,7 +47,7 @@ public sealed class LeadMaterialSnapshot
|
||||
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Material contour is discontinuous.");
|
||||
chain.Add(curve);
|
||||
if (PostVerificationGeometry.Closed(chain))
|
||||
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||
Finish();
|
||||
}
|
||||
Finish();
|
||||
@@ -67,6 +67,15 @@ public sealed class LeadMaterialSnapshot
|
||||
var b = rings[s][j];
|
||||
var contacts = a.Contacts(b, out var overlap);
|
||||
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
||||
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
|
||||
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|
||||
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
|
||||
// Native arc queries pad angles by 1e-5 radians, admitting
|
||||
// intersections on an extension (distance grows with radius).
|
||||
// At a shared arc joint require both actual finite spans;
|
||||
// overlap and genuine second intersections still refuse.
|
||||
if (arcJoint)
|
||||
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
|
||||
// Native contacts at rounded arc/line joints can drift from the
|
||||
// authored endpoint. Use the shared geometry tolerance only to
|
||||
// recognize that joint; overlaps and other contacts still refuse.
|
||||
@@ -101,8 +110,13 @@ public sealed class LeadMaterialSnapshot
|
||||
{
|
||||
if (chain.Count == 0)
|
||||
return;
|
||||
if (!PostVerificationGeometry.Closed(chain))
|
||||
throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly.");
|
||||
if (!NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||
{
|
||||
var gap = chain[0].Start.DistanceTo(chain[^1].End);
|
||||
throw new ArgumentException(FormattableString.Invariant(
|
||||
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
|
||||
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
|
||||
}
|
||||
rings.Add(chain.ToArray());
|
||||
chain.Clear();
|
||||
}
|
||||
|
||||
@@ -113,7 +113,8 @@ public static class LeadPathValidator
|
||||
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
|
||||
}
|
||||
}
|
||||
// Missing leads are the ReleasedContourState check's responsibility.
|
||||
// No lead motion is valid for direct contour piercing. The caller still
|
||||
// checks rapid travel and contour accounting for the complete program.
|
||||
return new(true, true, null);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Closure of source contours during planner material capture and preparation.</summary>
|
||||
internal static class NominalContourClosure
|
||||
{
|
||||
internal const double Tolerance = 0.000001;
|
||||
|
||||
internal static bool IsClosed(Vector start, Vector end)
|
||||
{
|
||||
// Subtracting translated/rotated coordinates can put an exact boundary gap
|
||||
// a few floating-point ulps above the limit. Bound that allowance to 1e-12.
|
||||
var scale = System.Math.Max(1, System.Math.Max(
|
||||
System.Math.Max(System.Math.Abs(start.X), System.Math.Abs(start.Y)),
|
||||
System.Math.Max(System.Math.Abs(end.X), System.Math.Abs(end.Y))));
|
||||
var roundoff = System.Math.Min(1e-12, 4 * 2.2204460492503131e-16 * scale);
|
||||
return start.DistanceTo(end) <= Tolerance + roundoff;
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ public sealed class PreparedContours
|
||||
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Discontinuous native contour.");
|
||||
current.Entities.Add(entity);
|
||||
if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon)
|
||||
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
|
||||
FinishContour();
|
||||
}
|
||||
if (current.Entities.Count != 0 || contours.Count == 0)
|
||||
|
||||
@@ -11,10 +11,20 @@ namespace OpenNest.CNC.CuttingPlanning;
|
||||
public sealed class ReleasedContourState
|
||||
{
|
||||
private readonly List<Obstacle> obstacles = new();
|
||||
private readonly bool reportMissingLeadIns;
|
||||
|
||||
public ReleasedContourState() : this(true) { }
|
||||
|
||||
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
|
||||
/// may pierce directly on a contour and checks travel without requiring a lead.</param>
|
||||
public ReleasedContourState(bool reportMissingLeadIns)
|
||||
{
|
||||
this.reportMissingLeadIns = reportMissingLeadIns;
|
||||
}
|
||||
|
||||
public ReleasedContourState Copy()
|
||||
{
|
||||
var copy = new ReleasedContourState();
|
||||
var copy = new ReleasedContourState(reportMissingLeadIns);
|
||||
copy.obstacles.AddRange(obstacles);
|
||||
return copy;
|
||||
}
|
||||
@@ -98,7 +108,7 @@ public sealed class ReleasedContourState
|
||||
if (contour.Count == 0)
|
||||
{
|
||||
contourNumber++;
|
||||
if (!cutoff && !hasLead)
|
||||
if (reportMissingLeadIns && !cutoff && !hasLead)
|
||||
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
|
||||
{ Location = move.Curve.Start });
|
||||
|
||||
@@ -201,6 +201,15 @@ internal static class PostVerificationGeometry
|
||||
|
||||
internal bool Contains(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= Epsilon;
|
||||
|
||||
/// <summary>
|
||||
/// Filters supporting-circle contacts against the actual finite arc span. The
|
||||
/// native angular padding is larger and must not turn an arc extension into a
|
||||
/// self-intersection at an adjacent nominal joint. This is not a radial test.
|
||||
/// </summary>
|
||||
internal bool IsOutsideArcSpan(Vector point) => Center.HasValue && !OnArc(point);
|
||||
|
||||
internal bool IsFiniteArc => Center.HasValue && System.Math.Abs(Sweep) < TwoPi;
|
||||
|
||||
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
||||
{
|
||||
var entity = nativeEntity.Value;
|
||||
|
||||
@@ -28,7 +28,7 @@ internal static class BestEffortCuttingPlan
|
||||
var done = new HashSet<int>();
|
||||
var position = snapshot.StartPoint;
|
||||
var distance = 0.0;
|
||||
var checker = new ReleasedContourState();
|
||||
var checker = new ReleasedContourState(reportMissingLeadIns: false);
|
||||
try
|
||||
{
|
||||
while (order.Count < snapshot.Placements.Count)
|
||||
|
||||
@@ -31,8 +31,8 @@ public sealed record ContourFeasibilityVerdict(ContourFeasibilityStatus Status,
|
||||
/// 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
|
||||
/// contour's emitted leads only — NoLeadIn is supported, and the complete plan must
|
||||
/// still pass rapid travel and contour-accounting checks. 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
|
||||
|
||||
@@ -349,7 +349,7 @@ public sealed class CuttingPlanProposal
|
||||
public IReadOnlyList<string> Describe(string unit)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var ready = Plates.Count(p => p.IsReady);
|
||||
var blocked = Plates.Count(p => !p.CanApplyWithWarnings);
|
||||
if (IsCancelled)
|
||||
lines.Add("Planning was cancelled. Nothing has changed.");
|
||||
else if (CanApply)
|
||||
@@ -360,7 +360,7 @@ public sealed class CuttingPlanProposal
|
||||
lines.Add("Best-effort plan available. Review the warnings and accept the unverified plan to apply. "
|
||||
+ "This is not approval to cut or post CNC output.");
|
||||
else
|
||||
lines.Add($"Apply is unavailable: {Plates.Count - ready} of {Count(Plates.Count, "plate")} could not "
|
||||
lines.Add($"Apply is unavailable: {blocked} of {Count(Plates.Count, "plate")} could not "
|
||||
+ "be planned. No plate changes until every plate is ready.");
|
||||
|
||||
foreach (var plate in Plates)
|
||||
|
||||
@@ -241,7 +241,7 @@ public static class CuttingPlanService
|
||||
if (placement.Prepared != null)
|
||||
continue; // An eligible old crossing is precisely what regeneration may repair.
|
||||
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
|
||||
var findings = new ReleasedContourState().Check(placement.Execution, null,
|
||||
var findings = new ReleasedContourState(reportMissingLeadIns: false).Check(placement.Execution, null,
|
||||
placement.SourceOrdinal + 1, placement.IsCutOff, token);
|
||||
fixedFindings.AddRange(Map(snapshot, findings));
|
||||
}
|
||||
@@ -279,7 +279,7 @@ public static class CuttingPlanService
|
||||
if (snapshot.Dependencies.FirstViolation(order) is { } violation)
|
||||
return new(CuttingPlanStatus.InvalidInput,
|
||||
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
|
||||
var checker = new ReleasedContourState();
|
||||
var checker = new ReleasedContourState(reportMissingLeadIns: false);
|
||||
var position = snapshot.StartPoint;
|
||||
var distance = 0.0;
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
@@ -313,7 +313,7 @@ public static class CuttingPlanService
|
||||
if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation)
|
||||
return new(CuttingPlanStatus.InvalidInput,
|
||||
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
|
||||
var checker = new ReleasedContourState();
|
||||
var checker = new ReleasedContourState(reportMissingLeadIns: false);
|
||||
var position = snapshot.StartPoint;
|
||||
var distance = 0.0;
|
||||
var findings = new List<CuttingPlanFinding>();
|
||||
|
||||
@@ -16,7 +16,7 @@ internal static class FixedProgramSearch
|
||||
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||
{
|
||||
var stack = new Stack<Frame>();
|
||||
stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState()));
|
||||
stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false)));
|
||||
var rejected = new HashSet<PostVerificationFinding>();
|
||||
var expansions = 0;
|
||||
while (stack.Count != 0)
|
||||
|
||||
@@ -191,7 +191,7 @@ internal static class JointCuttingPlanSearch
|
||||
/// </summary>
|
||||
internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false)
|
||||
{
|
||||
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(), null, null);
|
||||
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null);
|
||||
var attempt = new Attempt(sequence);
|
||||
var progressExpansions = Expansions;
|
||||
var stack = new Stack<Frame>();
|
||||
|
||||
@@ -126,7 +126,7 @@ public class ContourEntryFeasibilityTests
|
||||
|
||||
// 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
|
||||
// complete-plan rapid/contour checks still have 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;
|
||||
|
||||
@@ -45,7 +45,9 @@ public class CuttingPlanBatchTests
|
||||
var nest = new Nest();
|
||||
var ready = Plate(nest, Clean("open", 1, 1));
|
||||
var locked = Clean("locked", 1, 1);
|
||||
locked.LeadInsLocked = true; // Locked programs never regenerate: no lead-in is a conflict.
|
||||
var crossing = OwnedProgramCopy.Copy(locked.Program);
|
||||
crossing.MoveTo(5, 5); // Rapid enters the already completed square.
|
||||
Assert.True(locked.RestoreLeadInProgram(crossing, true));
|
||||
var blocked = Plate(nest, locked);
|
||||
var readyProgram = ready.Parts[0].Program;
|
||||
|
||||
@@ -314,7 +316,9 @@ public class CuttingPlanBatchTests
|
||||
var nest = new Nest();
|
||||
var ready = Plate(nest, Clean("a", 1, 1));
|
||||
var locked = Clean("locked", 1, 1);
|
||||
locked.LeadInsLocked = true;
|
||||
var crossing = OwnedProgramCopy.Copy(locked.Program);
|
||||
crossing.MoveTo(5, 5); // Rapid enters the already completed square.
|
||||
Assert.True(locked.RestoreLeadInProgram(crossing, true));
|
||||
var blocked = Plate(nest, locked);
|
||||
var proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(), false).Plan();
|
||||
Assert.True(proposal.Plates[0].IsReady);
|
||||
|
||||
@@ -8,77 +8,91 @@ namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class CuttingPlanMessageTests
|
||||
{
|
||||
[Fact]
|
||||
public void Describe_MixedBestEffortAndOpenContour_CountsOnlyBlockedPlateAndExplainsGap()
|
||||
{
|
||||
var open = new OpenNest.CNC.Program();
|
||||
open.MoveTo(0, 0);
|
||||
open.LineTo(10, 0);
|
||||
open.LineTo(10, 10);
|
||||
open.LineTo(0, 10);
|
||||
open.LineTo(0, 0.000002);
|
||||
var part = new Part(new Drawing("open perimeter", open));
|
||||
var original = OwnedProgramCopy.Copy(part.BaseDrawing.Program);
|
||||
var plates = new[]
|
||||
{
|
||||
BestEffortCuttingPlanTests.Plate(BestEffortCuttingPlanTests.TouchingContours()),
|
||||
BestEffortCuttingPlanTests.Plate(part),
|
||||
};
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture(plates, ExplicitContourTests.Parameters(), false).Plan();
|
||||
var text = string.Join("\n", proposal.Describe("in"));
|
||||
|
||||
Assert.StartsWith("Apply is unavailable: 1 of 2 plates could not be planned.", text);
|
||||
Assert.Contains("Plate 1: best-effort, unverified.", text);
|
||||
Assert.Contains("Plate 2: blocked:", text);
|
||||
Assert.Contains("contour 1 is open: endpoint gap 2E-06 model units", text);
|
||||
Assert.Contains("closure tolerance 1E-06", text);
|
||||
Assert.Contains("Review the source contour in the drawing editor", text);
|
||||
Assert.False(proposal.CanApplyWithWarnings);
|
||||
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(true).Status);
|
||||
Assert.True(ProgramContent.Equal(original, part.BaseDrawing.Program));
|
||||
Assert.All(plates.SelectMany(p => p.Parts), p => Assert.False(p.HasManualLeadIns));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(true, true)]
|
||||
public void Describe_MissingOrZeroLengthLead_ExplainsSettingsWithoutBlamingSearch(bool keepOrder, bool zeroLength)
|
||||
public void Describe_NoneIsSupportedButDegenerateLeadMotionStillRefuses(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 plate = BestEffortCuttingPlanTests.Plate(part);
|
||||
var original = OwnedProgramCopy.Copy(part.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);
|
||||
if (zeroLength)
|
||||
{
|
||||
Assert.False(proposal.CanApply);
|
||||
Assert.Contains("Lead motion is missing, degenerate or inconsistent", string.Join("\n", proposal.Describe("in")));
|
||||
Assert.True(ProgramContent.Equal(original, part.Program));
|
||||
return;
|
||||
}
|
||||
|
||||
// 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(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
|
||||
var result = Assert.Single(proposal.Plates).Result;
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
Assert.DoesNotContain(result.Findings, f => f.Kind == PostVerificationKind.MissingLeadIn);
|
||||
Assert.Contains("Plate 1: ready.", string.Join("\n", proposal.Describe("in")));
|
||||
var output = Assert.Single(result.ProposedOrder).CopyProgram();
|
||||
Assert.DoesNotContain(ExecutionMotionReader.ReadSupported(output, part.Location, null).Motions,
|
||||
m => m.Layer == OpenNest.CNC.LayerType.Leadin && m.Length > 0);
|
||||
Assert.True(ProgramContent.Equal(original, part.Program));
|
||||
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_LockedProgramWithoutLead_ExplainsThatSettingsCannotRegenerateIt()
|
||||
public void Describe_LockedProgramWithoutLead_IsReadyAndKeepsItsProgram()
|
||||
{
|
||||
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 plate = BestEffortCuttingPlanTests.Plate(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(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
|
||||
Assert.False(Assert.Single(proposal.Plates[0].Result.ProposedOrder).IsRegenerated);
|
||||
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status);
|
||||
Assert.True(part.LeadInsLocked);
|
||||
Assert.Same(program, part.Program);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_LeadHitsNeighbour_SuggestsSpacingOrShorterLeadWithoutAllowingApply()
|
||||
{
|
||||
|
||||
@@ -126,9 +126,24 @@ public class CuttingPlanServiceTests
|
||||
Assert.Equal(beforeCodes, program.Codes?.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedProgramWithoutLead_IsReadyAndReplayed()
|
||||
{
|
||||
var part = Fixture()[0];
|
||||
part.Program.Codes.RemoveAt(1);
|
||||
((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
|
||||
var unchanged = Unchanged([part]);
|
||||
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
|
||||
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
Assert.False(Assert.Single(result.ProposedOrder).IsRegenerated);
|
||||
unchanged();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("suppressed")]
|
||||
[InlineData("missing-lead")]
|
||||
[InlineData("retention-unknown")]
|
||||
[InlineData("cutoff")]
|
||||
public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault)
|
||||
@@ -138,10 +153,6 @@ public class CuttingPlanServiceTests
|
||||
switch (fault)
|
||||
{
|
||||
case "suppressed": ((Motion)part.Program.Codes[2]).Suppressed = true; break;
|
||||
case "missing-lead":
|
||||
part.Program.Codes.RemoveAt(1);
|
||||
((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
|
||||
expected = CuttingPlanStatus.ConstraintConflict; break;
|
||||
case "retention-unknown": ((LinearMove)part.Program.Codes[^2]).EndPoint = new Vector(4, 2.25); break;
|
||||
case "cutoff": part.BaseDrawing.IsCutOff = true; break;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,55 @@ using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class JointCuttingPlanTests
|
||||
{
|
||||
[Fact]
|
||||
public void JointSearch_NoLeads_RepairsRapidCrossings()
|
||||
{
|
||||
var (part, parameters) = Crossing();
|
||||
parameters.ExternalLeadIn = new NoLeadIn();
|
||||
parameters.InternalLeadIn = new NoLeadIn();
|
||||
parameters.ArcCircleLeadIn = new NoLeadIn();
|
||||
var unchanged = Unchanged(part, parameters);
|
||||
var source = Read(part.Program, part.Location);
|
||||
Assert.Contains(new ReleasedContourState(reportMissingLeadIns: false).Check(source, Vector.Zero, 1),
|
||||
f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], confirmedParameters: parameters));
|
||||
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
var output = Read(Assert.Single(result.ProposedOrder).CopyProgram(), part.Location);
|
||||
Assert.DoesNotContain(output.Motions, m => m.Layer == LayerType.Leadin);
|
||||
Assert.Empty(new ReleasedContourState(reportMissingLeadIns: false).Check(output, Vector.Zero, 1));
|
||||
Assert.Contains(new ReleasedContourState().Check(output, Vector.Zero, 1),
|
||||
f => f.Kind == PostVerificationKind.MissingLeadIn);
|
||||
unchanged();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JointSearch_NoLeads_ChoosesDifferentPiercesForOppositeApproaches()
|
||||
{
|
||||
var part = new Part(new Drawing("direct pierce", ExplicitContourTests.Square(false)));
|
||||
var settings = new CuttingParameters();
|
||||
var pierces = new List<Vector?>();
|
||||
foreach (var start in new[] { new Vector(-5, 5), new Vector(15, 5) })
|
||||
{
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], start, confirmedParameters: settings));
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
var output = Read(Assert.Single(result.ProposedOrder).CopyProgram(), part.Location);
|
||||
Assert.DoesNotContain(output.Motions, m => m.Layer == LayerType.Leadin);
|
||||
pierces.Add(output.Motions.First(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Start);
|
||||
Assert.Empty(new ReleasedContourState(reportMissingLeadIns: false).Check(output, start, 1));
|
||||
}
|
||||
Assert.NotEqual(pierces[0], pierces[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JointSearch_RepairsActualCompletedHoleCrossing_WithExactReplayAndNoMutation()
|
||||
{
|
||||
@@ -46,7 +90,6 @@ public class JointCuttingPlanTests
|
||||
[Theory]
|
||||
[InlineData("locked")]
|
||||
[InlineData("ineligible")]
|
||||
[InlineData("no-valid-entry")]
|
||||
[InlineData("unsupported")]
|
||||
public void JointSearch_RefusesWithoutInstallingFallbackOrMutating(string fault)
|
||||
{
|
||||
@@ -55,12 +98,6 @@ public class JointCuttingPlanTests
|
||||
var expected = CuttingPlanStatus.ConstraintConflict;
|
||||
if (fault == "locked") part.LeadInsLocked = true;
|
||||
if (fault == "ineligible") eligible = [];
|
||||
if (fault == "no-valid-entry")
|
||||
{
|
||||
parameters.ArcCircleLeadIn = new NoLeadIn();
|
||||
parameters.InternalLeadIn = parameters.ArcCircleLeadIn;
|
||||
expected = CuttingPlanStatus.NoSolutionWithinBudget;
|
||||
}
|
||||
if (fault == "unsupported")
|
||||
{
|
||||
part.BaseDrawing.Program.Codes.AddRange(LeadPathValidationTests.Rectangle(30, 30, 40, 40).Codes);
|
||||
@@ -418,7 +455,7 @@ public class JointCuttingPlanTests
|
||||
var manual = part.HasManualLeadIns;
|
||||
var bounds = part.BoundingBox;
|
||||
var quantity = part.BaseDrawing.Quantity.Nested;
|
||||
var length = ((LineLeadIn)parameters.ExternalLeadIn).Length;
|
||||
var settings = CuttingParametersSerializer.Serialize(parameters);
|
||||
return () =>
|
||||
{
|
||||
Assert.Same(program, part.Program); Assert.Same(clean, part.BaseDrawing.Program);
|
||||
@@ -427,7 +464,7 @@ public class JointCuttingPlanTests
|
||||
Assert.Equal(locked, part.LeadInsLocked); Assert.Equal(manual, part.HasManualLeadIns);
|
||||
Assert.Same(bounds, part.BoundingBox); Assert.Same(parameters, part.CuttingParameters);
|
||||
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
|
||||
Assert.Equal(length, ((LineLeadIn)parameters.ExternalLeadIn).Length);
|
||||
Assert.Equal(settings, CuttingParametersSerializer.Serialize(parameters));
|
||||
foreach (var sub in subs)
|
||||
{
|
||||
Assert.Same(sub.Program, sub.c.Program); Assert.Equal(sub.text, sub.c.Program.ToString());
|
||||
|
||||
@@ -6,6 +6,61 @@ namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class LeadMaterialSnapshotTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, 0, 0, 0)]
|
||||
[InlineData(true, 0, 0, 0)]
|
||||
[InlineData(false, 0.7, 40, 20)]
|
||||
[InlineData(true, 0.7, 40, 20)]
|
||||
public void AdjacentArcs_IntersectionOnExtensionIsNotASelfIntersection(bool atSeam, double angle, double x, double y)
|
||||
{
|
||||
var program = ArcJoint(0.00005, false, atSeam);
|
||||
program.Rotate(angle);
|
||||
var location = new Vector(x, y);
|
||||
var execution = ExecutionMotionReader.ReadSupported(program, location, null);
|
||||
var arcs = execution.Motions.Where(m => m.Curve?.IsFiniteArc == true).Select(m => m.Curve!).ToArray();
|
||||
var contacts = arcs[0].Contacts(arcs[1], out var overlap);
|
||||
Assert.False(overlap);
|
||||
Assert.Contains(contacts, p => arcs.Any(a => a.IsOutsideArcSpan(p)));
|
||||
|
||||
var material = LeadMaterialSnapshot.Capture(program, location);
|
||||
|
||||
Assert.True(material.IsComplete, material.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjacentArcs_RealSecondIntersectionRemainsRejected()
|
||||
{
|
||||
var program = ArcJoint(-0.00005, true, false);
|
||||
var arcs = ExecutionMotionReader.ReadSupported(program, Vector.Zero, null).Motions
|
||||
.Where(m => m.Curve?.IsFiniteArc == true).Select(m => m.Curve!).ToArray();
|
||||
var contacts = arcs[0].Contacts(arcs[1], out var overlap);
|
||||
Assert.False(overlap);
|
||||
Assert.Contains(contacts, p => p.DistanceTo(Vector.Zero) > 0.00001
|
||||
&& !arcs[0].IsOutsideArcSpan(p) && !arcs[1].IsOutsideArcSpan(p));
|
||||
var material = LeadMaterialSnapshot.Capture(program, Vector.Zero);
|
||||
|
||||
Assert.False(material.IsComplete);
|
||||
Assert.Contains("Material boundaries", material.Reason);
|
||||
}
|
||||
|
||||
private static Program ArcJoint(double centerOffset, bool reverseSecond, bool atSeam)
|
||||
{
|
||||
// Near-tangent circles meet at zero and about 0.000106 away. The native
|
||||
// angular band of the radius-18 arc admits its extension beyond zero.
|
||||
var first = new Vector(-18, 18);
|
||||
var center = new Vector(centerOffset, 1);
|
||||
var end = center + new Vector(center.DistanceTo(Vector.Zero), 0);
|
||||
var program = new Program();
|
||||
program.MoveTo(atSeam ? Vector.Zero : first);
|
||||
if (!atSeam)
|
||||
program.ArcTo(0, 0, 0, 18, RotationType.CCW);
|
||||
program.ArcTo(end.X, end.Y, center.X, center.Y, reverseSecond ? RotationType.CW : RotationType.CCW);
|
||||
program.LineTo(first);
|
||||
if (atSeam)
|
||||
program.ArcTo(0, 0, 0, 18, RotationType.CCW);
|
||||
return program;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class NominalContourClosureTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0.0000009, true)]
|
||||
[InlineData(0.000001, true)]
|
||||
[InlineData(0.00000101, false)]
|
||||
[InlineData(0.000002, false)]
|
||||
public void CaptureAndPreparation_AgreeAtClosureBoundary(double gap, bool accepted)
|
||||
{
|
||||
// Nonzero coordinates exercise roundoff at the inclusive boundary.
|
||||
var program = new OpenNest.CNC.Program();
|
||||
program.MoveTo(14, 3);
|
||||
program.LineTo(24, 3);
|
||||
program.LineTo(24, 13);
|
||||
program.LineTo(14, 13);
|
||||
program.LineTo(14, 3 + gap);
|
||||
var original = OwnedProgramCopy.Copy(program);
|
||||
|
||||
var material = LeadMaterialSnapshot.Capture(program, new Vector(30, 20));
|
||||
|
||||
Assert.Equal(accepted, material.IsComplete);
|
||||
if (accepted)
|
||||
Assert.Equal(1, PreparedContours.Capture(program, ExplicitContourTests.Parameters()).Count);
|
||||
else
|
||||
{
|
||||
Assert.Contains("closure tolerance 1E-06", material.Reason);
|
||||
Assert.Throws<ArgumentException>(() => PreparedContours.Capture(program, ExplicitContourTests.Parameters()));
|
||||
}
|
||||
Assert.True(ProgramContent.Equal(original, program));
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,9 @@ public class CuttingPlanFormTests
|
||||
public void BlockedPlate_KeepsApplyDisabledAndShowsTheCurrentParts() => RunSta(() =>
|
||||
{
|
||||
var locked = Square("locked", 1, 1);
|
||||
locked.LeadInsLocked = true; // A locked program is never regenerated: no lead-in blocks it.
|
||||
var crossing = (OpenNest.CNC.Program)locked.Program.Clone();
|
||||
crossing.MoveTo(5, 5); // A locked crossing cannot be repaired by regeneration.
|
||||
Assert.True(locked.RestoreLeadInProgram(crossing, true));
|
||||
var (nest, view) = CreateView(locked);
|
||||
using var editor = view;
|
||||
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
|
||||
@@ -187,7 +189,9 @@ public class CuttingPlanFormTests
|
||||
first.Parts.Add(Square("open", 1, 1));
|
||||
var second = nest.CreatePlate();
|
||||
var locked = Square("locked", 1, 1);
|
||||
locked.LeadInsLocked = true;
|
||||
var crossing = (OpenNest.CNC.Program)locked.Program.Clone();
|
||||
crossing.MoveTo(5, 5);
|
||||
Assert.True(locked.RestoreLeadInProgram(crossing, true));
|
||||
second.Parts.Add(locked);
|
||||
nest.CreatePlate(); // An empty plate, like the editor's trailing plate.
|
||||
using var view = new PlateView { Plate = first };
|
||||
|
||||
+25
-8
@@ -57,8 +57,17 @@ Nominal material validation recognizes contacts at shared endpoints of adjacent
|
||||
contour segments using `OpenNest.Math.Tolerance.Epsilon` (0.00001 model units).
|
||||
This avoids false self-intersection warnings from rounding at arc/line joints,
|
||||
including the contour seam. Overlapping segments and contacts away from those
|
||||
joints or between separate contours still fail validation. Contour closure and
|
||||
the underlying native intersection queries retain their existing tolerances.
|
||||
joints or between separate contours still fail validation. For adjacent finite arcs
|
||||
with a shared endpoint, material validation filters native contacts against both
|
||||
actual arc spans: the native query's angular padding can otherwise report a
|
||||
supporting-circle intersection beyond one arc's endpoint. This does not increase
|
||||
the joint-distance tolerance, discard overlapping arcs, or waive a second intersection
|
||||
inside both spans. Lead and rapid checks keep their conservative contact queries.
|
||||
Source contour closure
|
||||
in material capture and emitter preparation accepts endpoint gaps up to 0.000001
|
||||
model units (inches for an inch nest), with a floating-point roundoff allowance
|
||||
capped at 1e-12. It does not rewrite source endpoints. Native intersections,
|
||||
executed-motion checks and pre-post verification retain their existing tolerances.
|
||||
|
||||
Capture builds whole-part prerequisites from owned values, and both the search and
|
||||
the final replay enforce them:
|
||||
@@ -232,7 +241,7 @@ Neither obtaining a proposal nor copying its programs installs them on live part
|
||||
## Results and refusal
|
||||
|
||||
`Ready` and `IndependentlyReplayed` describe the modeled proposal only. In the
|
||||
no-parameter fixed route, replay checks rapid crossings, missing leads and
|
||||
no-parameter fixed route, replay checks rapid crossings and
|
||||
incomplete retention; it does not add regeneration-mode material/lead checks.
|
||||
In regeneration mode, replay also checks actual lead paths and contour accounting
|
||||
against owned clean material. Neither mode certifies final NC, production cutting
|
||||
@@ -317,10 +326,13 @@ 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. Locked parts keep their exact programs;
|
||||
the strict route checks them, while any best-effort warnings require explicit review below.
|
||||
- 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.
|
||||
- Lead-ins are optional for planning. With `None` (or a style that emits no lead
|
||||
motion), the emitter places the pierce directly on the selected contour point.
|
||||
Unlocked contour starts can still move to improve travel; locked programs keep
|
||||
their existing starts. Search and independent replay still check rapid crossings,
|
||||
emitted lead paths when present, and contour accounting. The separate pre-post
|
||||
missing-lead warning remains available for operator review; a ready plan does not
|
||||
waive posting checks.
|
||||
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
|
||||
@@ -360,7 +372,12 @@ plans every plate that has parts. Both open one dialog built on
|
||||
programs. Ready and best-effort proposals can be previewed only while the plate still
|
||||
matches capture. Best-effort previews are labelled `UNVERIFIED`. Refused program graphs
|
||||
are never copied, and changed plates require replanning.
|
||||
- Apply requires usable output for every plate and remains all or nothing. A ready batch
|
||||
- Apply requires usable output for every plate and remains all or nothing. The summary
|
||||
counts best-effort proposals as usable when reporting how many plates block Apply;
|
||||
their warnings still require acceptance. Open nominal contour findings identify the
|
||||
contour number, endpoint gap in model units (the nest's linear units), and closure
|
||||
tolerance, so the source can be reviewed in the drawing editor. Reporting never
|
||||
closes gaps or changes the validation tolerance. A fully ready batch
|
||||
can apply immediately; an unverified batch requires the unchecked, per-proposal
|
||||
`I reviewed the warnings. Apply this unverified plan.` checkbox. Replanning clears it.
|
||||
Both use the same owned-program, freshness and rollback boundary. After it applies, each plate keeps its own copy of the
|
||||
|
||||
@@ -54,6 +54,8 @@ shows all three check categories, even when there are no findings:
|
||||
- **Missing lead-ins:** cutting contours without an actual, nonzero lead-in in the
|
||||
placed program. Applying a lead-in to only one contour does not clear the other
|
||||
contours. Scribe-only work and scrap cutoff lines do not require lead-ins here.
|
||||
The cutting planner supports direct contour piercing without lead-ins; that does
|
||||
not suppress this pre-post review warning or grant posting consent.
|
||||
- **Rapid crossings:** direct XY moves crossing closed, already-cut, untabbed
|
||||
contours in cutting order. This includes earlier holes within the same part and
|
||||
previously cut parts. A future cut is not an obstacle yet. Actual uncut gaps are
|
||||
|
||||
Reference in new issue
Block a user