mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-08 22:45:47 -05:00
The part cut first no longer makes a separate uncompensated centerline cut with its own lead-in and lead-out, then re-pierces for its remainder. It cuts its outside compensated, which puts its shared edge on the kerf centerline, starting on the common line: a straight lead-in from the scrap along the shared edge, then the line and on round its perimeter with no lead-out. A member with spans already cut stops in that kerf and cuts its last stretch from it, ending beside the part it is cut before. Partners still cut only their remainders. Replay's tabbed-ring accounting now accepts pieces that tile each span exactly once, and the pre-post review counts a span as cut by a member's compensated edge (or an older centerline cut). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
601 lines
38 KiB
C#
601 lines
38 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using OpenNest.CNC;
|
|
using OpenNest.CNC.CuttingPlanning;
|
|
using OpenNest.Diagnostics;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.Support;
|
|
|
|
namespace OpenNest.Engine.CuttingPlanning;
|
|
|
|
public static class CuttingPlanService
|
|
{
|
|
/// <summary>
|
|
/// 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) =>
|
|
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
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
PlateCuttingState plateState = null;
|
|
if (request?.Plate != null)
|
|
{
|
|
// Exact freshness record first, on the caller thread; the planned list must be it.
|
|
plateState = PlateCuttingState.Capture(request.Plate, token);
|
|
if (request.Parts == null || !request.Parts.SequenceEqual(plateState.Order, ReferenceEqualityComparer.Instance))
|
|
throw new ArgumentException("The plate's parts changed after the request was created.");
|
|
if (request.Parts.Count == 0 && request.ExpansionBudget > 0 && request.MaxEntries > 0)
|
|
return new([], request.StartPoint, request.ExpansionBudget, plateState: plateState);
|
|
}
|
|
if (request?.Parts == null || request.Parts.Count == 0 || request.ExpansionBudget <= 0 || request.MaxEntries <= 0)
|
|
throw new ArgumentException("A nonempty source list and positive expansion budget are required.");
|
|
var eligible = new HashSet<Part>(ReferenceEqualityComparer.Instance);
|
|
if (request.EligibleParts != null)
|
|
{
|
|
if (request.ConfirmedParameters == null)
|
|
throw new ArgumentException("Eligibility requires confirmed cutting parameters.");
|
|
foreach (var part in request.EligibleParts)
|
|
if (part == null || !request.Parts.Any(p => ReferenceEquals(p, part)) || !eligible.Add(part))
|
|
throw new ArgumentException("Foreign or duplicate eligible placement.");
|
|
}
|
|
// Common lines cut once: each member regenerates as its holes, shared cuts and remainder.
|
|
var commonLines = CommonLineCapture.Of(request);
|
|
var deferredMembers = new List<(int Placement, Program Clean)>();
|
|
// Cutoff definitions by drawing reference, as automatic sequencing matches them.
|
|
var definitions = new Dictionary<Drawing, CutOff>(ReferenceEqualityComparer.Instance);
|
|
foreach (var cutOff in request.Plate?.CutOffs ?? [])
|
|
if (cutOff?.Drawing != null)
|
|
definitions[cutOff.Drawing] = cutOff;
|
|
// Each cut-off part cuts one trimmed segment of its line and precedes only the parts
|
|
// in that segment's span (docs/automatic-scrap-cutoffs.md), not every part the line passes.
|
|
var spans = new Dictionary<CutOff, Dictionary<Part, (double Start, double End)>>(ReferenceEqualityComparer.Instance);
|
|
// Lines splitting off a piece of the sheet, alone or with the separators they stop on.
|
|
IReadOnlyDictionary<Part, int> separators = null;
|
|
var nodes = new List<DependencyNode>();
|
|
// Reuse the reader's coordinate validation without publishing its native kernel.
|
|
var identities = new HashSet<Part>(ReferenceEqualityComparer.Instance);
|
|
for (var index = 0; index < request.Parts.Count; index++)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
source = request.Parts[index];
|
|
ordinal = index;
|
|
if (source?.BaseDrawing == null || source.Program == null || !double.IsFinite(source.Rotation)
|
|
|| !identities.Add(source))
|
|
throw new ArgumentException("Missing/duplicate source placement or invalid pose.");
|
|
// Validate both original graphs before Clone or any virtual transform can
|
|
// erase unsupported runtime semantics, including fixed/ineligible targets.
|
|
OwnedProgramCopy.Validate(source.BaseDrawing.Program, token);
|
|
OwnedProgramCopy.Validate(source.Program, token);
|
|
if (source.BaseDrawing.IsCutOff)
|
|
{
|
|
// A cutoff is a fixed open cut: never material, never regenerated.
|
|
if (request.Plate == null)
|
|
throw new NotSupportedException("Cutoff dependencies require a plate-scoped request.");
|
|
if (eligible.Contains(source))
|
|
throw new ArgumentException("A cutoff cannot be eligible for regeneration.");
|
|
var cut = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
|
|
if (!cut.HasCuttingContour)
|
|
throw new ArgumentException("Placed cutoff program has no nonzero cutting motions.");
|
|
var defined = definitions.TryGetValue(source.BaseDrawing, out var definition);
|
|
var sheet = request.Plate.BoundingBox(includeParts: false);
|
|
separators ??= CutOff.Separators(request.Parts, definitions.Values, sheet);
|
|
int? round = defined && separators.TryGetValue(source, out var found) ? found : null;
|
|
var separates = round != null;
|
|
CutOffDefinition nominal = null;
|
|
if (defined)
|
|
{
|
|
if (!spans.TryGetValue(definition, out var segments))
|
|
{
|
|
spans[definition] = segments = new(ReferenceEqualityComparer.Instance);
|
|
foreach (var segment in CutOffSegments.Of(definition, request.Parts, sheet))
|
|
segments[segment.Part] = (segment.SpanStart, segment.SpanEnd);
|
|
}
|
|
nominal = segments.TryGetValue(source, out var span)
|
|
? new(definition.Axis, definition.Position, span.Start, span.End, definition.Gaps)
|
|
: new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit,
|
|
definition.Gaps);
|
|
}
|
|
nodes.Add(new(true, nominal, source.BoundingBox, null, null, round));
|
|
// A head-locked far-edge segment may be cut from either end (docs/automatic-scrap-cutoffs.md).
|
|
FixedProgramPlacement reversed = null;
|
|
if (defined && CutOff.ReverseAtFarEdge(source.Program) is { } flipped)
|
|
reversed = new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
|
|
ExecutionMotionReader.ReadSupported(flipped, source.Location, request.StartPoint, token), flipped)
|
|
{ IsCutOff = true, SeparatesSheet = separates, ReversesCutOff = true };
|
|
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
|
|
cut, OwnedProgramCopy.Copy(source.Program, token))
|
|
{ IsCutOff = true, SeparatesSheet = separates, Reversed = reversed });
|
|
continue;
|
|
}
|
|
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
|
|
if (!clean.HasCuttingContour)
|
|
throw new NotSupportedException(clean.Motions.Any(m => m.Layer == LayerType.DisplayPerimeter)
|
|
? "A display perimeter with no cutouts has nothing to cut."
|
|
: "Scribe-only or noncutting source drawings are outside this route slice.");
|
|
var execution = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
|
|
if (!execution.HasCuttingContour)
|
|
throw new ArgumentException("Placed program has no nonzero cutting contour motions.");
|
|
var ownedProgram = OwnedProgramCopy.Copy(source.Program, token);
|
|
PreparedContours prepared = null;
|
|
LeadMaterialSnapshot material = null;
|
|
// Geometry-only transform: original graphs were strictly validated and
|
|
// clone expansion bounded above. Legacy Rotate visits per parent, so
|
|
// retain Clone's per-parent sharing here instead of restoring diamonds.
|
|
// Exact placed/proposed payloads still use lossless OwnedProgramCopy.
|
|
var ownedClean = (Program)source.BaseDrawing.Program.Clone();
|
|
ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation);
|
|
// Incremental rounding can leave a converted contour just short of its seam.
|
|
IncrementalClosureDrift.Close(ownedClean);
|
|
// A baked-in seam overcut encloses no area; left in, the ring touches itself.
|
|
SeamOvercut.Trim(ownedClean);
|
|
// A common-line member never cuts the spans earlier members cut: they are its material's tabs.
|
|
var member = request.ConfirmedParameters != null && commonLines?.IsMember(source) == true;
|
|
if (member)
|
|
{
|
|
if (source.LeadInsLocked || request.EligibleParts != null && !eligible.Contains(source))
|
|
throw new NotSupportedException($"Part {index + 1} shares a common line that is cut once, "
|
|
+ "so Plan Cutting must regenerate its program: "
|
|
+ (source.LeadInsLocked ? "unlock its lead-ins" : "include it in the plan")
|
|
+ ", or cut the plate's common lines twice.");
|
|
ownedClean = commonLines.Mark(source, ownedClean, source.Location, index);
|
|
}
|
|
// Material extent only: remote rapids and scribe marks must not widen it, or a
|
|
// genuine insert could fail the containment prefilter.
|
|
var cleanBounds = NestedPartContainment.CleanBounds(ownedClean, source.Location, token);
|
|
var location = source.Location;
|
|
if (request.ConfirmedParameters != null)
|
|
{
|
|
material = LeadMaterialSnapshot.Capture(ownedClean, location, token, sharedSpans: member);
|
|
if (!material.IsComplete)
|
|
{
|
|
if (!bestEffort)
|
|
throw new NotSupportedException(material.Reason);
|
|
warnings.Add(new(index, source, null, null, PostVerificationKind.Incomplete, material.Reason));
|
|
}
|
|
if (member)
|
|
deferredMembers.Add((placements.Count, ownedClean));
|
|
else if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source)))
|
|
{
|
|
prepared = DrawingPinCapture.Prepare(source, index, ownedClean, request.ConfirmedParameters,
|
|
warnings, token);
|
|
prepared = SupportTabCapture.Prepare(prepared, ownedClean, location, request, token);
|
|
}
|
|
else if (source.HasActiveDrawingPins)
|
|
warnings.Add(DrawingPinCapture.NotApplied(source, index));
|
|
}
|
|
var captured = material;
|
|
// Fixed-route material is captured only for a pair whose bounds admit containment.
|
|
nodes.Add(new(false, null, source.BoundingBox, cleanBounds,
|
|
() => captured ?? LeadMaterialSnapshot.Capture(ownedClean, location, token),
|
|
cutFirst: source.BaseDrawing.CutFirst));
|
|
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
|
|
execution, ownedProgram, prepared, material));
|
|
}
|
|
// A member's shared cuts need every part's material: each must fit its leads at one end.
|
|
var materials = placements.Where(p => !p.IsCutOff && p.Material != null).Select(p => p.Material).ToArray();
|
|
foreach (var (at, clean) in deferredMembers)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var placed = placements[at];
|
|
source = placed.SourcePart;
|
|
ordinal = placed.SourceOrdinal;
|
|
placements[at] = CaptureMember(placed, clean, commonLines, request, materials, warnings, bestEffort, token);
|
|
}
|
|
// The start can be invalid even when the first rapid has no cutting geometry.
|
|
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
|
|
source = null;
|
|
ordinal = null;
|
|
var commonLineOrder = commonLines?.Order(request.Parts) ?? [];
|
|
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, commonLineOrder);
|
|
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, findings: warnings, bestEffort: bestEffort)
|
|
{
|
|
SupportTabsSkipped = SupportTabSettings.SkipsForNoRange(request.ConfirmedParameters, request.SupportBed),
|
|
CommonLineMembers = commonLines?.MemberNumbers(request.Parts) ?? new HashSet<int>(),
|
|
CommonLineOrder = commonLineOrder.ToHashSet(),
|
|
};
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return Failure(CuttingPlanStatus.Canceled, "Capture canceled.");
|
|
}
|
|
catch (CuttingDependencyException exception)
|
|
{
|
|
var other = exception.Other is { } index ? request.Parts[index] : null;
|
|
return new(placements, request.StartPoint, request.ExpansionBudget, exception.Status,
|
|
[new(exception.Ordinal, request.Parts[exception.Ordinal], exception.Other, other, null, exception.Message)]);
|
|
}
|
|
catch (NotSupportedException exception)
|
|
{
|
|
return Failure(CuttingPlanStatus.UnsupportedGeometry, exception.Message);
|
|
}
|
|
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or ArithmeticException)
|
|
{
|
|
return Failure(CuttingPlanStatus.InvalidInput, exception.Message);
|
|
}
|
|
|
|
CuttingPlanSnapshot Failure(CuttingPlanStatus status, string message) =>
|
|
new(placements, request?.StartPoint ?? Vector.Zero, request?.ExpansionBudget ?? 0, status,
|
|
[new(ordinal, source, null, null, null, message)]);
|
|
}
|
|
|
|
// A common-line member regenerates from its marked clean program: holes as usual, then its
|
|
// whole perimeter from its common line, or its remainder, from one fixed entry. Its leads are checked here, so a lead that
|
|
// fits nowhere refuses with the span named rather than as a search that ran out.
|
|
private static FixedProgramPlacement CaptureMember(FixedProgramPlacement placed, Program clean,
|
|
CommonLineCapture commonLines, CuttingPlanRequest request, IReadOnlyList<LeadMaterialSnapshot> materials,
|
|
List<CuttingPlanFinding> warnings, bool bestEffort, CancellationToken token)
|
|
{
|
|
var source = placed.SourcePart;
|
|
var index = placed.SourceOrdinal;
|
|
var parameters = request.ConfirmedParameters;
|
|
var pierceRadius = parameters.GetPierceZoneRadius();
|
|
var material = placed.Material;
|
|
PreparedContours prepared;
|
|
try
|
|
{
|
|
var perimeter = commonLines.Perimeter(source, index, request.Parts, clean, material, materials,
|
|
parameters, pierceRadius, token);
|
|
prepared = DrawingPinCapture.Prepare(source, index, clean, parameters, warnings, token, perimeter);
|
|
if (!bestEffort)
|
|
{
|
|
var entry = prepared.Entries(prepared.PerimeterOrdinal, Vector.Zero)[0];
|
|
var probe = ExecutionMotionReader.ReadSupported(prepared.EmitCandidateForValidation(entry),
|
|
source.Location, null, token);
|
|
var lead = LeadPathValidator.Check(probe, material, materials, pierceRadius, token);
|
|
if (!lead.IsComplete || !lead.IsClear)
|
|
throw new NotSupportedException((perimeter.StartsOnCommonLine
|
|
? "no lead-in fits the start of its common line: "
|
|
: "no lead-in fits its common-line remainder in the shared kerf: ") + lead.Reason);
|
|
}
|
|
}
|
|
catch (NotSupportedException exception) when (!exception.Message.StartsWith("Part ", StringComparison.Ordinal)
|
|
&& !exception.Message.StartsWith("No lead fits", StringComparison.Ordinal))
|
|
{
|
|
throw new NotSupportedException($"Part {index + 1}: {exception.Message}", exception);
|
|
}
|
|
return new(source, index, placed.Location, placed.Rotation, placed.LeadInsLocked, placed.Execution,
|
|
placed.CopyProgram(), prepared, material);
|
|
}
|
|
|
|
public static CuttingPlanResult Plan(CuttingPlanRequest request, CancellationToken token = default) =>
|
|
Plan(Capture(request, token), token);
|
|
|
|
/// <summary>Worker-only planning uses owned values; live identities are never dereferenced.</summary>
|
|
public static CuttingPlanResult Plan(CuttingPlanSnapshot snapshot, CancellationToken token = default)
|
|
{
|
|
var result = PlanCaptured(snapshot, token);
|
|
result.Snapshot = snapshot;
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Installs exactly the replayed proposals of Ready plate-scoped results, all or nothing.
|
|
/// Each plate must still match the state captured with its request; otherwise Stale and
|
|
/// nothing changes. Run on the thread that owns the plates. Never replans.
|
|
/// </summary>
|
|
public static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token = default) =>
|
|
Apply(results, token, null);
|
|
|
|
// beforeInstall is the commit's install-boundary test seam.
|
|
internal static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token,
|
|
Action<Plate, Part> beforeInstall, bool acceptUnverified = false)
|
|
{
|
|
var plans = new List<PlateCuttingPlan>();
|
|
foreach (var result in results ?? [])
|
|
{
|
|
var snapshot = result?.Snapshot;
|
|
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>();
|
|
var cutOffs = new List<PlannedCutOffProgram>();
|
|
foreach (var proposal in result.ProposedOrder)
|
|
{
|
|
if (proposal.ReversesCutOff)
|
|
cutOffs.Add(new(proposal.SourcePart, proposal.CopyProgram()));
|
|
if (!proposal.IsRegenerated)
|
|
continue;
|
|
if (snapshot.OwnedParameters == null)
|
|
return new(CuttingCommitStatus.InvalidInput, "A regenerated proposal has no captured settings.",
|
|
snapshot.PlateState.Plate);
|
|
// Fresh owned copies: a result can be applied at most once per captured state,
|
|
// and nothing installed aliases the proposal or another part's settings.
|
|
programs.Add(new(proposal.SourcePart, proposal.CopyProgram(),
|
|
OwnedCuttingParameters.Copy(snapshot.OwnedParameters)));
|
|
}
|
|
plans.Add(new(snapshot.PlateState, result.ProposedOrder.Select(p => p.SourcePart), programs, cutOffs));
|
|
}
|
|
return CuttingPlanCommit.Apply(plans, token, beforeInstall);
|
|
}
|
|
|
|
private static CuttingPlanResult PlanCaptured(CuttingPlanSnapshot snapshot, CancellationToken token)
|
|
{
|
|
if (token.IsCancellationRequested)
|
|
return new(CuttingPlanStatus.Canceled);
|
|
if (snapshot == null)
|
|
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.
|
|
if (snapshot.PreservePartOrder
|
|
&& snapshot.Dependencies.FirstViolation(Enumerable.Range(0, snapshot.Placements.Count).ToArray()) is { } manual)
|
|
return new(CuttingPlanStatus.ConstraintConflict,
|
|
findings: [DependencyFinding(snapshot, manual, "The preserved part order")]);
|
|
try
|
|
{
|
|
var fixedFindings = new List<CuttingPlanFinding>();
|
|
foreach (var placement in snapshot.Placements)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (placement.Prepared != null)
|
|
continue; // An eligible old crossing is precisely what regeneration may repair.
|
|
// With confirmed parameters a locked program is accepted as given: replay reports
|
|
// its internal violations as warnings (D1a). The no-parameter route and unlocked
|
|
// programs left ineligible keep refusing them.
|
|
if (snapshot.Regeneration && placement.FixedMotions(placement.Execution) != null)
|
|
continue;
|
|
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
|
|
var findings = new ReleasedContourState(reportMissingLeadIns: false).Check(placement.Execution, null,
|
|
placement.SourceOrdinal + 1, placement.IsCutOff, token);
|
|
fixedFindings.AddRange(JointCuttingPlanSearch.Map(snapshot, findings));
|
|
}
|
|
if (fixedFindings.Count != 0)
|
|
return new(fixedFindings.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
|
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
|
|
findings: fixedFindings);
|
|
|
|
if (snapshot.Regeneration)
|
|
{
|
|
var joint = JointCuttingPlanSearch.Run(snapshot, token);
|
|
// Capture warnings (drawing pins that could not be honored) stay visible on a refusal.
|
|
if (joint.Status != CuttingPlanStatus.Ready)
|
|
return new(joint.Status, findings: snapshot.Findings.Concat(joint.Findings), expansions: joint.Expansions);
|
|
return ReplayPrograms(snapshot, joint.Order, joint.Expansions, token);
|
|
}
|
|
var search = FixedProgramSearch.Run(snapshot, token);
|
|
if (search.Status != CuttingPlanStatus.Ready)
|
|
return new(search.Status, findings: JointCuttingPlanSearch.Map(snapshot, search.Findings), expansions: search.Expansions);
|
|
return Replay(snapshot, search.Order, search.Expansions, token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return new(CuttingPlanStatus.Canceled);
|
|
}
|
|
}
|
|
|
|
// Fresh checker and full sequence replay: no cached branch state is accepted as evidence.
|
|
internal static CuttingPlanResult Replay(CuttingPlanSnapshot snapshot, IReadOnlyList<int> order,
|
|
int expansions, CancellationToken token)
|
|
{
|
|
if (order.Count != snapshot.Placements.Count || order.Distinct().Count() != order.Count
|
|
|| order.Any(index => index < 0 || index >= snapshot.Placements.Count))
|
|
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
|
// Dependencies are rechecked from the captured graph, not trusted from the search.
|
|
if (snapshot.Dependencies.FirstViolation(order) is { } violation)
|
|
return new(CuttingPlanStatus.InvalidInput,
|
|
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
|
|
var checker = snapshot.NewReleasedState();
|
|
var position = snapshot.StartPoint;
|
|
var distance = 0.0;
|
|
var findings = new List<PostVerificationFinding>();
|
|
foreach (var index in order)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var placement = snapshot.Placements[index];
|
|
findings.AddRange(checker.Check(placement.Execution, position, placement.SourceOrdinal + 1,
|
|
placement.IsCutOff, token));
|
|
distance += placement.Execution.RapidDistanceFrom(position);
|
|
position = placement.Execution.DeparturePoint;
|
|
}
|
|
token.ThrowIfCancellationRequested();
|
|
if (findings.Count != 0)
|
|
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
|
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
|
|
findings: JointCuttingPlanSearch.Map(snapshot, findings), expansions: expansions);
|
|
return new(CuttingPlanStatus.Ready, order.Select(index => snapshot.Placements[index]),
|
|
expansions: expansions, rapidDistance: distance, independentlyReplayed: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The safest route the greedy walk finds, independently replayed; null when there is none.
|
|
/// Its planner-chosen rapids that still cross parts already cut raise the head
|
|
/// (<see cref="RaisedRapids"/>): only here, after the strict search found no route avoiding
|
|
/// them. A route that then passes every check is Ready, listing its raises. One that does not
|
|
/// is an UNVERIFIED BestEffort carrying every violation, applicable only with explicit
|
|
/// acceptance. A route the replay cannot check completely, or with any motion into material
|
|
/// (a lead that does not fit, a cut-order or nested-lead conflict), is never offered: the
|
|
/// strict refusal stands.
|
|
/// </summary>
|
|
internal static CuttingPlanResult PlanSafestRoute(CuttingPlanSnapshot snapshot, CancellationToken token)
|
|
{
|
|
if (snapshot == null || snapshot.Failure != null || !snapshot.Regeneration || snapshot.BestEffort
|
|
|| snapshot.Placements.Count == 0)
|
|
return null;
|
|
// The walk weighs every part it cuts rather than stopping at the first refusal: it gets
|
|
// twice the strict search's budget. Replay still checks it against the captured input.
|
|
var budget = (int)System.Math.Min(int.MaxValue, 2L * snapshot.ExpansionBudget);
|
|
var order = JointCuttingPlanSearch.SafestRoute(snapshot.WithSearchLimits(budget, snapshot.MaxEntries), token);
|
|
if (order == null)
|
|
return null;
|
|
try
|
|
{
|
|
order = RaisedRapids.Mark(snapshot, order, token);
|
|
}
|
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
|
{
|
|
// A route that cannot be read for marking keeps its crossings as findings.
|
|
}
|
|
var result = ReplayPrograms(snapshot, order, 0, token, safestRoute: true);
|
|
if (result.Status is not (CuttingPlanStatus.Ready or CuttingPlanStatus.BestEffort)
|
|
|| result.SafestRoute is { Severe: > 0 })
|
|
return null;
|
|
result.Snapshot = snapshot;
|
|
return result;
|
|
}
|
|
|
|
// Re-read EXACT selected programs with a fresh checker and native lead validation.
|
|
// No emission/regeneration or cached branch verdict is used here.
|
|
/// <param name="safestRoute">
|
|
/// Return a route whose planner-chosen motions break checks as an UNVERIFIED BestEffort with
|
|
/// those findings rather than refusing it; incomplete checks are still refused.
|
|
/// </param>
|
|
internal static CuttingPlanResult ReplayPrograms(CuttingPlanSnapshot snapshot,
|
|
IReadOnlyList<FixedProgramPlacement> order, int expansions, CancellationToken token, bool safestRoute = false)
|
|
{
|
|
if (order == null || order.Count != snapshot.Placements.Count
|
|
|| order.Any(p => p == null || p.SourceOrdinal < 0 || p.SourceOrdinal >= snapshot.Placements.Count)
|
|
|| order.Select(p => p.SourceOrdinal).Distinct().Count() != order.Count)
|
|
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
|
if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation)
|
|
return new(CuttingPlanStatus.InvalidInput,
|
|
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
|
|
var checker = snapshot.NewReleasedState();
|
|
var position = snapshot.StartPoint;
|
|
var distance = 0.0;
|
|
var findings = new List<CuttingPlanFinding>();
|
|
// Violations inside fixed emissions: accepted as given, reported, never refused (D1a).
|
|
// Capture adds the drawing pins it could not honor; their contours were planned normally.
|
|
var warnings = new List<CuttingPlanFinding>(snapshot.Findings);
|
|
// Rapids that raise the head over cut parts are listed, never refused.
|
|
var raises = new List<CuttingPlanFinding>();
|
|
var raisedRapids = 0;
|
|
var materialOwners = snapshot.Placements.Where(p => !p.IsCutOff).ToArray();
|
|
var materials = materialOwners.Select(p => p.Material).ToArray();
|
|
foreach (var proposal in order)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var source = snapshot.Placements[proposal.SourceOrdinal];
|
|
if (!ReferenceEquals(source.SourcePart, proposal.SourcePart)
|
|
|| !SameBits(source.Location.X, proposal.Location.X) || !SameBits(source.Location.Y, proposal.Location.Y)
|
|
|| !SameBits(source.Rotation, proposal.Rotation) || source.LeadInsLocked != proposal.LeadInsLocked
|
|
|| !ReferenceEquals(source.Prepared, proposal.Prepared) || !ReferenceEquals(source.Material, proposal.Material)
|
|
|| source.IsCutOff != proposal.IsCutOff
|
|
|| snapshot.PreservePartOrder && proposal.SourceOrdinal != order.TakeWhile(p => !ReferenceEquals(p, proposal)).Count())
|
|
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
|
if (source.Prepared == null)
|
|
{
|
|
if (!ReferenceEquals(source, proposal) && !ReferenceEquals(source.Reversed, proposal))
|
|
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
|
}
|
|
else if (proposal.SelectedProgram == null
|
|
|| !proposal.SelectedProgram.Matches(source.Prepared, proposal.ContourChoices))
|
|
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
|
OwnedExecution execution;
|
|
bool complete;
|
|
try
|
|
{
|
|
execution = ExecutionMotionReader.Read(proposal.CopyProgram(), proposal.Location, position, token);
|
|
// A cutoff is an open cut with no material; it is retained exactly (checked above).
|
|
complete = source.IsCutOff
|
|
|| ContourProgramVerifier.Verify(execution, source.Material, proposal.SelectedProgram, token);
|
|
}
|
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
|
{
|
|
return new(CuttingPlanStatus.UnsupportedGeometry,
|
|
findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions);
|
|
}
|
|
var isFixed = proposal.FixedMotions(execution);
|
|
var (strict, fixedFindings, raised) = checker.Check(execution, position, source.SourceOrdinal + 1,
|
|
source.IsCutOff, isFixed, token);
|
|
raises.AddRange(RaisedRapids.Notes(snapshot, source, raised));
|
|
raisedRapids += RaisedRapids.Count(raised);
|
|
findings.AddRange(JointCuttingPlanSearch.Map(snapshot, strict));
|
|
warnings.AddRange(fixedFindings.Select(f => FixedLeadWarnings.FromChecker(snapshot, source, f)));
|
|
var fixedLeads = new List<FixedLeadFailure>();
|
|
var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
|
|
: LeadPathValidator.Check(execution, source.Material, materials, isFixed, fixedLeads, token,
|
|
pierceRadius: snapshot.PierceZoneRadius);
|
|
if (!lead.IsComplete || !lead.IsClear)
|
|
findings.Add(JointCuttingPlanSearch.Finding(source,
|
|
lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
|
|
var pins = proposal.PinsByMotion(execution);
|
|
warnings.AddRange(fixedLeads.Select(f => FixedLeadWarnings.FromLead(source, execution, f, materialOwners,
|
|
pins?[f.Motion])));
|
|
// A locked program's coverage (a tab gap, a partial or reversed cut) has no certified
|
|
// selection to compare with; a regenerated program's never escapes the strict rule.
|
|
if (!complete && source.Prepared == null && isFixed != null)
|
|
warnings.Add(FixedLeadWarnings.Uncertified(source));
|
|
else if (!complete && (findings.Count == 0 || safestRoute))
|
|
return new(CuttingPlanStatus.InvalidInput,
|
|
findings: [JointCuttingPlanSearch.Finding(source, null,
|
|
"Selected program does not preserve complete directed contours, certified tabs or selected entry/style geometry.")],
|
|
expansions: expansions);
|
|
// A part the support bed cannot hold level must be tabbed where the tab steadies it.
|
|
if (SupportTabCapture.Review(source, proposal.ContourChoices) is { } unsteady)
|
|
warnings.Add(unsteady);
|
|
distance += execution.RapidDistanceFrom(position);
|
|
position = execution.DeparturePoint;
|
|
}
|
|
token.ThrowIfCancellationRequested();
|
|
// Support tabs that cannot hold their part level are noted, never refused.
|
|
var notes = raises.Concat(order.SelectMany(SupportTabCapture.Notes))
|
|
.Concat(SupportTabCapture.Skipped(snapshot)).ToList();
|
|
if (safestRoute && findings.Count != 0 && findings.All(f => f.Kind != PostVerificationKind.Incomplete))
|
|
return new(CuttingPlanStatus.BestEffort, order, findings.Concat(warnings).Concat(notes).Distinct(),
|
|
expansions, distance)
|
|
{
|
|
SafestRoute = RouteSeverity.Of(findings.Distinct().Select(f => f.Kind)),
|
|
HeadRaises = raisedRapids,
|
|
};
|
|
if (findings.Count != 0)
|
|
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
|
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
|
|
findings: findings, expansions: expansions);
|
|
// Every planner-chosen motion passed. Fixed-emission warnings make the proposal
|
|
// UNVERIFIED: never Ready, applicable only through explicit warning acceptance.
|
|
if (warnings.Count != 0)
|
|
return new(CuttingPlanStatus.BestEffort, order, warnings.Concat(notes).Distinct(), expansions,
|
|
distance, independentlyReplayed: true)
|
|
{ HeadRaises = raisedRapids };
|
|
return new(CuttingPlanStatus.Ready, order, notes, expansions: expansions,
|
|
rapidDistance: distance, independentlyReplayed: true)
|
|
{ HeadRaises = raisedRapids };
|
|
}
|
|
|
|
private static CuttingPlanFinding DependencyFinding(CuttingPlanSnapshot snapshot,
|
|
(int Part, int Prerequisite) violation, string subject)
|
|
{
|
|
var part = snapshot.Placements[violation.Part];
|
|
var prerequisite = snapshot.Placements[violation.Prerequisite];
|
|
if (part.SeparatesSheet)
|
|
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
|
|
$"{subject} cuts the sheet-separating cutoff (part {part.SourceOrdinal + 1}) before part "
|
|
+ $"{prerequisite.SourceOrdinal + 1}; every part and other cutoff must be cut before the sheet is separated.");
|
|
var reason = prerequisite.IsCutOff ? "the cutoff crossing it"
|
|
: snapshot.CommonLineOrder.Contains((violation.Part, violation.Prerequisite))
|
|
? "the common-line partner that cuts their shared line"
|
|
: prerequisite.SourcePart.BaseDrawing.CutFirst && !part.SourcePart.BaseDrawing.CutFirst
|
|
? "the cut-first part" : "the part nested in its cutout";
|
|
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
|
|
$"{subject} cuts part {part.SourceOrdinal + 1} before {reason} (part {prerequisite.SourceOrdinal + 1}).");
|
|
}
|
|
|
|
private static bool SameBits(double source, double proposed) =>
|
|
BitConverter.DoubleToInt64Bits(source) == BitConverter.DoubleToInt64Bits(proposed);
|
|
}
|