merge: integrate best-effort cutting on current master

This commit is contained in:
aj committed 2026-10-08 13:34:05 -04:00
commit 81f3c006ab
10 files changed
+572 -49

No files matched your search

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<FixedProgramPlacement>();
var done = new HashSet<int>();
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<ContourChoice>();
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);
}
}
}
@@ -76,7 +76,7 @@ internal sealed class CuttingDependencyGraph
}
internal static CuttingDependencyGraph Build(IReadOnlyList<DependencyNode> nodes, Box plate,
CancellationToken token)
CancellationToken token, Action<CuttingDependencyException> uncertain = null)
{
var edges = nodes.Select(_ => new SortedSet<int>()).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)
@@ -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.
/// </summary>
public CuttingPlanProposal Plan(IProgress<CuttingPlanProgress> 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);
}
/// <summary>One plate's outcome inside a <see cref="CuttingPlanProposal"/>.</summary>
@@ -188,6 +205,10 @@ public sealed class CuttingPlanPlateResult
public bool IsOverlapClear => Overlap is { IsComplete: true } && Overlap.Pairs.Count == 0;
public bool IsReady => IsRouteReady && IsOverlapClear;
/// <summary>Owned readable output is available, but not all geometric checks passed.</summary>
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
}
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>All plates have usable output, possibly requiring explicit warning acceptance.</summary>
public bool CanApplyWithWarnings => Plates.Count > 0 && !IsCancelled && ownedParameters != null
&& Plates.All(p => p.CanApplyWithWarnings);
public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply;
/// <summary>
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, 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.
/// </summary>
public CuttingCommitResult Apply(CancellationToken token = default)
public CuttingCommitResult Apply(CancellationToken token = default) => Apply(false, token);
/// <summary>Explicit per-proposal acceptance; never grants consent to post CNC output.</summary>
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
}
/// <summary>
/// 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.
/// </summary>
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;
}
var missingLead = !plate.IsRouteReady && result.Status != CuttingPlanStatus.Cancelled
&& result.Findings.Any(f => f.Kind == PostVerificationKind.MissingLeadIn);
yield return heading + (plate.IsRouteReady
@@ -60,8 +60,9 @@ public sealed class CuttingPlanSnapshot
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null,
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
Action<int> 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; }
/// <summary>Owned copy of the confirmed parameters taken at capture; never the caller's object.</summary>
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,
/// <summary>Usable owned programs, but geometric safety checks did not pass. Never Ready.</summary>
BestEffort
}
/// <summary>Ordinals are zero-based source positions, not proposed sequence positions.</summary>
@@ -169,7 +173,8 @@ public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart,
/// <summary>
/// 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.
/// </summary>
public sealed class CuttingPlanResult
{
@@ -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.
/// </summary>
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<FixedProgramPlacement>();
var warnings = new List<CuttingPlanFinding>();
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<CuttingPlanResult> results, CancellationToken token,
Action<Plate, Part> beforeInstall)
Action<Plate, Part> beforeInstall, bool acceptUnverified = false)
{
var plans = new List<PlateCuttingPlan>();
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<PlannedPartProgram>();
@@ -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.
@@ -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<Motion>().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;
}
}
@@ -227,6 +227,41 @@ public class CuttingPlanFormTests
Assert.DoesNotContain("Plate 2", text);
});
[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<CheckBox>(form, "acceptWarningsCheckBox");
Assert.True(accept.Visible);
Assert.False(accept.Checked);
Assert.False(Control<Button>(form, "applyButton").Enabled);
Assert.True(Field<PlateView>(form, "preview").Visible);
Assert.Contains("UNVERIFIED", Control<Label>(form, "previewLabel").Text);
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.Null(form.CommitResult);
Assert.False(part.HasManualLeadIns);
accept.Checked = true;
Assert.True(Control<Button>(form, "applyButton").Enabled);
Invoke(form, "PlanButton_Click", null, EventArgs.Empty);
WaitForPlan(form);
Assert.False(accept.Checked);
Assert.False(Control<Button>(form, "applyButton").Enabled);
accept.Checked = true;
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.Equal(CuttingCommitStatus.Applied, form.CommitResult!.Status);
Assert.True(part.HasManualLeadIns);
});
private static (Nest Nest, PlateView View) CreateView(params Part[] parts)
{
var nest = new Nest();
+17 -3
View File
@@ -25,6 +25,7 @@ partial class CuttingPlanForm
settingsButton = new Button();
optionsPanel = new FlowLayoutPanel();
keepOrderCheckBox = new CheckBox();
acceptWarningsCheckBox = new CheckBox();
planButton = new Button();
split = new SplitContainer();
previewLayout = new TableLayoutPanel();
@@ -54,15 +55,17 @@ partial class CuttingPlanForm
layout.Controls.Add(optionsPanel, 0, 1);
layout.Controls.Add(split, 0, 2);
layout.Controls.Add(statusLabel, 0, 3);
layout.Controls.Add(buttonsPanel, 0, 4);
layout.Controls.Add(acceptWarningsCheckBox, 0, 4);
layout.Controls.Add(buttonsPanel, 0, 5);
layout.Dock = DockStyle.Fill;
layout.Name = "layout";
layout.RowCount = 5;
layout.RowCount = 6;
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.TabIndex = 0;
//
// settingsPanel
@@ -190,6 +193,16 @@ partial class CuttingPlanForm
statusLabel.Name = "statusLabel";
statusLabel.TabIndex = 3;
//
// acceptWarningsCheckBox
//
acceptWarningsCheckBox.AutoSize = true;
acceptWarningsCheckBox.Dock = DockStyle.Fill;
acceptWarningsCheckBox.Name = "acceptWarningsCheckBox";
acceptWarningsCheckBox.Text = "I reviewed the warnings. Apply this unverified plan.";
acceptWarningsCheckBox.Visible = false;
acceptWarningsCheckBox.TabIndex = 4;
acceptWarningsCheckBox.CheckedChanged += AcceptWarningsCheckBox_CheckedChanged;
//
// buttonsPanel
//
buttonsPanel.AutoSize = true;
@@ -199,7 +212,7 @@ partial class CuttingPlanForm
buttonsPanel.FlowDirection = FlowDirection.RightToLeft;
buttonsPanel.Margin = new Padding(0, 8, 0, 0);
buttonsPanel.Name = "buttonsPanel";
buttonsPanel.TabIndex = 4;
buttonsPanel.TabIndex = 5;
buttonsPanel.WrapContents = false;
//
// applyButton
@@ -264,6 +277,7 @@ partial class CuttingPlanForm
private Button settingsButton;
private FlowLayoutPanel optionsPanel;
private CheckBox keepOrderCheckBox;
private CheckBox acceptWarningsCheckBox;
private Button planButton;
private SplitContainer split;
private TableLayoutPanel previewLayout;
+17 -4
View File
@@ -241,7 +241,11 @@ public partial class CuttingPlanForm : Form
{
proposal = result;
summaryBox.Text = string.Join(Environment.NewLine, result.Describe(unit));
statusLabel.Text = result.CanApply ? "Review the plan, then apply it." : "Nothing can be applied.";
acceptWarningsCheckBox.Checked = false;
acceptWarningsCheckBox.Visible = result.RequiresWarningAcceptance;
statusLabel.Text = result.CanApply ? "Review the plan, then apply it."
: result.RequiresWarningAcceptance ? "Unverified plan: review and accept the warnings to apply."
: "Nothing can be applied.";
applyButton.Enabled = result.CanApply;
var index = Array.IndexOf(plates, activePlate);
var plate = index < 0 ? null : result.BuildPreview(index);
@@ -252,7 +256,7 @@ public partial class CuttingPlanForm : Form
+ "match the editor.";
return;
}
previewLabel.Text = previewText;
previewLabel.Text = result.Plates[index].IsReady ? previewText : "UNVERIFIED — " + previewText;
preview.Plate = plate;
preview.Visible = true;
preview.ZoomToFit();
@@ -261,6 +265,8 @@ public partial class CuttingPlanForm : Form
private void ClearProposal()
{
proposal = null;
acceptWarningsCheckBox.Checked = false;
acceptWarningsCheckBox.Visible = false;
applyButton.Enabled = false;
preview.Visible = false;
previewLabel.Text = previewText;
@@ -277,6 +283,7 @@ public partial class CuttingPlanForm : Form
{
settingsButton.Enabled = !running;
keepOrderCheckBox.Enabled = !running;
acceptWarningsCheckBox.Enabled = !running;
planButton.Enabled = !running;
if (running)
applyButton.Enabled = false;
@@ -334,9 +341,15 @@ public partial class CuttingPlanForm : Form
public void Report(CuttingPlanProgress value) => context.Post(_ => handler(value), null);
}
private bool CanApplyProposal => proposal?.CanApply == true
|| proposal?.CanApplyWithWarnings == true && acceptWarningsCheckBox.Checked;
private void AcceptWarningsCheckBox_CheckedChanged(object sender, EventArgs e) =>
applyButton.Enabled = planning == null && CanApplyProposal;
private void ApplyButton_Click(object sender, EventArgs e)
{
if (planning != null || proposal?.CanApply != true)
if (planning != null || !CanApplyProposal)
return;
if (!TryCheckCanChange(out var reason))
{
@@ -344,7 +357,7 @@ public partial class CuttingPlanForm : Form
return;
}
var commit = proposal.Apply();
var commit = proposal.Apply(acceptWarningsCheckBox.Checked);
CommitResult = commit;
switch (commit.Status)
{
+42 -12
View File
@@ -219,7 +219,9 @@ against owned clean material. Neither mode certifies final NC, production cuttin
readiness or physical machine safety.
Findings and source ordinals use the original zero-based source list, not proposed
sequence positions. A non-ready result contains no proposed order or unsafe fallback.
sequence positions. Strict service refusals contain no proposed order. The desktop batch may
produce a separate `BestEffort` proposal as described below; it never labels it `Ready` or
sets `IndependentlyReplayed`.
- `ConstraintConflict`: fixed programs or explored fixed routing violate the
modeled constraints. Locked internal crossings cannot be repaired by regeneration.
@@ -293,8 +295,8 @@ plans every plate that has parts. Both open one dialog built on
- The dialog 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, and locked parts keep programs that must
already pass the checks.
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.
- A missing or zero-length lead-in is reported directly, rather than as a search-limit
failure. Open `Cutting Settings...`, choose a lead-in other than `None` with a nonzero
length on the affected `External`, `Internal`, or `Arc / Circle` tab, then replan.
@@ -302,11 +304,12 @@ plans every plate that has parts. Both open one dialog built on
When a lead hits another part, the finding suggests more spacing or a shorter lead;
when no tested entry fits, it suggests reducing lead-in length and, if neighbours
obstruct it, spacing the parts farther apart. These are suggestions, not guaranteed
fixes: replanning runs the same checks, and Apply stays blocked until every plate is ready.
fixes: replanning runs the same strict checks; an unverified fallback requires
separate warning acceptance and is not approval to post or cut.
- Every plate is captured on the UI thread and checked and planned on a worker. Clean part
material is checked for overlaps with the pre-post overlap analyzer; overlapping parts or
an incomplete check (see [pre-post verification](post-verification.md)) block that plate
whatever its route. A free-order search that ends
material is checked for overlaps with the pre-post overlap analyzer; known overlapping parts
still block that plate whatever its route. Incomplete checks remain visible warnings, never
a clear result (see [pre-post verification](post-verification.md)). A free-order search that ends
`NoSolutionWithinBudget` is retried once with the current part order, and the summary
says the order was kept. Both are allowed 400 expansions per part (at least the
default 20000), because both still plan contour order and entries for every part.
@@ -314,11 +317,13 @@ plans every plate that has parts. Both open one dialog built on
with their status and findings. Finding part numbers are the plate's current order, as the
editor numbers them. The preview shows the active plate detached from the nest (quantity
zero, so drawing quantities do not change) in the proposed order with the proposed
programs. It is shown only for a ready plate that still matches what was planned: a
refused plate may hold program graphs that are unsafe to copy, and a changed one would
draw replayed programs at poses that were never checked.
- Apply is enabled only when every plate is ready, and it is all or nothing through
`CuttingPlanService.Apply`. After it applies, each plate keeps its own copy of the
programs. Ready and best-effort proposals can be previewed only while the plate still
matches capture. Best-effort previews are labelled `UNVERIFIED`. Refused program graphs
are never copied, and changed plates require replanning.
- Apply requires usable output for every plate and remains all or nothing. A ready batch
can apply immediately; an unverified batch requires the unchecked, per-proposal
`I reviewed the warnings. Apply this unverified plan.` checkbox. Replanning clears it.
Both use the same owned-program, freshness and rollback boundary. After it applies, each plate keeps its own copy of the
confirmed settings, which also become the saved defaults. `Stale` keeps the dialog open
and asks for a replan; nothing changes.
- Closing or cancelling while planning cancels the worker and keeps the dialog open until
@@ -332,6 +337,31 @@ plans every plate that has parts. Both open one dialog built on
therefore follow moves and clones just like incremental (G91) programs; displaying a
part does not rewrite its program or coordinate mode.
### Best-effort fallback for imperfect geometry
When strict planning reports unsupported or incomplete geometry, the desktop batch
automatically attempts a bounded, deterministic best-effort proposal. Touching, intersecting
or numerically uncertain material boundaries need not prevent lead-in generation when their
closed executable contours are readable. The fallback reuses the existing contour emitter:
internal contours first, the largest bounding perimeter last, nearest entry from the preceding
departure, with the confirmed lead styles. It retains source part order except to satisfy
proven cutoff and nested-part prerequisites. `Keep the current part order` still refuses an
order that contradicts a proven prerequisite. Uncertain containment is named as a warning.
No source contours are repaired, removed, simplified, or silently closed. Locked programs
and cutoffs remain exact. Unknown/null/recursive instruction graphs, suppressed or nonfinite
motions and contours the emitter cannot represent still refuse. This fallback is for
incomplete geometry checks, not every constraint conflict or exhausted search. Known
inter-part material overlap still blocks the entire batch.
The result retains strict refusal findings and available rapid-check findings, and is
explicitly not a certificate of lead clearance, rapid travel or material containment.
Inspect the preview and warnings before accepting it. A route with only an incomplete
overlap check may likewise be accepted with warnings. `CanApply` and parameterless batch
`Apply()` remain strict; `CanApplyWithWarnings` and `Apply(acceptWarnings: true)` are the
explicit review path. `CuttingPlanService.Apply` still rejects best-effort results directly.
Acceptance never skips the separate pre-post checks or grants posting consent.
## Remaining integration boundaries
The service does not establish clean-material non-overlap, scrap release by open