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.