diff --git a/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs b/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs new file mode 100644 index 0000000..c168909 --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Diagnostics; + +namespace OpenNest.Engine.CuttingPlanning; + +/// +/// A deliberately uncertified fallback, not a relaxed safety checker. Reuses closed native +/// contour preparation and emission, retaining source order except for proven prerequisites. +/// Unknown program semantics, invalid motions and unrepresentable contours still refuse. +/// +internal static class BestEffortCuttingPlan +{ + internal static CuttingPlanResult Plan(CuttingPlanSnapshot snapshot, CancellationToken token) + { + if (snapshot.Failure is { } failure) + return new(failure, findings: snapshot.Findings); + if (!snapshot.Regeneration || snapshot.OwnedParameters == null) + return new(CuttingPlanStatus.InvalidInput); + var findings = snapshot.Findings.ToList(); + findings.Add(new(null, null, null, null, PostVerificationKind.Incomplete, + "Best-effort plan: lead clearance, rapid travel and material containment are not certified. " + + "Review the complete plan before cutting. Source contours have not been repaired.")); + var order = new List(); + var done = new HashSet(); + var position = snapshot.StartPoint; + var distance = 0.0; + var checker = new ReleasedContourState(); + try + { + while (order.Count < snapshot.Placements.Count) + { + token.ThrowIfCancellationRequested(); + var source = snapshot.PreservePartOrder ? snapshot.Placements[order.Count] + : snapshot.Placements.FirstOrDefault(p => !done.Contains(p.SourceOrdinal) + && snapshot.Dependencies.IsReady(p.SourceOrdinal, done)); + if (source == null || !snapshot.Dependencies.IsReady(source.SourceOrdinal, done)) + return new(CuttingPlanStatus.ConstraintConflict, findings: findings); + var proposal = source; + if (source.Prepared is { } prepared) + { + var choices = new List(); + var approach = position - source.Location; + for (var contour = 0; contour < prepared.Count; contour++) + { + token.ThrowIfCancellationRequested(); + choices.Add(prepared.ClosestEntry(contour, approach)); + // Prefixes are standalone programs, not relative to the prior prefix. + var prefix = ExecutionMotionReader.ReadSupported(prepared.EmitPrefix(choices), + source.Location, position, token); + approach = prefix.DeparturePoint - source.Location; + } + var program = prepared.Emit(choices); + var emitted = ExecutionMotionReader.ReadSupported(program, source.Location, position, token); + proposal = source.Propose(program, emitted, choices, token); + } + // Read the owned payload again; malformed/nonfinite output never reaches Apply. + var execution = ExecutionMotionReader.ReadSupported(proposal.CopyProgram(), source.Location, position, token); + if (!execution.HasCuttingContour) + throw new ArgumentException("Best-effort output has no cutting contour."); + findings.AddRange(checker.Check(execution, position, source.SourceOrdinal + 1, source.IsCutOff, token) + .Select(f => new CuttingPlanFinding(f.PartNumber is int part ? part - 1 : null, + f.PartNumber is int ordinal ? snapshot.Placements[ordinal - 1].SourcePart : null, + f.OtherPartNumber is int other ? other - 1 : null, + f.OtherPartNumber is int otherOrdinal ? snapshot.Placements[otherOrdinal - 1].SourcePart : null, + f.Kind, f.Message))); + distance += execution.RapidDistanceFrom(position); + if (!double.IsFinite(distance)) + throw new ArgumentException("Best-effort travel is not finite."); + position = execution.DeparturePoint; + done.Add(source.SourceOrdinal); + order.Add(proposal); + } + token.ThrowIfCancellationRequested(); + return new(CuttingPlanStatus.BestEffort, order, findings, rapidDistance: distance) + { Snapshot = snapshot }; + } + catch (OperationCanceledException) + { + return new(CuttingPlanStatus.Cancelled); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException + or InvalidOperationException or ArithmeticException) + { + findings.Add(new(null, null, null, null, PostVerificationKind.Incomplete, exception.Message)); + return new(CuttingPlanStatus.UnsupportedGeometry, findings: findings); + } + } +} diff --git a/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs b/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs index 634ff90..6d03bd6 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs @@ -76,7 +76,7 @@ internal sealed class CuttingDependencyGraph } internal static CuttingDependencyGraph Build(IReadOnlyList nodes, Box plate, - CancellationToken token) + CancellationToken token, Action uncertain = null) { var edges = nodes.Select(_ => new SortedSet()).ToArray(); for (var cut = 0; cut < nodes.Count; cut++) @@ -98,9 +98,24 @@ internal sealed class CuttingDependencyGraph token.ThrowIfCancellationRequested(); // Bounds only select candidates; containment itself is proven on native material. if (inner != host && !nodes[inner].IsCutOff && !nodes[host].IsCutOff - && Within(nodes[inner].CleanBounds, nodes[host].HostBounds) - && InsideCutout(nodes, inner, host, token)) - edges[host].Add(inner); + && Within(nodes[inner].CleanBounds, nodes[host].HostBounds)) + { + try + { + if (InsideCutout(nodes, inner, host, token)) + edges[host].Add(inner); + } + catch (CuttingDependencyException exception) when (uncertain != null + && exception.Status == CuttingPlanStatus.UnsupportedGeometry) + { + uncertain(exception); + } + catch (Exception exception) when (uncertain != null + && exception is ArgumentException or NotSupportedException) + { + uncertain(Ambiguous(inner, host, exception.Message)); + } + } } var graph = new CuttingDependencyGraph(edges.Select(e => e.ToArray()).ToArray()); if (graph.FindCycle() is { } cyclic) diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs index 341d4d3..33fa2f2 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs @@ -99,7 +99,11 @@ public sealed class CuttingPlanBatch var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), token); var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token); - entries[index] = new(plate, plateNumbers?[index] ?? index + 1, reorder, keepOrder, overlap); + var fallback = reorder ?? keepOrder; + if (fallback.Failure == CuttingPlanStatus.UnsupportedGeometry) + fallback = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: confirmedParameters, preservePartOrder: preservePartOrder), token, bestEffort: true); + entries[index] = new(plate, plateNumbers?[index] ?? index + 1, reorder, keepOrder, overlap, fallback); } return new(entries, owned); } @@ -112,7 +116,8 @@ public sealed class CuttingPlanBatch /// Checks and plans every plate from its captured snapshots. Safe on a worker: live plates are /// not read. Overlapping material blocks a plate whatever its route. A free search that ends /// without a complete plan within its budget is retried with the current order; the proposal - /// reports that it kept the order. + /// reports that it kept the order. Incomplete geometry also gets a separately labelled, + /// unverified fallback when supported closed contours can still be emitted. /// public CuttingPlanProposal Plan(IProgress progress = null, CancellationToken token = default) @@ -140,19 +145,31 @@ public sealed class CuttingPlanBatch reorder = CuttingPlanService.Plan(entry.Reorder, token); if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget) { - plans[index] = new(entry.Plate, entry.Number, reorder, null, overlap); + plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap); continue; } } progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.KeepingOrder)); - plans[index] = new(entry.Plate, entry.Number, CuttingPlanService.Plan(entry.KeepOrder, token), reorder, - overlap); + plans[index] = new(entry.Plate, entry.Number, + WithFallback(entry, CuttingPlanService.Plan(entry.KeepOrder, token), token), reorder, overlap); } return new(plans, ownedParameters); } + private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token) + { + if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null) + return strict; + var fallback = BestEffortCuttingPlan.Plan(entry.Fallback, token); + if (fallback.Status != CuttingPlanStatus.BestEffort) + return fallback.Status == CuttingPlanStatus.Cancelled ? fallback : strict; + return new(CuttingPlanStatus.BestEffort, fallback.ProposedOrder, + strict.Findings.Concat(fallback.Findings).Distinct(), rapidDistance: fallback.RapidDistance) + { Snapshot = entry.Fallback }; + } + private sealed record Entry(Plate Plate, int Number, CuttingPlanSnapshot Reorder, CuttingPlanSnapshot KeepOrder, - PlateOverlapSnapshot Overlap); + PlateOverlapSnapshot Overlap, CuttingPlanSnapshot Fallback); } /// One plate's outcome inside a . @@ -188,6 +205,10 @@ public sealed class CuttingPlanPlateResult public bool IsOverlapClear => Overlap is { IsComplete: true } && Overlap.Pairs.Count == 0; public bool IsReady => IsRouteReady && IsOverlapClear; + + /// 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); public int PartCount => Result.ProposedOrder.Count; public int RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated); @@ -196,7 +217,7 @@ public sealed class CuttingPlanPlateResult } /// -/// The outcome of a batch. Apply is all-or-nothing and is offered only when every plate is ready. +/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance. /// public sealed class CuttingPlanProposal { @@ -216,17 +237,26 @@ public sealed class CuttingPlanProposal public bool CanApply => Plates.Count > 0 && !IsCancelled && ownedParameters != null && Plates.All(p => p.IsReady); + /// All plates have usable output, possibly requiring explicit warning acceptance. + public bool CanApplyWithWarnings => Plates.Count > 0 && !IsCancelled && ownedParameters != null + && Plates.All(p => p.CanApplyWithWarnings); + + public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply; + /// /// Installs every plate's replayed proposal through , on the /// thread that owns the plates. Only after every plate is applied does each one keep an owned copy /// of the confirmed parameters as its cutting settings; any other status changes nothing. /// - public CuttingCommitResult Apply(CancellationToken token = default) + public CuttingCommitResult Apply(CancellationToken token = default) => Apply(false, token); + + /// Explicit per-proposal acceptance; never grants consent to post CNC output. + public CuttingCommitResult Apply(bool acceptWarnings, CancellationToken token = default) { - if (!CanApply) + if (!(CanApply || acceptWarnings && CanApplyWithWarnings)) return new(CuttingCommitStatus.InvalidInput, "Every plate must have a ready plan before anything is applied."); - var commit = CuttingPlanService.Apply(Plates.Select(p => p.Result), token); + var commit = CuttingPlanService.Apply(Plates.Select(p => p.Result), token, null, acceptWarnings); if (commit.Status == CuttingCommitStatus.Applied) foreach (var plate in Plates) plate.Plate.CuttingParameters = OwnedCuttingParameters.Copy(ownedParameters); @@ -234,18 +264,17 @@ public sealed class CuttingPlanProposal } /// - /// A detached copy of a ready plate for display, in the proposed order with the proposed - /// programs, or null. A refused plate is not previewed: its program graphs may be unsafe to - /// copy. Neither is a plate that changed after planning, which would draw replayed programs at - /// poses that were never checked. The copy has quantity zero, so drawing quantities stay put. + /// A detached copy of a ready or explicitly unverified proposal for display, or null. + /// Refused graphs are never cloned. Changed plates are not previewed at uncaptured poses. + /// The copy has quantity zero, so drawing quantities stay put. /// Call it on the thread that owns the plates. /// public Plate BuildPreview(int index) { var planned = Plates[index]; - if (!planned.IsReady || planned.Result.Snapshot?.PlateState?.IsCurrent() != true) + if (!planned.CanApplyWithWarnings || planned.Result.Snapshot?.PlateState?.IsCurrent() != true) return null; - // Unchanged since capture: live poses, plate and programs are the validated, replayed ones. + // Unchanged since capture: graphs are supported and the preview uses the exact proposal. var source = planned.Plate; var preview = new Plate(source.Size) { @@ -274,6 +303,9 @@ public sealed class CuttingPlanProposal lines.Add($"Ready to apply to {Count(Plates.Count, "plate")}: " + $"{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. " + + "This is not approval to cut or post CNC output."); else lines.Add($"Apply is unavailable: {Plates.Count - ready} of {Count(Plates.Count, "plate")} could not " + "be planned. No plate changes until every plate is ready."); @@ -307,6 +339,17 @@ public sealed class CuttingPlanProposal yield break; } + if (plate.CanApplyWithWarnings) + { + yield return heading + $"best-effort, unverified. {Count(plate.PartCount, "part")}, " + + $"{plate.RegeneratedCount} regenerated; review lead-ins and cutting order."; + foreach (var line in Limit(DescribeOverlap(plate.Overlap))) + yield return line; + foreach (var line in Limit(result.Findings.Select(DescribeFinding))) + yield return line; + yield break; + } + yield return heading + (plate.IsRouteReady ? "blocked: parts overlap or could not be checked for overlap." : StatusText(result.Status)); diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs index 1e165dc..59092ce 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -60,8 +60,9 @@ public sealed class CuttingPlanSnapshot int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable findings = null, bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16, Action expansionObserver = null, PlateCuttingState plateState = null, - CuttingParameters ownedParameters = null, CuttingDependencyGraph dependencies = null) + CuttingParameters ownedParameters = null, CuttingDependencyGraph dependencies = null, bool bestEffort = false) { + BestEffort = bestEffort; PlateState = plateState; OwnedParameters = ownedParameters; Placements = Array.AsReadOnly(placements.ToArray()); @@ -82,6 +83,7 @@ public sealed class CuttingPlanSnapshot internal PlateCuttingState PlateState { get; } /// Owned copy of the confirmed parameters taken at capture; never the caller's object. internal CuttingParameters OwnedParameters { get; } + internal bool BestEffort { get; } internal bool Regeneration { get; } internal bool PreservePartOrder { get; } internal int MaxEntries { get; } @@ -159,7 +161,9 @@ public enum CuttingPlanStatus UnsupportedGeometry, InvalidInput, NoSolutionWithinBudget, - Cancelled + Cancelled, + /// Usable owned programs, but geometric safety checks did not pass. Never Ready. + BestEffort } /// Ordinals are zero-based source positions, not proposed sequence positions. @@ -169,7 +173,8 @@ public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart, /// /// 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. Failures contain no proposals. +/// 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. /// public sealed class CuttingPlanResult { diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index eea3eac..03b1405 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -15,9 +15,13 @@ public static class CuttingPlanService /// Read stable caller-owned sources once into privately owned programs and geometry. /// No private Plates, settings aliases, quantity updates or source subcall rebinding. /// - public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default) + public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default) => + Capture(request, token, bestEffort: false); + + internal static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token, bool bestEffort) { var placements = new List(); + var warnings = new List(); Part source = null; int? ordinal = null; try @@ -107,7 +111,11 @@ public static class CuttingPlanService { material = LeadMaterialSnapshot.Capture(ownedClean, location, token); if (!material.IsComplete) - throw new NotSupportedException(material.Reason); + { + if (!bestEffort) + throw new NotSupportedException(material.Reason); + warnings.Add(new(index, source, null, null, PostVerificationKind.Incomplete, material.Reason)); + } if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source))) prepared = PreparedContours.Capture(ownedClean, request.ConfirmedParameters, token); } @@ -122,13 +130,16 @@ public static class CuttingPlanService placements[0].Execution.RapidDistanceFrom(request.StartPoint); source = null; ordinal = null; - var dependencies = CuttingDependencyGraph.Build(nodes, request.Plate?.BoundingBox(includeParts: false), token); + var dependencies = CuttingDependencyGraph.Build(nodes, request.Plate?.BoundingBox(includeParts: false), token, + bestEffort ? exception => warnings.Add(new(exception.Ordinal, request.Parts[exception.Ordinal], + exception.Other, exception.Other is int other ? request.Parts[other] : null, + PostVerificationKind.Incomplete, exception.Message)) : null); return new(placements, request.StartPoint, request.ExpansionBudget, regeneration: request.ConfirmedParameters != null, preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries, expansionObserver: request.ExpansionObserver, plateState: plateState, ownedParameters: request.ConfirmedParameters == null ? null : OwnedCuttingParameters.Copy(request.ConfirmedParameters), - dependencies: dependencies); + dependencies: dependencies, findings: warnings, bestEffort: bestEffort); } catch (OperationCanceledException) { @@ -175,14 +186,15 @@ public static class CuttingPlanService // beforeInstall is the commit's install-boundary test seam. internal static CuttingCommitResult Apply(IEnumerable results, CancellationToken token, - Action beforeInstall) + Action beforeInstall, bool acceptUnverified = false) { var plans = new List(); foreach (var result in results ?? []) { var snapshot = result?.Snapshot; - if (result?.Status != CuttingPlanStatus.Ready || !result.IndependentlyReplayed - || snapshot?.PlateState == null || result.ProposedOrder.Count != snapshot.Placements.Count) + var eligible = result != null && (result.Status == CuttingPlanStatus.Ready && result.IndependentlyReplayed + || acceptUnverified && result.Status == CuttingPlanStatus.BestEffort); + if (!eligible || snapshot?.PlateState == null || result.ProposedOrder.Count != snapshot.Placements.Count) return new(CuttingCommitStatus.InvalidInput, "Only Ready, independently replayed plate-scoped proposals can be applied."); var programs = new List(); @@ -211,6 +223,8 @@ public static class CuttingPlanService return new(CuttingPlanStatus.InvalidInput); if (snapshot.Failure is { } failure) return new(failure, findings: snapshot.Findings); + if (snapshot.BestEffort) + return BestEffortCuttingPlan.Plan(snapshot, token); if (snapshot.Placements.Count == 0) return snapshot.PlateState == null ? new(CuttingPlanStatus.InvalidInput) : new(CuttingPlanStatus.Ready, independentlyReplayed: true); // Empty plate: unchanged no-op. diff --git a/OpenNest.Tests/CuttingPlanning/BestEffortCuttingPlanTests.cs b/OpenNest.Tests/CuttingPlanning/BestEffortCuttingPlanTests.cs new file mode 100644 index 0000000..1dbebf8 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/BestEffortCuttingPlanTests.cs @@ -0,0 +1,262 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class BestEffortCuttingPlanTests +{ + [Fact] + public void TouchingContours_OfferAPreviewWithoutChangingTheDrawing() + { + var part = TouchingContours(); + var plate = Plate(part); + var original = OwnedProgramCopy.Copy(part.BaseDrawing.Program); + var strict = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: ExplicitContourTests.Parameters())); + Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, strict.Status); + + var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan(); + + Assert.NotNull(proposal.BuildPreview(0)); + Assert.False(proposal.CanApply); + Assert.False(part.HasManualLeadIns); + Assert.True(ProgramContent.Equal(original, part.BaseDrawing.Program)); + } + + [Fact] + public void AcceptWarnings_InstallsOwnedProgramsAndLeavesStrictApplyClosed() + { + var part = TouchingContours(); + var plate = Plate(part); + var source = OwnedProgramCopy.Copy(part.BaseDrawing.Program); + var pose = (part.Location, part.Rotation); + var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan(); + var result = Assert.Single(proposal.Plates).Result; + Assert.Equal(CuttingPlanStatus.BestEffort, result.Status); + Assert.False(result.IndependentlyReplayed); + Assert.True(proposal.RequiresWarningAcceptance); + Assert.Contains(result.Findings, f => f.Message.Contains("Material boundaries")); + Assert.Contains("unverified", string.Join("\n", proposal.Describe("in"))); + Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status); + Assert.Equal(CuttingCommitStatus.InvalidInput, CuttingPlanService.Apply([result]).Status); + var generated = Assert.Single(result.ProposedOrder); + Assert.True(generated.IsRegenerated); + Assert.Equal(3, generated.ContourChoices.Count); + Assert.Equal(2, generated.ContourChoices[^1].ContourOrdinal); + var originalCuts = ExecutionMotionReader.ReadSupported(source, part.Location, null).Motions + .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Sum(m => m.Length); + var emittedCuts = ExecutionMotionReader.ReadSupported(generated.CopyProgram(), part.Location, null).Motions + .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Sum(m => m.Length); + Assert.Equal(originalCuts, emittedCuts, 8); + var copy = generated.CopyProgram(); + copy.Codes.Clear(); + + Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: true).Status); + + Assert.True(part.HasManualLeadIns); + Assert.True(ProgramContent.Equal(generated.CopyProgram(), part.Program)); + Assert.True(ProgramContent.Equal(source, part.BaseDrawing.Program)); + Assert.Equal(pose, (part.Location, part.Rotation)); + Assert.Equal(CuttingCommitStatus.Stale, proposal.Apply(acceptWarnings: true).Status); + } + + [Theory] + [InlineData("null")] + [InlineData("unknown")] + [InlineData("cycle")] + [InlineData("nonfinite")] + [InlineData("open")] + [InlineData("suppressed")] + public void UnreadableOrUnrepresentableInput_IsNeverWaived(string failure) + { + var part = TouchingContours(); + var plate = Plate(part); + var program = part.BaseDrawing.Program; + switch (failure) + { + case "null": program.Codes = null; break; + case "unknown": program.Codes.Add(new CustomMove()); break; + case "cycle": program.Codes.Add(new SubProgramCall { Program = program }); break; + case "nonfinite": part.Location = new Vector(double.NaN, 1); break; + case "open": program.Codes.RemoveAt(program.Codes.Count - 1); break; + case "suppressed": program.Codes.OfType().First().Suppressed = true; break; + } + var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan(); + Assert.False(proposal.CanApplyWithWarnings); + Assert.Null(proposal.BuildPreview(0)); + Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(acceptWarnings: true).Status); + Assert.False(part.HasManualLeadIns); + } + + [Fact] + public void MixedBatch_StaleOrCancelledChangesNothing() + { + var imperfect = TouchingContours(); + var clean = new Part(new Drawing("clean", ExplicitContourTests.Square(false)), new Vector(1, 1)); + var plates = new[] { Plate(clean), Plate(imperfect) }; + var originals = new[] { clean.Program, imperfect.Program }; + var batch = CuttingPlanBatch.Capture(plates, ExplicitContourTests.Parameters(), false); + var proposal = batch.Plan(); + Assert.True(proposal.RequiresWarningAcceptance); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + Assert.Equal(CuttingCommitStatus.Cancelled, proposal.Apply(true, cancelled.Token).Status); + Assert.False(batch.Plan(token: cancelled.Token).CanApplyWithWarnings); + imperfect.Offset(1, 0); + Assert.Null(proposal.BuildPreview(1)); + Assert.Equal(CuttingCommitStatus.Stale, proposal.Apply(acceptWarnings: true).Status); + Assert.Equal(originals, plates.SelectMany(p => p.Parts).Select(p => p.Program)); + Assert.All(plates, p => Assert.Null(p.CuttingParameters)); + } + + [Fact] + public void LockedPart_KeepsExactProgramWhileOtherPartsRegenerate() + { + var part = TouchingContours(); + part.LeadInsLocked = true; + var program = part.Program; + var other = new Part(new Drawing("clean", ExplicitContourTests.Square(false)), new Vector(20, 1)); + var proposal = CuttingPlanBatch.Capture([Plate(part, other)], ExplicitContourTests.Parameters(), false).Plan(); + Assert.True(proposal.RequiresWarningAcceptance); + Assert.False(proposal.Plates[0].Result.ProposedOrder[0].IsRegenerated); + Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(true).Status); + Assert.Same(program, part.Program); + Assert.True(part.LeadInsLocked); + Assert.True(other.HasManualLeadIns); + } + + [Fact] + public void KnownPartOverlap_StillBlocksTheWholeBatch() + { + var first = new Part(new Drawing("first", ExplicitContourTests.Square(false)), new Vector(1, 1)); + var second = new Part(new Drawing("second", ExplicitContourTests.Square(false)), new Vector(6, 6)); + var proposal = CuttingPlanBatch.Capture([Plate(TouchingContours()), Plate(first, second)], + ExplicitContourTests.Parameters(), false).Plan(); + Assert.False(proposal.CanApplyWithWarnings); + Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(true).Status); + Assert.False(first.HasManualLeadIns); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ProvenCutoffDependencies_AreNotWaived(bool keepOrder) + { + var part = TouchingContours(); + var cutProgram = new Program(); + cutProgram.MoveTo(20, 0); cutProgram.LineTo(20, 20); + var cutoff = new Part(new Drawing("cutoff", cutProgram) { IsCutOff = true }); + var plate = Plate(part, cutoff); // An orphaned cutoff must precede all ordinary parts. + var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), keepOrder).Plan(); + if (keepOrder) + { + Assert.False(proposal.CanApplyWithWarnings); + Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(true).Status); + } + else + { + Assert.True(proposal.CanApplyWithWarnings); + Assert.Same(cutoff, proposal.Plates[0].Result.ProposedOrder[0].SourcePart); + Assert.False(proposal.Plates[0].Result.ProposedOrder[0].IsRegenerated); + } + } + + [Fact] + public void SelfIntersectingClosedContour_StillGetsEveryCutAndALead() + { + var program = new Program(); + program.MoveTo(0, 0); program.LineTo(8, 8); program.LineTo(0, 8); + program.LineTo(8, 0); program.LineTo(0, 0); + var part = new Part(new Drawing("crossed contour", program), new Vector(1, 1)); + var proposal = CuttingPlanBatch.Capture([Plate(part)], ExplicitContourTests.Parameters(), false).Plan(); + Assert.True(proposal.RequiresWarningAcceptance); + var generated = Assert.Single(proposal.Plates[0].Result.ProposedOrder); + Assert.True(generated.IsRegenerated); + Assert.Single(generated.ContourChoices); + var moves = ExecutionMotionReader.ReadSupported(generated.CopyProgram(), part.Location, null).Motions; + Assert.Contains(moves, m => m.Layer == LayerType.Leadin); + var original = ExecutionMotionReader.ReadSupported(program, part.Location, null).Motions; + Assert.Equal(original.Where(m => !m.Rapid).Sum(m => m.Length), + moves.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Sum(m => m.Length), 8); + } + + [Fact] + public void ProvenNestedPart_PrecedesItsHostEvenWithAnImperfectNeighbour() + { + var hostProgram = ExplicitContourTests.Square(false); + hostProgram.MoveTo(2, 2); hostProgram.LineTo(8, 2); hostProgram.LineTo(8, 8); + hostProgram.LineTo(2, 8); hostProgram.LineTo(2, 2); + var host = new Part(new Drawing("host", hostProgram)); + var innerProgram = new Program(); + innerProgram.MoveTo(0, 0); innerProgram.LineTo(0, 1); innerProgram.LineTo(1, 1); + innerProgram.LineTo(1, 0); innerProgram.LineTo(0, 0); + var inner = new Part(new Drawing("insert", innerProgram), new Vector(4, 4)); + var imperfect = TouchingContours(); + imperfect.Offset(20, 0); + var proposal = CuttingPlanBatch.Capture([Plate(host, inner, imperfect)], + ExplicitContourTests.Parameters(), false).Plan(); + Assert.True(proposal.RequiresWarningAcceptance, string.Join("\n", proposal.Describe("in"))); + var order = proposal.Plates[0].Result.ProposedOrder.Select(p => p.SourcePart).ToList(); + Assert.True(order.IndexOf(inner) < order.IndexOf(host)); + } + + [Fact] + public void BestEffortInstallFailure_RollsBackEveryPlate() + { + var parts = new[] { TouchingContours(), TouchingContours() }; + var plates = parts.Select(p => Plate(p)).ToArray(); + var originals = parts.Select(p => p.Program).ToArray(); + var proposal = CuttingPlanBatch.Capture(plates, ExplicitContourTests.Parameters(), false).Plan(); + Assert.True(proposal.RequiresWarningAcceptance); + var commit = CuttingPlanService.Apply(proposal.Plates.Select(p => p.Result), default, + (plate, _) => { if (ReferenceEquals(plate, plates[1])) throw new InvalidOperationException("test install fault"); }, + acceptUnverified: true); + Assert.Equal(CuttingCommitStatus.Failed, commit.Status); + Assert.Equal(originals, parts.Select(p => p.Program)); + Assert.All(parts, p => Assert.False(p.HasManualLeadIns)); + } + + [SkippableTheory] + [InlineData(0)] + [InlineData(45)] + public void RealDxf_PlansAndAppliesWithoutChangingTheDrawing(double degrees) + { + var path = TestConfig.GetExistingPath("BestEffortDxfPath"); + Skip.If(path == null, "BestEffortDxfPath not configured in test-config.json or file not found"); + var drawing = OpenNest.IO.CadImporter.ImportDrawing(path); + var part = new Part(drawing, new Vector(1, 1)); + part.Rotate(degrees * System.Math.PI / 180); + var plate = Plate(part); + var source = OwnedProgramCopy.Copy(drawing.Program); + var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan(); + Assert.True(proposal.CanApplyWithWarnings, string.Join("\n", proposal.Describe("in"))); + Assert.NotNull(proposal.BuildPreview(0)); + Assert.True(Assert.Single(proposal.Plates[0].Result.ProposedOrder).IsRegenerated); + Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(true).Status); + Assert.True(ProgramContent.Equal(source, drawing.Program)); + Assert.True(part.HasManualLeadIns); + } + + private sealed class CustomMove : LinearMove { } + + internal static Part TouchingContours() + { + var program = ExplicitContourTests.Square(false); + // Two ordinary holes share an edge: closed executable contours, ambiguous material. + program.MoveTo(2, 2); program.LineTo(4, 2); program.LineTo(4, 4); + program.LineTo(2, 4); program.LineTo(2, 2); + program.MoveTo(4, 2); program.LineTo(6, 2); program.LineTo(6, 4); + program.LineTo(4, 4); program.LineTo(4, 2); + return new Part(new Drawing("touching contours", program), new Vector(1, 1)); + } + + internal static Plate Plate(params Part[] parts) + { + var plate = new Plate(new Size(100, 100)); + foreach (var part in parts) + plate.Parts.Add(part); + return plate; + } +} diff --git a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs index c405d57..3133776 100644 --- a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs @@ -202,6 +202,41 @@ public class CuttingPlanFormTests Assert.All(first.Parts, part => Assert.False(part.HasManualLeadIns)); }); + [Fact] + public void UnverifiedPlan_RequiresAcceptanceAndReplanClearsIt() => RunSta(() => + { + var program = SquareProgram(); + program.MoveTo(2, 2); program.LineTo(4, 2); program.LineTo(4, 4); + program.LineTo(2, 4); program.LineTo(2, 2); + program.MoveTo(4, 2); program.LineTo(6, 2); program.LineTo(6, 4); + program.LineTo(4, 4); program.LineTo(4, 2); + var part = new Part(new Drawing("touching contours", program), new Vector(1, 1)); + var (nest, view) = CreateView(part); + using var editor = view; + using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters()); + form.Show(); + WaitForPlan(form); + var accept = Control(form, "acceptWarningsCheckBox"); + Assert.True(accept.Visible); + Assert.False(accept.Checked); + Assert.False(Control