diff --git a/OpenNest.Core/CNC/CuttingPlanning/FixedLeadReview.cs b/OpenNest.Core/CNC/CuttingPlanning/FixedLeadReview.cs
new file mode 100644
index 00000000..40b51560
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/FixedLeadReview.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Diagnostics;
+
+namespace OpenNest.CNC.CuttingPlanning;
+
+///
+/// Pre-post review of fixed emissions: Plan Cutting accepts a locked program's leads as given
+/// and warns (D1a), so posting checks them again against every part's clean material on the
+/// plate. Reports only; nothing is repaired and an incomplete check is never a clear result.
+///
+internal static class FixedLeadReview
+{
+ internal static void Add(Plate plate, int plateNumber, List findings,
+ CancellationToken token)
+ {
+ var parts = plate.Parts;
+ var reviewed = Enumerable.Range(0, parts.Count)
+ .Where(i => parts[i] is { LeadInsLocked: true, BaseDrawing.IsCutOff: false }).ToArray();
+ if (reviewed.Length == 0)
+ return;
+ var materials = new LeadMaterialSnapshot[parts.Count];
+ var uncaptured = new List();
+ for (var index = 0; index < parts.Count; index++)
+ {
+ token.ThrowIfCancellationRequested();
+ var part = parts[index];
+ if (part?.BaseDrawing?.Program == null || part.BaseDrawing.IsCutOff)
+ continue;
+ try
+ {
+ // Scribe-only drawings are marks, not material.
+ if (!ExecutionMotionReader.Read(part.BaseDrawing.Program, Geometry.Vector.Zero, null, token).Motions
+ .Any(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display))
+ continue;
+ // The same clean, rotation-baked, seam-trimmed material that planning captures.
+ var clean = (Program)part.BaseDrawing.Program.Clone();
+ clean.Rotate(part.Rotation - part.BaseDrawing.Program.Rotation);
+ SeamOvercut.Trim(clean);
+ materials[index] = LeadMaterialSnapshot.Capture(clean, part.Location, token);
+ }
+ catch (Exception exception) when (IsInvalid(exception))
+ {
+ // The overlap and rapid checks already report unreadable programs.
+ }
+ if (materials[index] is not { IsComplete: true })
+ uncaptured.Add(index);
+ }
+ var others = materials.Select((material, index) => (Material: material, Index: index))
+ .Where(m => m.Material is { IsComplete: true }).ToArray();
+ // Leads near material that could not be captured are not checked: say so once per plate.
+ foreach (var index in uncaptured)
+ findings.Add(Incomplete(index, (materials[index]?.Reason ?? "the clean material could not be captured.")
+ + " Locked leads near this part are not checked."));
+ foreach (var index in reviewed)
+ {
+ token.ThrowIfCancellationRequested();
+ var part = parts[index];
+ if (materials[index] is not { IsComplete: true } own)
+ continue;
+ OwnedExecution execution;
+ try
+ {
+ execution = ExecutionMotionReader.Read(part.Program, part.Location, null, token);
+ }
+ catch (Exception exception) when (IsInvalid(exception))
+ {
+ continue; // The rapid check reports the unreadable program.
+ }
+ var failures = new List();
+ var result = LeadPathValidator.Check(execution, own, others.Select(o => o.Material).ToArray(),
+ _ => true, failures, token);
+ if (!result.IsComplete)
+ findings.Add(Incomplete(index, result.Reason));
+ foreach (var failure in failures)
+ {
+ var obstacle = failure.Obstacle is int other ? others[other].Index + 1 : (int?)null;
+ var what = !failure.Result.IsComplete ? $"could not be checked ({failure.Result.Reason})"
+ : obstacle != null ? $"contacts or enters the material of part {obstacle}"
+ : "contacts or enters its own part's material";
+ var motion = execution.Motions[failure.Motion];
+ findings.Add(new(PostVerificationKind.FixedLead, plateNumber, index + 1, obstacle,
+ $"Locked lead on cutting contour {ContourAt(execution, failure.Motion)} {what}. "
+ + "Planning kept it as given; review it before posting.")
+ { Location = motion.Curve?.Midpoint ?? motion.End });
+ }
+ }
+
+ PostVerificationFinding Incomplete(int index, string reason) =>
+ new(PostVerificationKind.Incomplete, plateNumber, index + 1, null, "Fixed lead check: " + reason);
+ }
+
+ ///
+ /// The one-based cutting contour a lead motion belongs to: a lead-in leads into the next cut
+ /// run, a lead-out leaves the last one. Scribes are not cutting contours.
+ ///
+ internal static int ContourAt(OwnedExecution execution, int motion)
+ {
+ var count = 0;
+ var cutting = false;
+ for (var index = 0; index < motion; index++)
+ {
+ var move = execution.Motions[index];
+ var cut = !move.Rapid && move.Layer is LayerType.Cut or LayerType.Display
+ && move.Length > PostVerificationGeometry.Epsilon;
+ if (cut && !cutting)
+ count++;
+ cutting = cut;
+ }
+ return execution.Motions[motion].Layer == LayerType.Leadout ? System.Math.Max(count, 1) : count + 1;
+ }
+
+ private static bool IsInvalid(Exception exception) => exception is
+ ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
+}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs
index 961fac34..6b3bfadd 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs
@@ -10,11 +10,28 @@ namespace OpenNest.CNC.CuttingPlanning;
/// A complete unsafe result is distinct from an incomplete/unsupported check.
public sealed record LeadPathValidationResult(bool IsComplete, bool IsClear, string Reason);
+///
+/// One lead motion of a fixed emission that failed validation. indexes the
+/// other materials passed to the check, or is null when the lead meets its own part's material
+/// or could not be checked.
+///
+internal sealed record FixedLeadFailure(int Motion, LeadPathValidationResult Result, int? Obstacle);
+
/// Certifies actual emitted native lead paths against owned nominal material.
public static class LeadPathValidator
{
public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
- IReadOnlyList otherMaterials, CancellationToken token = default)
+ IReadOnlyList otherMaterials, CancellationToken token = default) =>
+ Check(execution, target, otherMaterials, null, null, token);
+
+ ///
+ /// As the public check, but a lead motion that owns (by motion
+ /// index) is part of a fixed emission: its failure is added to
+ /// and checking continues. Any other failure is returned exactly as the public check returns it.
+ ///
+ internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
+ IReadOnlyList otherMaterials, Func isFixed,
+ List fixedFailures, CancellationToken token)
{
token.ThrowIfCancellationRequested();
if (execution == null || target == null || otherMaterials == null)
@@ -30,11 +47,25 @@ public static class LeadPathValidator
var move = execution.Motions[i];
if (move.Layer is not (LayerType.Leadin or LayerType.Leadout))
continue;
+ var (failure, obstacle) = CheckMotion(i);
+ if (failure == null)
+ continue;
+ if (isFixed?.Invoke(i) != true || fixedFailures == null)
+ return failure;
+ fixedFailures.Add(new(i, failure, obstacle));
+ }
+ // 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);
+
+ (LeadPathValidationResult Failure, int? Obstacle) CheckMotion(int i)
+ {
+ var move = execution.Motions[i];
if (move.Rapid || move.Start is not { } start || move.Curve == null
|| !double.IsFinite(move.Length) || move.Length <= PostVerificationGeometry.Epsilon
|| start.DistanceTo(move.Curve.Start) > PostVerificationGeometry.Epsilon
|| move.End.DistanceTo(move.Curve.End) > PostVerificationGeometry.Epsilon)
- return new(false, false, "Lead motion is missing, degenerate or inconsistent.");
+ return (new(false, false, "Lead motion is missing, degenerate or inconsistent."), null);
Vector? allowed = null;
var groupEdge = i;
var step = move.Layer == LayerType.Leadin ? 1 : -1;
@@ -77,23 +108,25 @@ public static class LeadPathValidator
}
}
if (!genuineJoint)
- return new(true, false, "Lead chain has no genuine adjacent target contour entry or exit.");
+ return (new(true, false, "Lead chain has no genuine adjacent target contour entry or exit."), null);
if (allowed is { } jointPoint
&& (move.Layer == LayerType.Leadin ? start : move.End).DistanceTo(jointPoint) <= PostVerificationGeometry.Epsilon)
- return new(true, false, "A positive-length lead returns to its contour joint; contact is not endpoint-only.");
+ return (new(true, false, "A positive-length lead returns to its contour joint; contact is not endpoint-only."), null);
var failure = CheckMaterial(target, allowed);
if (failure != null)
- return new(true, false, $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure}).");
- foreach (var material in otherMaterials)
+ return (new(true, false, $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null);
+ for (var other = 0; other < otherMaterials.Count; other++)
{
token.ThrowIfCancellationRequested();
+ var material = otherMaterials[other];
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.");
+ + "For locked parts, edit the leads or unlock the part before replanning."), other);
}
+ return (null, null);
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint)
{
@@ -113,9 +146,6 @@ public static class LeadPathValidator
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
}
}
- // 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)
{
diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
index c10a81e1..3d97c045 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
@@ -38,7 +38,18 @@ public sealed class ReleasedContourState
/// being open, never become obstacles.
///
public IReadOnlyList Check(OwnedExecution execution, Vector? arrival,
- int partNumber, bool cutoff, CancellationToken token = default)
+ int partNumber, bool cutoff, CancellationToken token = default) =>
+ Check(execution, arrival, partNumber, cutoff, null, token).Strict;
+
+ ///
+ /// As , but a
+ /// finding whose offending motion owns (by motion index) is
+ /// returned apart: a fixed emission is accepted as given and only reported. The checker
+ /// state advances exactly as it would without ownership.
+ ///
+ internal (IReadOnlyList Strict, IReadOnlyList Fixed) Check(
+ OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func isFixed,
+ CancellationToken token)
{
ArgumentNullException.ThrowIfNull(execution);
if (arrival is { } point)
@@ -46,20 +57,25 @@ public sealed class ReleasedContourState
var moves = execution.Motions.ToArray();
moves[0] = moves[0].WithStart(arrival);
var findings = new List();
- AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token);
- return findings.AsReadOnly();
+ var fixedFindings = new List();
+ AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
+ return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
}
internal void AnalyzeMoves(IReadOnlyList moves, bool cutoff,
- List findings, int plate, int part, CancellationToken token)
+ List findings, int plate, int part, CancellationToken token,
+ Func isFixed = null, List fixedFindings = null)
{
var contour = new List();
- var unfinished = new List();
+ var contourFixed = false;
+ var unfinished = new List<(PostVerificationGeometry.Curve[] Curves, bool Fixed)>();
var hasLead = false;
var contourNumber = 0;
- foreach (var move in moves)
+ for (var index = 0; index < moves.Count; index++)
{
token.ThrowIfCancellationRequested();
+ var move = moves[index];
+ var owned = isFixed?.Invoke(index) == true;
if (move.Rapid)
{
Finish();
@@ -76,7 +92,7 @@ public sealed class ReleasedContourState
if (reach.IsClearOf(obstacle.Extent))
continue;
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
- findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
+ Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}.")
{
@@ -84,7 +100,7 @@ public sealed class ReleasedContourState
RapidStart = start,
RapidEnd = move.End,
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token)
- });
+ }, owned);
}
}
else if (move.Layer == LayerType.Leadin)
@@ -97,9 +113,9 @@ public sealed class ReleasedContourState
if (move.Layer == LayerType.Leadout && contour.Count > 0
&& !PostVerificationGeometry.Closed(contour)
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
- findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
+ Add(new(PostVerificationKind.Incomplete, plate, part, null,
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
- "Rapid safety for that contour requires manual review."));
+ "Rapid safety for that contour requires manual review."), owned);
Finish();
hasLead = false;
}
@@ -109,32 +125,45 @@ public sealed class ReleasedContourState
{
contourNumber++;
if (reportMissingLeadIns && !cutoff && !hasLead)
- findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
+ Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
- { Location = move.Curve.Start });
+ { Location = move.Curve.Start }, owned);
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles
// until an actually continuous chain closes.
var previous = unfinished.FindIndex(chain =>
- chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
+ chain.Curves[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
if (previous >= 0)
{
- contour.AddRange(unfinished[previous]);
+ contour.AddRange(unfinished[previous].Curves);
+ contourFixed |= unfinished[previous].Fixed;
unfinished.RemoveAt(previous);
}
}
contour.Add(move.Curve);
+ contourFixed |= owned;
// A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(contour))
Finish();
}
}
Finish();
+ // Only one open fragment per part can be the planner's own certified tab, so a
+ // further fragment that a fixed emission cut makes this uncertainty that emission's.
if (unfinished.Count > 1)
- findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
+ Add(new(PostVerificationKind.Incomplete, plate, part, null,
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
- "they cannot be assumed to be retained by tabs. Review rapid travel manually."));
+ "they cannot be assumed to be retained by tabs. Review rapid travel manually."),
+ unfinished.Any(chain => chain.Fixed));
+
+ void Add(PostVerificationFinding finding, bool fixedEmission)
+ {
+ if (fixedEmission && fixedFindings != null)
+ fixedFindings.Add(finding);
+ else
+ findings.Add(finding);
+ }
void Finish()
{
@@ -145,8 +174,9 @@ public sealed class ReleasedContourState
if (!cutoff && PostVerificationGeometry.Closed(contour))
obstacles.Add(new(part, contourNumber, contour.ToArray()));
else if (!cutoff)
- unfinished.Add(contour.ToArray());
+ unfinished.Add((contour.ToArray(), contourFixed));
contour.Clear();
+ contourFixed = false;
}
}
diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
index 2d7ba3c1..c8d920a6 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
@@ -194,6 +194,8 @@ public static class PostVerificationAnalyzer
plateNumber, partNumber, null, message));
}
cancellationToken.ThrowIfCancellationRequested();
+ // Leads Plan Cutting accepted as given (locked programs) are checked again here.
+ FixedLeadReview.Add(plate, plateNumber, findings, cancellationToken);
var overlap = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(materialParts, (clamps ?? Array.Empty()).ToArray(),
cancellationToken),
diff --git a/OpenNest.Core/Diagnostics/PostVerificationReport.cs b/OpenNest.Core/Diagnostics/PostVerificationReport.cs
index 8b1e6b3a..32a39a8f 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationReport.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationReport.cs
@@ -11,7 +11,13 @@ public enum PostVerificationKind
Overlap,
MissingLeadIn,
RapidCrossing,
- Incomplete
+ Incomplete,
+
+ ///
+ /// A violation inside a fixed emission (a locked program, or a contour cut exactly as its
+ /// drawing pins it): accepted as given, never repaired, always reported for review.
+ ///
+ FixedLead
}
/// Plate and part numbers are one-based; plate zero denotes a whole-post limitation.
@@ -50,6 +56,9 @@ public sealed class PostVerificationReport
Summary(PostVerificationKind.Overlap, "Overlap");
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
Summary(PostVerificationKind.RapidCrossing, "Rapid crossings");
+ // Only nests with fixed-lead findings gain the line, so other reports read as before.
+ if (Findings.Any(finding => finding.Kind == PostVerificationKind.FixedLead))
+ Summary(PostVerificationKind.FixedLead, "Fixed leads");
foreach (var finding in Findings)
{
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
index aea9df1e..579dfdbc 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
@@ -169,7 +169,9 @@ public sealed class CuttingPlanBatch
&& snapshot.ExpansionBudget > firstPassBudget)
{
var first = CuttingPlanService.Plan(snapshot.WithSearchLimits(firstPassBudget, FirstPassEntries), token);
- if ((first.Status == CuttingPlanStatus.Ready && first.IndependentlyReplayed)
+ // A replayed proposal whose only warnings are fixed emissions passed every strict
+ // check; fixed emissions never steer the search, so the full pass cannot improve it.
+ if (first.IndependentlyReplayed && first.Status is CuttingPlanStatus.Ready or CuttingPlanStatus.BestEffort
|| first.Status == CuttingPlanStatus.Cancelled)
return first;
}
@@ -229,6 +231,13 @@ public sealed class CuttingPlanPlateResult
/// Owned readable output is available, but not all geometric checks passed.
public bool CanApplyWithWarnings => Overlap != null && Overlap.Pairs.Count == 0
&& (IsRouteReady || Result.Status == CuttingPlanStatus.BestEffort);
+
+ ///
+ /// The route was replayed and passed every strict check; only fixed emissions (locked or
+ /// pinned leads) carry warnings, so the proposal is UNVERIFIED and needs acceptance.
+ ///
+ public bool HasOnlyFixedLeadWarnings => Result.Status == CuttingPlanStatus.BestEffort
+ && Result.IndependentlyReplayed && IsOverlapClear;
public int PartCount => Result.ProposedOrder.Count;
public int RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated);
@@ -372,7 +381,10 @@ public sealed class CuttingPlanProposal
+ $"{Count(Plates.Count(p => p.OrderChanged), "plate")} with a new part order, "
+ $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated.");
else if (RequiresWarningAcceptance)
- lines.Add("Best-effort plan available. Review the warnings and accept the unverified plan to apply. "
+ lines.Add((Plates.All(p => p.IsReady || p.HasOnlyFixedLeadWarnings)
+ ? "Unverified plan available: fixed lead-ins could not be certified. "
+ : "Best-effort plan available. ")
+ + "Review the warnings and accept the unverified plan to apply. "
+ "Posting CNC output still requires its own review and approval.");
else
lines.Add($"Apply is unavailable: {blocked} of {Count(Plates.Count, "plate")} could not "
@@ -409,8 +421,11 @@ public sealed class CuttingPlanProposal
if (plate.CanApplyWithWarnings)
{
- yield return heading + $"best-effort, unverified. {Count(plate.PartCount, "part")}, "
- + $"{plate.RegeneratedCount} regenerated; review lead-ins and cutting order.";
+ yield return heading + (plate.HasOnlyFixedLeadWarnings
+ ? $"unverified: fixed lead-ins need review. {Count(plate.PartCount, "part")}, "
+ + $"{plate.RegeneratedCount} regenerated; every planned motion passed its checks."
+ : $"best-effort, unverified. {Count(plate.PartCount, "part")}, "
+ + $"{plate.RegeneratedCount} regenerated; review lead-ins and cutting order.");
// Unverified Apply requires the operator to see every finding, not just a
// bounded diagnostic summary that hides later parts behind "... and N more".
foreach (var line in DescribeOverlap(plate.Overlap).Concat(result.Findings.Select(DescribeFinding)))
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
index c07ee0c5..02cb44fc 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
@@ -130,6 +130,20 @@ public sealed class FixedProgramPlacement
public Program CopyProgram() => program == null ? null : OwnedProgramCopy.Copy(program);
internal SelectedContourProgram SelectedProgram { get; private init; }
+ ///
+ /// The motions of (this placement's own program read with its
+ /// incoming rapid first) that are a fixed emission, accepted as given in regeneration mode:
+ /// everything a locked program does after the planner's rapid into it. Null when nothing is
+ /// fixed. Cutoffs and unlocked programs a caller left ineligible keep their strict checks.
+ ///
+ internal Func FixedMotions(OwnedExecution execution)
+ {
+ if (IsCutOff || Prepared != null || !LeadInsLocked)
+ return null;
+ var opensWithRapid = execution.Motions.Count != 0 && execution.Motions[0].Rapid;
+ return motion => motion > 0 || !opensWithRapid;
+ }
+
internal FixedProgramPlacement Propose(Program proposed, OwnedExecution execution, IReadOnlyList choices,
System.Threading.CancellationToken token = default)
{
@@ -187,6 +201,12 @@ public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart,
///
public PostVerificationFinding Witness { get; init; }
+ ///
+ /// For a warning, what the fixed emission is and
+ /// what it hits; null otherwise. Not part of equality (the message already names both).
+ ///
+ public FixedLeadWarning FixedLead { get; init; }
+
public bool Equals(CuttingPlanFinding other) => other is not null
&& SourceOrdinal == other.SourceOrdinal && Equals(SourcePart, other.SourcePart)
&& OtherSourceOrdinal == other.OtherSourceOrdinal && Equals(OtherSourcePart, other.OtherSourcePart)
@@ -196,11 +216,23 @@ public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart,
HashCode.Combine(SourceOrdinal, SourcePart, OtherSourceOrdinal, OtherSourcePart, Kind, Message);
}
+///
+/// A violation inside a fixed emission, accepted as given rather than refused (D1a); the part is
+/// the finding's source part. is one-based in the part's program
+/// (null when the whole program is concerned); is the one-based drawing
+/// pin, or null for a locked program; is the other part hit,
+/// or null for the part's own material, a released contour named in the message, or an
+/// uncertifiable gap.
+///
+public sealed record FixedLeadWarning(int? ContourNumber, int? PinNumber, int? ObstacleSourceOrdinal);
+
///
/// A replayed direct-XY proposal, optionally with regenerated programs, that respects the
/// captured cutoff/containment prerequisites. Not physical safety or posting consent. Apply
/// installs it only for a plate-scoped request whose plate is unchanged. BestEffort is explicitly
-/// unverified and requires batch warning acceptance; other failures contain no proposals.
+/// unverified and requires batch warning acceptance; other failures contain no proposals. A
+/// BestEffort result that is also passed every strict check
+/// except inside fixed emissions, whose violations are its FixedLead warnings.
///
public sealed class CuttingPlanResult
{
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
index f03d4035..3a86e20b 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
@@ -245,6 +245,11 @@ public static class CuttingPlanService
token.ThrowIfCancellationRequested();
if (placement.Prepared != null)
continue; // An eligible old crossing is precisely what regeneration may repair.
+ // With confirmed parameters a locked program is accepted as given: replay reports
+ // its internal violations as warnings (D1a). The no-parameter route and unlocked
+ // programs left ineligible keep refusing them.
+ if (snapshot.Regeneration && placement.FixedMotions(placement.Execution) != null)
+ continue;
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
var findings = new ReleasedContourState(reportMissingLeadIns: false).Check(placement.Execution, null,
placement.SourceOrdinal + 1, placement.IsCutOff, token);
@@ -322,7 +327,10 @@ public static class CuttingPlanService
var position = snapshot.StartPoint;
var distance = 0.0;
var findings = new List();
- var materials = snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
+ // Violations inside fixed emissions: accepted as given, reported, never refused (D1a).
+ var warnings = new List();
+ var materialOwners = snapshot.Placements.Where(p => !p.IsCutOff).ToArray();
+ var materials = materialOwners.Select(p => p.Material).ToArray();
foreach (var proposal in order)
{
token.ThrowIfCancellationRequested();
@@ -356,14 +364,23 @@ public static class CuttingPlanService
return new(CuttingPlanStatus.UnsupportedGeometry,
findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions);
}
- findings.AddRange(JointCuttingPlanSearch.Map(snapshot,
- checker.Check(execution, position, source.SourceOrdinal + 1, source.IsCutOff, token)));
+ var isFixed = source.FixedMotions(execution);
+ var (strict, fixedFindings) = checker.Check(execution, position, source.SourceOrdinal + 1,
+ source.IsCutOff, isFixed, token);
+ findings.AddRange(JointCuttingPlanSearch.Map(snapshot, strict));
+ warnings.AddRange(fixedFindings.Select(f => FixedLeadWarnings.FromChecker(snapshot, source, f)));
+ var fixedLeads = new List();
var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
- : LeadPathValidator.Check(execution, source.Material, materials, token);
+ : LeadPathValidator.Check(execution, source.Material, materials, isFixed, fixedLeads, token);
if (!lead.IsComplete || !lead.IsClear)
findings.Add(JointCuttingPlanSearch.Finding(source,
lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
- if (!complete && findings.Count == 0)
+ warnings.AddRange(fixedLeads.Select(f => FixedLeadWarnings.FromLead(source, execution, f, materialOwners)));
+ // A locked program's coverage (a tab gap, a partial or reversed cut) has no certified
+ // selection to compare with; a regenerated program's never escapes the strict rule.
+ if (!complete && source.Prepared == null && isFixed != null)
+ warnings.Add(FixedLeadWarnings.Uncertified(source));
+ else if (!complete && findings.Count == 0)
return new(CuttingPlanStatus.InvalidInput,
findings: [JointCuttingPlanSearch.Finding(source, null,
"Selected program does not preserve complete directed contours, certified tabs or selected entry/style geometry.")],
@@ -376,6 +393,11 @@ public static class CuttingPlanService
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
findings: findings, expansions: expansions);
+ // Every planner-chosen motion passed. Fixed-emission warnings make the proposal
+ // UNVERIFIED: never Ready, applicable only through explicit warning acceptance.
+ if (warnings.Count != 0)
+ return new(CuttingPlanStatus.BestEffort, order, warnings.Distinct(), expansions,
+ distance, independentlyReplayed: true);
return new(CuttingPlanStatus.Ready, order, expansions: expansions,
rapidDistance: distance, independentlyReplayed: true);
}
diff --git a/OpenNest.Engine/CuttingPlanning/FixedLeadWarnings.cs b/OpenNest.Engine/CuttingPlanning/FixedLeadWarnings.cs
new file mode 100644
index 00000000..f6157b78
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/FixedLeadWarnings.cs
@@ -0,0 +1,58 @@
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Diagnostics;
+
+namespace OpenNest.Engine.CuttingPlanning;
+
+///
+/// Turns violations inside a fixed emission into named
+/// warnings (D1a). The emission is kept exactly as given; only the operator's review can accept it.
+///
+internal static class FixedLeadWarnings
+{
+ private const string LockedAdvice = "The locked program is kept as given. Increase spacing, or edit or "
+ + "unlock this part's lead-ins and replan.";
+
+ /// A replay-checker finding (rapid crossing or retention) inside the fixed emission.
+ internal static CuttingPlanFinding FromChecker(CuttingPlanSnapshot snapshot, FixedProgramPlacement source,
+ PostVerificationFinding finding)
+ {
+ var other = finding.OtherPartNumber is int part ? snapshot.Placements[part - 1] : null;
+ return new(source.SourceOrdinal, source.SourcePart, other?.SourceOrdinal, other?.SourcePart,
+ PostVerificationKind.FixedLead, $"Locked lead-ins: {finding.Message} {LockedAdvice}")
+ {
+ Witness = finding,
+ FixedLead = new(null, null, other?.SourceOrdinal)
+ };
+ }
+
+ /// A lead motion of the fixed emission that meets material or could not be certified.
+ internal static CuttingPlanFinding FromLead(FixedProgramPlacement source, OwnedExecution execution,
+ FixedLeadFailure failure, IReadOnlyList materialOwners)
+ {
+ var contour = FixedLeadReview.ContourAt(execution, failure.Motion);
+ var obstacle = failure.Obstacle is int index ? materialOwners[index] : null;
+ var what = !failure.Result.IsComplete
+ ? $"could not be checked ({failure.Result.Reason})"
+ : obstacle != null ? "contacts or enters another part's material"
+ : $"contacts or enters its own part's material ({failure.Result.Reason})";
+ var motion = execution.Motions[failure.Motion];
+ return new(source.SourceOrdinal, source.SourcePart, obstacle?.SourceOrdinal, obstacle?.SourcePart,
+ PostVerificationKind.FixedLead, $"Locked lead on cutting contour {contour} {what}. {LockedAdvice}")
+ {
+ Witness = new(PostVerificationKind.FixedLead, 1, source.SourceOrdinal + 1,
+ obstacle == null ? null : obstacle.SourceOrdinal + 1, failure.Result.Reason ?? what)
+ { Location = motion.Curve?.Midpoint ?? motion.End },
+ FixedLead = new(contour, null, obstacle?.SourceOrdinal)
+ };
+ }
+
+ /// A fixed program whose contour coverage (for example a tab gap) cannot be certified.
+ internal static CuttingPlanFinding Uncertified(FixedProgramPlacement source) =>
+ new(source.SourceOrdinal, source.SourcePart, null, null, PostVerificationKind.FixedLead,
+ "Locked lead-ins: the program does not cut every closed contour of its drawing exactly once "
+ + "(for example, it leaves a tab gap), so its retention cannot be certified. " + LockedAdvice)
+ { FixedLead = new(null, null, null) };
+}
diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index 00433724..5e40aafd 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -731,12 +731,16 @@ internal static class JointCuttingPlanSearch
private bool Check(FixedProgramPlacement source, OwnedExecution execution, Vector arrival,
ReleasedContourState checker, Attempt attempt)
{
- var findings = checker.Check(execution, arrival, source.SourceOrdinal + 1, source.IsCutOff, token);
+ // A fixed emission is accepted as given and never constrains the search: its own
+ // violations neither reject this candidate nor teach an order rule. Final replay
+ // reports them as warnings. Every planner-chosen motion stays strict.
+ var isFixed = source.FixedMotions(execution);
+ var findings = checker.Check(execution, arrival, source.SourceOrdinal + 1, source.IsCutOff, isFixed, token).Strict;
rejected.AddRange(Map(snapshot, findings));
attempt.NoteCrossings(source.SourceOrdinal, findings);
// A fixed cutoff has no material or leads to certify; its rapids are still checked.
var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
- : LeadPathValidator.Check(execution, source.Material, materials, token);
+ : LeadPathValidator.Check(execution, source.Material, materials, isFixed, [], token);
if (!lead.IsComplete || !lead.IsClear)
rejected.Add(Finding(source, lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
return findings.Count == 0 && lead.IsComplete && lead.IsClear;
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
index d0b63297..ba25dfce 100644
--- a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
@@ -44,26 +44,24 @@ public class CuttingPlanBatchTests
{
var nest = new Nest();
var ready = Plate(nest, Clean("open", 1, 1));
- var locked = Clean("locked", 1, 1);
- 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);
+ // Overlapping material is a hard block (a locked program's own violations only warn).
+ var blocked = Plate(nest, Clean("first", 1, 1), Clean("second", 6, 6));
var readyProgram = ready.Parts[0].Program;
var proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(),
preservePartOrder: false, plateNumbers: [3, 7]).Plan();
Assert.False(proposal.CanApply);
+ Assert.False(proposal.CanApplyWithWarnings);
Assert.True(proposal.Plates[0].IsReady);
var refused = proposal.Plates[1];
- Assert.Equal(CuttingPlanStatus.ConstraintConflict, refused.Result.Status);
+ Assert.False(refused.CanApplyWithWarnings);
Assert.False(refused.KeptCurrentOrder); // Only a budget-exhausted search is retried.
var text = string.Join("\n", proposal.Describe("in"));
Assert.StartsWith("Apply is unavailable: 1 of 2 plates", text);
Assert.Contains("Plate 3: ready.", text);
Assert.Contains("Plate 7: blocked", text);
- Assert.Contains("- Part 1 (locked):", text);
+ Assert.Contains("- Part 1 (first) overlaps part 2 (second).", text);
var commit = proposal.Apply();
@@ -315,16 +313,12 @@ public class CuttingPlanBatchTests
{
var nest = new Nest();
var ready = Plate(nest, Clean("a", 1, 1));
- var locked = Clean("locked", 1, 1);
- 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 blocked = Plate(nest, Clean("first", 1, 1), Clean("second", 6, 6));
var proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(), false).Plan();
Assert.True(proposal.Plates[0].IsReady);
- Assert.False(proposal.Plates[1].IsReady);
+ Assert.False(proposal.Plates[1].CanApplyWithWarnings);
- // A refused plate may hold graphs that are unsafe to copy, so it is never previewed.
+ // A blocked plate may hold graphs that are unsafe to copy, so it is never previewed.
Assert.Null(proposal.BuildPreview(1));
Assert.NotNull(proposal.BuildPreview(0));
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs
index d13d5dd7..074d8e56 100644
--- a/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs
@@ -94,7 +94,7 @@ public class CuttingPlanMessageTests
Assert.Same(program, part.Program);
}
[Fact]
- public void Describe_LeadHitsNeighbour_SuggestsSpacingOrShorterLeadWithoutAllowingApply()
+ public void Describe_LockedLeadHitsNeighbour_WarnsWithAdviceAndNeedsAcceptance()
{
var settings = ExplicitContourTests.Parameters();
var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
@@ -110,11 +110,14 @@ public class CuttingPlanMessageTests
var proposal = CuttingPlanBatch.Capture([plate], settings, true).Plan();
var text = string.Join("\n", proposal.Describe("in"));
+ // D1a: the locked lead is a named warning on an UNVERIFIED plate, not a refusal.
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.True(proposal.RequiresWarningAcceptance);
+ Assert.StartsWith("Unverified plan available: fixed lead-ins could not be certified.", text);
+ Assert.Contains("Plate 1: unverified: fixed lead-ins need review.", text);
+ Assert.Contains("- Part 1 (lead blocked): Locked lead on cutting contour 1 contacts or enters another "
+ + "part's material. The locked program is kept as given. Increase spacing, or edit or unlock "
+ + "this part's lead-ins and replan. (with part 2 (neighbour))", text);
Assert.DoesNotContain("other than None", text);
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
Assert.True(ProgramContent.Equal(original, part.Program));
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingReplayContractTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingReplayContractTests.cs
index a7ee1c80..2a9883ef 100644
--- a/OpenNest.Tests/CuttingPlanning/CuttingReplayContractTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/CuttingReplayContractTests.cs
@@ -72,7 +72,19 @@ public class CuttingReplayContractTests
var result = CuttingPlanService.Plan(snapshot);
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
Assert.NotEqual(CuttingPlanStatus.Ready, CuttingPlanService.ReplayPrograms(snapshot, snapshot.Placements, 0, default).Status);
- Assert.Empty(result.ProposedOrder);
+ if (locked)
+ {
+ // D1a: a locked program is kept as given; its uncertifiable coverage is a named
+ // warning on an UNVERIFIED proposal, never repaired and never Ready.
+ Assert.Equal(CuttingPlanStatus.BestEffort, result.Status);
+ Assert.True(result.IndependentlyReplayed);
+ var warning = Assert.Single(result.Findings);
+ Assert.Equal(PostVerificationKind.FixedLead, warning.Kind);
+ Assert.Contains("retention cannot be certified", warning.Message);
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(Assert.Single(result.ProposedOrder).CopyProgram()));
+ }
+ else
+ Assert.Empty(result.ProposedOrder);
Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program));
}
diff --git a/OpenNest.Tests/CuttingPlanning/FixedLeadWarningTests.cs b/OpenNest.Tests/CuttingPlanning/FixedLeadWarningTests.cs
new file mode 100644
index 00000000..aa12ae29
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/FixedLeadWarningTests.cs
@@ -0,0 +1,208 @@
+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;
+
+///
+/// D1a: a violation inside a fixed emission (a locked program) is a named warning on an
+/// UNVERIFIED proposal. Every planner-chosen motion keeps its strict checks, and hard blocks
+/// (overlap, malformed programs) still refuse.
+///
+public class FixedLeadWarningTests
+{
+ [Fact]
+ public void LockedHoleLeadIntoNestedPart_IsAnUnverifiedProposalWithEveryOtherPartPlanned()
+ {
+ var (plate, host, nested, nest) = PlanningCharacterizationTests.LockedHoleLeadFixture();
+ var hostProgram = host.Program;
+
+ var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
+
+ var planned = Assert.Single(proposal.Plates);
+ var result = planned.Result;
+ Assert.Equal(CuttingPlanStatus.BestEffort, result.Status);
+ Assert.True(result.IndependentlyReplayed);
+ Assert.False(planned.IsReady);
+ Assert.True(planned.CanApplyWithWarnings);
+ Assert.True(planned.HasOnlyFixedLeadWarnings);
+ Assert.False(proposal.CanApply);
+ Assert.True(proposal.RequiresWarningAcceptance);
+ // The nested part is regenerated and strictly planned; only the locked host warns.
+ Assert.Equal([nested, host], result.ProposedOrder.Select(p => p.SourcePart));
+ Assert.True(result.ProposedOrder[0].IsRegenerated);
+ Assert.False(result.ProposedOrder[1].IsRegenerated);
+ var warning = Assert.Single(result.Findings);
+ Assert.Equal(PostVerificationKind.FixedLead, warning.Kind);
+ Assert.Same(host, warning.SourcePart);
+ Assert.Same(nested, warning.OtherSourcePart);
+ Assert.Equal(new FixedLeadWarning(1, null, 0), warning.FixedLead);
+ Assert.Contains("Locked lead on cutting contour 1 contacts or enters another part's material", warning.Message);
+ Assert.NotNull(warning.Witness?.Location);
+ var row = Assert.Single(proposal.DiagnosticRows());
+ Assert.Equal((2, 1), (row.PartNumber, row.OtherPartNumber));
+
+ // CuttingPlanService.Apply keeps refusing unverified results; only acceptance applies.
+ Assert.Equal(CuttingCommitStatus.InvalidInput, CuttingPlanService.Apply([result]).Status);
+ Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
+ Assert.Same(hostProgram, host.Program);
+ Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: true).Status);
+ Assert.Same(hostProgram, host.Program); // The locked program is never replaced.
+ Assert.True(host.LeadInsLocked);
+ Assert.True(nested.HasManualLeadIns);
+
+ // The same violation reappears in the separate pre-post review.
+ var review = PostVerificationAnalyzer.Analyze(nest);
+ var posted = Assert.Single(review.Findings, f => f.Kind == PostVerificationKind.FixedLead);
+ Assert.Equal((2, 1), (posted.PartNumber, posted.OtherPartNumber));
+ Assert.Contains("Locked lead on cutting contour 1 contacts or enters the material of part 1", posted.Message);
+ Assert.True(review.HasWarnings);
+ Assert.Contains("Fixed leads: 1 warning(s)", review.ToDisplayText());
+ }
+
+ [Fact]
+ public void LockedTabbedPerimeter_IsAWarningNotARefusal()
+ {
+ var (plate, part) = PlanningCharacterizationTests.LockedTabbedFixture();
+ var program = part.Program;
+
+ var planned = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan().Plates[0];
+
+ Assert.True(planned.HasOnlyFixedLeadWarnings);
+ var warning = Assert.Single(planned.Result.Findings);
+ Assert.Equal(PostVerificationKind.FixedLead, warning.Kind);
+ Assert.Contains("retention cannot be certified", warning.Message);
+ Assert.Same(part, Assert.Single(planned.Result.ProposedOrder).SourcePart);
+ Assert.Same(program, part.Program);
+ }
+
+ [Fact]
+ public void RegeneratedHoleLead_AvoidsTheNestedPartAsBefore()
+ {
+ var (plate, host, nested, nest) = PlanningCharacterizationTests.LockedHoleLeadFixture();
+ host.RemoveLeadIns();
+ Assert.False(host.LeadInsLocked);
+
+ var planned = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan().Plates[0];
+
+ Assert.True(planned.IsReady, string.Join("\n", planned.Result.Findings.Select(f => f.Message)));
+ Assert.Empty(planned.Result.Findings);
+ }
+
+ [Fact]
+ public void RegeneratedLeadThatCannotFit_IsStillRefused()
+ {
+ var plate = new Nest().CreatePlate();
+ plate.Parts.Add(new Part(new Drawing("tight", TightHole()), new Vector(1, 1)));
+
+ var planned = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan().Plates[0];
+
+ Assert.False(planned.CanApplyWithWarnings);
+ Assert.Empty(planned.Result.ProposedOrder);
+ Assert.DoesNotContain(planned.Result.Findings, f => f.Kind == PostVerificationKind.FixedLead);
+ Assert.Contains(planned.Result.Findings, f => f.Message.Contains("No tested lead-in fits"));
+ }
+
+ [Fact]
+ public void KnownPartOverlap_StillBlocksAPlateWithLockedWarnings()
+ {
+ var (plate, _, _, _) = PlanningCharacterizationTests.LockedHoleLeadFixture();
+ plate.Parts.Add(new Part(new Drawing("overlapping", LeadPathValidationTests.Rectangle(0, 0, 2, 2)),
+ new Vector(19, 19)));
+
+ var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
+
+ var planned = Assert.Single(proposal.Plates);
+ Assert.NotEmpty(planned.Overlap.Pairs);
+ Assert.False(planned.CanApplyWithWarnings);
+ Assert.False(proposal.CanApplyWithWarnings);
+ Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(acceptWarnings: true).Status);
+ }
+
+ [Fact]
+ public void MalformedLockedProgram_IsStillRefused()
+ {
+ var part = new Part(new Drawing("square", ExplicitContourTests.Square(false)), new Vector(1, 1));
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(0, 10); program.LineTo(10, 10); program.LineTo(10, 0); program.LineTo(0, 0);
+ program.Codes.Add(new LinearMove(double.PositiveInfinity, 0));
+ Assert.True(part.RestoreLeadInProgram(program, true));
+ var plate = new Nest().CreatePlate();
+ plate.Parts.Add(part);
+
+ var planned = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan().Plates[0];
+
+ Assert.False(planned.CanApplyWithWarnings);
+ Assert.Empty(planned.Result.ProposedOrder);
+ Assert.DoesNotContain(planned.Result.Findings, f => f.Kind == PostVerificationKind.FixedLead);
+ }
+
+ [Fact]
+ public void MixedBatch_OneWarnedPlateAndOneReadyPlate_AppliesOnlyWithAcceptance()
+ {
+ var (warned, host, _, _) = PlanningCharacterizationTests.LockedHoleLeadFixture();
+ var ready = new Nest().CreatePlate();
+ ready.Parts.Add(new Part(new Drawing("clean", ExplicitContourTests.Square(false)), new Vector(1, 1)));
+
+ var proposal = CuttingPlanBatch.Capture([warned, ready], ExplicitContourTests.Parameters(), false).Plan();
+
+ Assert.True(proposal.Plates[0].HasOnlyFixedLeadWarnings);
+ Assert.True(proposal.Plates[1].IsReady);
+ Assert.False(proposal.CanApply);
+ Assert.True(proposal.CanApplyWithWarnings);
+ Assert.StartsWith("Unverified plan available", proposal.Describe("in")[0]);
+ Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
+ Assert.False(ready.Parts[0].HasManualLeadIns);
+ Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: true).Status);
+ Assert.True(ready.Parts[0].HasManualLeadIns);
+ Assert.True(host.LeadInsLocked);
+ }
+
+ [Fact]
+ public void PlannerRapidIntoALockedPart_StaysStrict()
+ {
+ // The locked square's own program is clean; only the planner's rapid into it could
+ // cross the regenerated square cut first. A preserved order forces the search to pick
+ // a departure for the first square that does not cross it, so the plate stays Ready.
+ var parameters = ExplicitContourTests.Parameters();
+ var first = new Part(new Drawing("first", ExplicitContourTests.Square(false)), new Vector(0, 0));
+ var locked = new Part(new Drawing("locked", ExplicitContourTests.Square(false)), new Vector(20, 0));
+ var prepared = PreparedContours.Capture(locked.BaseDrawing.Program, parameters);
+ Assert.True(locked.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(-1, 5))]), true));
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([first, locked], new Vector(30, 5),
+ confirmedParameters: parameters, preservePartOrder: true));
+
+ Assert.Equal(CuttingPlanStatus.Ready, result.Status);
+ Assert.Empty(result.Findings);
+ }
+
+ [Fact]
+ public void PrePost_LockedPartWithoutViolations_AddsNoFixedLeadFinding()
+ {
+ var nest = new Nest();
+ var plate = nest.CreatePlate();
+ var part = new Part(new Drawing("square", ExplicitContourTests.Square(false)), new Vector(1, 1));
+ var prepared = PreparedContours.Capture(part.BaseDrawing.Program, ExplicitContourTests.Parameters());
+ Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(-1, 5))]), true));
+ plate.Parts.Add(part);
+
+ var review = PostVerificationAnalyzer.Analyze(nest);
+
+ Assert.DoesNotContain(review.Findings, f => f.Kind is PostVerificationKind.FixedLead or PostVerificationKind.Incomplete);
+ Assert.DoesNotContain("Fixed leads", review.ToDisplayText());
+ }
+
+ // A 10 x 10 square with a 0.1 x 0.1 square hole: no 0.3 line lead fits inside it.
+ private static Program TightHole()
+ {
+ var program = new Program();
+ program.MoveTo(5, 5); program.LineTo(5.1, 5); program.LineTo(5.1, 5.1); program.LineTo(5, 5.1); program.LineTo(5, 5);
+ program.MoveTo(0, 0); program.LineTo(0, 10); program.LineTo(10, 10); program.LineTo(10, 0); program.LineTo(0, 0);
+ return program;
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs b/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs
index a2893254..93c9eddc 100644
--- a/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs
@@ -87,8 +87,22 @@ public class JointCuttingPlanTests
unchanged();
}
+ [Fact]
+ public void JointSearch_LockedInternalCrossing_IsAnUnverifiedWarningWithoutMutating()
+ {
+ var (part, parameters) = Crossing();
+ part.LeadInsLocked = true;
+ var unchanged = Unchanged(part, parameters);
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], confirmedParameters: parameters));
+ Assert.Equal(CuttingPlanStatus.BestEffort, result.Status);
+ Assert.True(result.IndependentlyReplayed);
+ Assert.False(Assert.Single(result.ProposedOrder).IsRegenerated);
+ Assert.All(result.Findings, f => Assert.Equal(PostVerificationKind.FixedLead, f.Kind));
+ Assert.Contains(result.Findings, f => f.Message.Contains("rapid crosses") && f.Witness?.RapidStart != null);
+ unchanged();
+ }
+
[Theory]
- [InlineData("locked")]
[InlineData("ineligible")]
[InlineData("unsupported")]
public void JointSearch_RefusesWithoutInstallingFallbackOrMutating(string fault)
@@ -96,7 +110,6 @@ public class JointCuttingPlanTests
var (part, parameters) = Crossing();
Part[]? eligible = null;
var expected = CuttingPlanStatus.ConstraintConflict;
- if (fault == "locked") part.LeadInsLocked = true;
if (fault == "ineligible") eligible = [];
if (fault == "unsupported")
{
@@ -277,7 +290,7 @@ public class JointCuttingPlanTests
}
[Fact]
- public void FixedLeads_SearchAndReplayRejectNativeTargetMaterialCrossing()
+ public void FixedLeads_NativeTargetMaterialCrossing_IsAcceptedAsGivenAndWarnedByReplay()
{
var parameters = ExplicitContourTests.Parameters();
var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
@@ -289,12 +302,17 @@ public class JointCuttingPlanTests
Assert.True(part.RestoreLeadInProgram(unsafeProgram, true));
Assert.Empty(new ReleasedContourState().Check(Read(unsafeProgram, Vector.Zero), Vector.Zero, 1));
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], confirmedParameters: parameters));
+ // D1a: the locked lead never constrains the search; replay names it as a warning.
var search = JointCuttingPlanSearch.Run(snapshot, default);
- Assert.Equal(CuttingPlanStatus.ConstraintConflict, search.Status);
- Assert.Empty(search.Order);
- var replay = CuttingPlanService.ReplayPrograms(snapshot, snapshot.Placements, 0, default);
- Assert.Equal(CuttingPlanStatus.ConstraintConflict, replay.Status);
- Assert.Contains(replay.Findings, f => f.Message.Contains("target material"));
+ Assert.Equal(CuttingPlanStatus.Ready, search.Status);
+ var replay = CuttingPlanService.ReplayPrograms(snapshot, search.Order, 0, default);
+ Assert.Equal(CuttingPlanStatus.BestEffort, replay.Status);
+ Assert.True(replay.IndependentlyReplayed);
+ var warning = Assert.Single(replay.Findings);
+ Assert.Equal(PostVerificationKind.FixedLead, warning.Kind);
+ Assert.Contains("own part's material", warning.Message);
+ Assert.Contains("target material", warning.Message);
+ Assert.Equal(new FixedLeadWarning(1, null, null), warning.FixedLead);
}
[Fact]
@@ -313,7 +331,7 @@ public class JointCuttingPlanTests
}
[Fact]
- public void FixedLead_SearchChecksOtherUncutHoleAwareMaterial_NotOnlyReleasedContours()
+ public void FixedLead_ReplayChecksOtherUncutHoleAwareMaterial_NotOnlyReleasedContours()
{
var parameters = ExplicitContourTests.Parameters();
var first = SimplePart(Vector.Zero, parameters); first.LeadInsLocked = true;
@@ -327,9 +345,15 @@ public class JointCuttingPlanTests
snapshot.Placements.Select(p => p.Material).ToArray());
Assert.True(direct.IsComplete); Assert.False(direct.IsClear);
Assert.Contains("another placed material", direct.Reason);
- var search = JointCuttingPlanSearch.Run(snapshot, default);
- Assert.Equal(CuttingPlanStatus.ConstraintConflict, search.Status);
- Assert.Contains(search.Findings, f => f.Message.Contains("another placed material"));
+ // The not-yet-cut neighbour is still material: replay names it as the obstacle.
+ var result = CuttingPlanService.Plan(snapshot);
+ Assert.Equal(CuttingPlanStatus.BestEffort, result.Status);
+ Assert.True(result.IndependentlyReplayed);
+ var warning = Assert.Single(result.Findings, f => f.SourceOrdinal == 0);
+ Assert.Equal(PostVerificationKind.FixedLead, warning.Kind);
+ Assert.Same(obstacle, warning.OtherSourcePart);
+ Assert.Equal(new FixedLeadWarning(1, null, 1), warning.FixedLead);
+ Assert.Contains("another part's material", warning.Message);
}
[Fact]
diff --git a/OpenNest.Tests/CuttingPlanning/PlanningCharacterizationTests.cs b/OpenNest.Tests/CuttingPlanning/PlanningCharacterizationTests.cs
index 14c46cd6..4d4ed3b7 100644
--- a/OpenNest.Tests/CuttingPlanning/PlanningCharacterizationTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/PlanningCharacterizationTests.cs
@@ -83,46 +83,19 @@ public class PlanningCharacterizationTests
Check(name, expected, plates, parameters, preserve);
}
- ///
- /// Red row for D1a: a locked hole lead that runs into the material of a part nested in
- /// that hole. Today the plate is refused and offers no proposal.
- ///
- [Fact]
- public void LockedHoleLeadIntoNeighbourMaterial_IsRefusedToday()
- {
- var (plate, _, _) = LockedHoleLeadFixture();
-
- var planned = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan().Plates[0];
-
- Assert.False(planned.CanApplyWithWarnings, Describe(planned));
- Assert.Empty(planned.Result.ProposedOrder);
- Assert.Contains(planned.Result.Findings, f => f.Message.Contains("another placed material"));
- }
-
- /// Red row for D1a: a locked tabbed perimeter has no certified tab and is refused today.
- [Fact]
- public void LockedTabbedPerimeter_IsRefusedToday()
- {
- var (plate, _) = LockedTabbedFixture();
-
- var planned = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan().Plates[0];
-
- Assert.False(planned.CanApplyWithWarnings);
- Assert.Empty(planned.Result.ProposedOrder);
- }
-
///
/// A 20 x 20 host with a 10 x 10 cutout and a 2 x 2 part nested in it. The host is locked
- /// with a hole lead-in that starts inside the nested part's material.
+ /// with a hole lead-in that runs straight through the nested part. Before D1a this plate
+ /// was refused; holds its rows now.
///
- internal static (Plate Plate, Part Host, Part Nested) LockedHoleLeadFixture()
+ internal static (Plate Plate, Part Host, Part Nested, Nest Nest) LockedHoleLeadFixture()
{
var clean = new Program();
clean.MoveTo(5, 5); clean.LineTo(15, 5); clean.LineTo(15, 15); clean.LineTo(5, 15); clean.LineTo(5, 5);
clean.MoveTo(0, 0); clean.LineTo(0, 20); clean.LineTo(20, 20); clean.LineTo(20, 0); clean.LineTo(0, 0);
var host = new Part(new Drawing("host", clean), new Vector(0, 0));
var locked = new Program();
- locked.MoveTo(7, 10);
+ locked.MoveTo(9, 10);
locked.Codes.Add(new LinearMove(5, 10) { Layer = LayerType.Leadin });
locked.LineTo(5, 5); locked.LineTo(15, 5); locked.LineTo(15, 15); locked.LineTo(5, 15); locked.LineTo(5, 10);
locked.MoveTo(-0.3, 10);
@@ -131,7 +104,7 @@ public class PlanningCharacterizationTests
Assert.True(host.RestoreLeadInProgram(locked, true));
var nested = new Part(new Drawing("nested", LeadPathValidationTests.Rectangle(0, 0, 2, 2)), new Vector(6, 9));
var nest = new Nest();
- return (Plate(nest, nested, host), host, nested);
+ return (Plate(nest, nested, host), host, nested, nest);
}
/// A single 10 x 10 square locked with a program that leaves a 0.2 tab at its entry.
@@ -146,9 +119,6 @@ public class PlanningCharacterizationTests
return (Plate(new Nest(), part), part);
}
- private static string Describe(CuttingPlanPlateResult planned) => planned.Result.Status + ": "
- + string.Join("\n", planned.Result.Findings.Select(f => $"{f.SourceOrdinal} {f.Kind} {f.Message}"));
-
internal static void Check(string name, string expected, Plate[] plates, CuttingParameters parameters,
bool preservePartOrder)
{
diff --git a/OpenNest/Forms/CuttingInspectionPanel.cs b/OpenNest/Forms/CuttingInspectionPanel.cs
index a2e034a6..2010349b 100644
--- a/OpenNest/Forms/CuttingInspectionPanel.cs
+++ b/OpenNest/Forms/CuttingInspectionPanel.cs
@@ -468,6 +468,7 @@ internal sealed class CuttingInspectionPanel : UserControl
PostVerificationKind.MissingLeadIn => "Missing lead-in",
PostVerificationKind.RapidCrossing => "Rapid crossing",
PostVerificationKind.Incomplete => "Not checked",
+ PostVerificationKind.FixedLead => "Fixed lead",
_ => kind.ToString(),
};
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index 2f5fd250..c9e29585 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -328,7 +328,8 @@ produce a separate `BestEffort` proposal as described below; it never labels it
sets `IndependentlyReplayed`.
- `ConstraintConflict`: fixed programs or explored fixed routing violate the
- modeled constraints. Locked internal crossings cannot be repaired by regeneration.
+ modeled constraints. Locked internal crossings cannot be repaired by regeneration;
+ with confirmed parameters they warn instead (see [fixed emissions](#fixed-emissions-warn)).
- `UnsupportedGeometry`: unsupported motion/material semantics or an incomplete
check. Open nominal outlines, ambiguous release states or containment, cutoffs
in a detached part list and scribe-only source drawings are not silently accepted.
@@ -341,13 +342,41 @@ sets `IndependentlyReplayed`.
Malformed original executed graphs are refused, not salvaged. Valid but incomplete
old programs can regenerate from clean geometry. Regenerated programs retain genuine
configured tab gaps; stale tab settings do not establish retention. In confirmed-
-parameters mode, locked/ineligible programs must cover the complete directed clean
-boundary: an open fixed program has no certified selected tab metadata and is refused,
-not repaired, even if it may have been intentionally tabbed. The no-parameter route
-retains its narrower compatibility contract. A lead-out that may bridge a tab, or
-a malformed emitted arc, is refused, not automatically repaired. Tabbed lead-outs
-leave from the trimmed cut end, but a lead-out after an open contour still needs
-manual review of its retention gap, so confirmed-parameters planning refuses it.
+parameters mode, an unlocked program a caller left ineligible must cover the complete
+directed clean boundary: an open one has no certified selected tab metadata and is
+refused, not repaired. A locked program that does not (a tab gap, a partial, reversed
+or retraced cut) is kept as given with a fixed-lead warning instead; see below. The
+no-parameter route retains its narrower compatibility contract and still refuses
+fixed-program violations. A planned lead-out that may bridge a tab, or a malformed
+emitted arc, is refused, not automatically repaired. Tabbed lead-outs leave from the
+trimmed cut end, but a lead-out after an open contour still needs manual review of
+its retention gap, so confirmed-parameters planning refuses it in a planned contour.
+
+### Fixed emissions warn
+
+With confirmed parameters, a locked program (Place Lead-in locks, PEP-imported locked
+parts) is a fixed emission: everything it does after the planner's rapid into it is
+accepted as given (user decision D1a, 2026-10-14). Its violations are not refusals:
+
+- Its lead-ins and lead-outs meeting another part's material or its own, a rapid inside
+ it crossing a completed contour, a lead-out after its own open contour, and coverage
+ that cannot be certified (a tab gap, a partial, reversed or retraced cut) each become a
+ `PostVerificationKind.FixedLead` finding naming the part, the cutting contour where
+ known and the other part hit (`CuttingPlanFinding.FixedLead`).
+- Fixed emissions never constrain the search: they neither reject a candidate nor teach
+ an order rule. Independent replay reports them. Every planner-chosen motion keeps
+ every strict check, including the rapid into the locked program and every
+ regenerated lead.
+- A plate whose only findings are these warnings yields `BestEffort` with
+ `IndependentlyReplayed` set (`CuttingPlanPlateResult.HasOnlyFixedLeadWarnings`): never
+ `Ready`, so `IsReady`/`CanApply` stay false and `CanApplyWithWarnings` with the
+ per-proposal acceptance checkbox is the only way to Apply. `CuttingPlanService.Apply`
+ still rejects it. The locked program is never replaced.
+- Known part-to-part material overlap, unreadable or unsupported programs, stale input
+ and cancellation still block. Unlocked programs a caller left ineligible keep the
+ strict rule above.
+- The [pre-post review](post-verification.md) checks locked leads again before posting;
+ accepting the plan is not posting consent.
## Apply
@@ -419,8 +448,9 @@ panel is wired in the editor. Non-editor callers still use the shared analyzer.
- The panel starts from the plate's cutting settings (or the last-used settings) and plans
at once. `Cutting Settings...` edits them and `Keep the current part order` fixes the
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.
+ unlocked part's lead-ins are regenerated. Locked parts keep their exact programs; their
+ own violations are fixed-lead warnings on an UNVERIFIED plate (see
+ [fixed emissions](#fixed-emissions-warn)), and any warnings require explicit review below.
- When a plate in scope already carries applied lead-ins or cut-offs, a red banner
above the settings says how many (and how many lead-ins are locked), and that the
plan keeps locked lead-ins and existing cut-offs unchanged. `Clear Lead-ins &
@@ -557,7 +587,10 @@ dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release --filter 'FullyQuali
```
Synthetic fixtures exercise whole-part routing and internal-hole crossing repair,
-locked/ineligible refusal, actual native lead paths, shared subprograms, ownership,
+ineligible refusal and locked fixed-lead warnings, actual native lead paths, shared subprograms, ownership,
entry/whole-part backtracking, deterministic budgets, cancellation and fresh replay
rejection. Retained emission characterizations cover styles, winding, corner rules,
circle rounding/clamping and tabs; unsupported cases remain explicit refusals.
+`PlanningCharacterizationTests` freeze the batch output (proposals, expansions, emitted
+motions) for the six synthetic nests and focused fixtures; a change that is meant to
+leave plates without fixed-lead warnings untouched must keep those digests.
diff --git a/docs/post-verification.md b/docs/post-verification.md
index 5dc3b3cf..069915bf 100644
--- a/docs/post-verification.md
+++ b/docs/post-verification.md
@@ -54,7 +54,7 @@ shared `PostVerificationAnalyzer` without an editor UI dependency.
The desktop's **Nest > Post > (post processor)** workflow checks every plate after
post settings are accepted and before asking where to save CNC output. The review
-shows all three check categories, even when there are no findings:
+shows the first three check categories, even when there are no findings:
- **Overlapping parts and clamps:** shared material between placed parts, including containment,
and physical material overlapping an active clamp. Holes are subtracted; placing a part inside
@@ -80,6 +80,12 @@ shows all three check categories, even when there are no findings:
A clamp whose footprint covers the program start point warns on every posting
attempt by design; there is no exemption for the first rapid and no persisted
bypass (settled decision D10).
+- **Fixed leads:** on a plate with a locked part, every lead-in and lead-out of each
+ locked program is checked against the clean material of every part on the plate,
+ its own included. Plan Cutting accepts these fixed emissions as given and only warns
+ (see [cutting planner](cutting-planner.md#fixed-emissions-warn)), so posting reports
+ them again. A part whose clean material cannot be captured makes the check incomplete
+ for leads near it. This line appears in the summary only when there is such a finding.
Findings identify the plate and part's position in the cutting sequence. Read the
scrollable report, cancel to fix the nest, then post again to rerun verification.