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.