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