From d56f67a99a926c97be271eb973e5c2c87fa4511f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 16 Oct 2026 14:47:56 -0400 Subject: [PATCH] feat(engine): fixed-lead violations warn instead of refusing D1a (user, 2026-10-14): one locked cutout must not refuse the whole layout. With confirmed parameters a locked program is now a fixed emission, accepted as given. The replay checker and the lead validator take a per-motion owner: a violation inside the fixed emission (a lead meeting another part's or its own material, a rapid inside the locked program crossing a cut contour, an uncertifiable tab gap or a partial/reversed/retraced cut) is recorded apart instead of failing the candidate. The search never rejects or learns an order rule from them, so fixed emissions never steer it; the planner's rapid into the locked program and every regenerated lead keep every strict check. Replay turns those violations into typed FixedLead findings that name the part, contour and the part hit. A plate whose only findings are these is BestEffort with IndependentlyReplayed set: never Ready, applicable only with the per-proposal acceptance, and CuttingPlanService.Apply still rejects it. Overlap, unreadable programs, stale input and cancellation still block, and unlocked programs a caller left ineligible and the no-parameter route keep refusing as before. Pre-post review now re-checks locked leads against every part's clean material, so the same violations reappear before posting. Existing tests that encoded the old locked refusal now assert the warning; two batch tests that used a locked crossing as their hard block use an overlap instead. The 5.1 digests are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../CNC/CuttingPlanning/FixedLeadReview.cs | 117 ++++++++++ .../CNC/CuttingPlanning/LeadPathValidator.cs | 52 ++++- .../CuttingPlanning/ReleasedContourState.cs | 64 ++++-- .../Diagnostics/PostVerificationAnalyzer.cs | 2 + .../Diagnostics/PostVerificationReport.cs | 11 +- .../CuttingPlanning/CuttingPlanBatch.cs | 23 +- .../CuttingPlanning/CuttingPlanModels.cs | 34 ++- .../CuttingPlanning/CuttingPlanService.cs | 32 ++- .../CuttingPlanning/FixedLeadWarnings.cs | 58 +++++ .../CuttingPlanning/JointCuttingPlanSearch.cs | 8 +- .../CuttingPlanning/CuttingPlanBatchTests.cs | 22 +- .../CuttingPlanMessageTests.cs | 13 +- .../CuttingReplayContractTests.cs | 14 +- .../CuttingPlanning/FixedLeadWarningTests.cs | 208 ++++++++++++++++++ .../CuttingPlanning/JointCuttingPlanTests.cs | 48 +++- .../PlanningCharacterizationTests.cs | 40 +--- OpenNest/Forms/CuttingInspectionPanel.cs | 1 + docs/cutting-planner.md | 55 ++++- docs/post-verification.md | 8 +- 19 files changed, 690 insertions(+), 120 deletions(-) create mode 100644 OpenNest.Core/CNC/CuttingPlanning/FixedLeadReview.cs create mode 100644 OpenNest.Engine/CuttingPlanning/FixedLeadWarnings.cs create mode 100644 OpenNest.Tests/CuttingPlanning/FixedLeadWarningTests.cs 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.