Support direct contour piercing and correct planner geometry checks

This commit is contained in:
aj committed 2026-10-10 16:44:03 -04:00
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
View File
@@ -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
+2
View File
@@ -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