mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-06 06:02:11 -04:00
feat(cutting): plan verified part order from fixed programs
This commit is contained in:
1 parent
c49c387af5
commit
761cee7c1f
10 files changed
+1098
-175
No files matched your search
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Caller-side input for a fixed-program, direct-XY route only. Keep all sources stable
|
||||
/// during Capture. Both locked and unlocked programs are fixed; this is not an Apply request.
|
||||
/// </summary>
|
||||
public sealed class CuttingPlanRequest
|
||||
{
|
||||
public CuttingPlanRequest(IEnumerable<Part> parts, Vector startPoint = default, int expansionBudget = 20000)
|
||||
{
|
||||
Parts = parts == null ? null : Array.AsReadOnly(parts.ToArray());
|
||||
StartPoint = startPoint;
|
||||
ExpansionBudget = expansionBudget;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Part> Parts { get; }
|
||||
public Vector StartPoint { get; }
|
||||
public int ExpansionBudget { get; }
|
||||
}
|
||||
|
||||
/// <summary>Owned worker input. SourcePart references are identity handles only, never worker data.</summary>
|
||||
public sealed class CuttingPlanSnapshot
|
||||
{
|
||||
internal CuttingPlanSnapshot(IEnumerable<FixedProgramPlacement> placements, Vector startPoint,
|
||||
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null)
|
||||
{
|
||||
Placements = Array.AsReadOnly(placements.ToArray());
|
||||
StartPoint = startPoint;
|
||||
ExpansionBudget = expansionBudget;
|
||||
Failure = failure;
|
||||
Findings = Array.AsReadOnly((findings ?? []).ToArray());
|
||||
}
|
||||
|
||||
public IReadOnlyList<FixedProgramPlacement> Placements { get; }
|
||||
public Vector StartPoint { get; }
|
||||
public int ExpansionBudget { get; }
|
||||
internal CuttingPlanStatus? Failure { get; }
|
||||
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
|
||||
}
|
||||
|
||||
public sealed class FixedProgramPlacement
|
||||
{
|
||||
internal FixedProgramPlacement(Part sourcePart, int sourceOrdinal, Vector location,
|
||||
double rotation, bool leadInsLocked, OwnedExecution execution)
|
||||
{
|
||||
SourcePart = sourcePart;
|
||||
SourceOrdinal = sourceOrdinal;
|
||||
Location = location;
|
||||
Rotation = rotation;
|
||||
LeadInsLocked = leadInsLocked;
|
||||
Execution = execution;
|
||||
}
|
||||
|
||||
public Part SourcePart { get; }
|
||||
public int SourceOrdinal { get; }
|
||||
public Vector Location { get; }
|
||||
public double Rotation { get; }
|
||||
public bool LeadInsLocked { get; }
|
||||
public OwnedExecution Execution { get; }
|
||||
}
|
||||
|
||||
public enum CuttingPlanStatus
|
||||
{
|
||||
Ready,
|
||||
ConstraintConflict,
|
||||
UnsupportedGeometry,
|
||||
InvalidInput,
|
||||
NoSolutionWithinBudget,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>Ordinals are zero-based source positions, not proposed sequence positions.</summary>
|
||||
public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart,
|
||||
int? OtherSourceOrdinal, Part OtherSourcePart, PostVerificationKind? Kind, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// A checked fixed-program route, not full cutting-plan readiness, posting consent,
|
||||
/// physical safety, or an atomic Apply payload. Failures contain no proposed order.
|
||||
/// </summary>
|
||||
public sealed class CuttingPlanResult
|
||||
{
|
||||
internal CuttingPlanResult(CuttingPlanStatus status, IEnumerable<FixedProgramPlacement> order = null,
|
||||
IEnumerable<CuttingPlanFinding> findings = null, int expansions = 0,
|
||||
double rapidDistance = 0, bool independentlyReplayed = false)
|
||||
{
|
||||
Status = status;
|
||||
ProposedOrder = Array.AsReadOnly((order ?? []).ToArray());
|
||||
Findings = Array.AsReadOnly((findings ?? []).ToArray());
|
||||
Expansions = expansions;
|
||||
RapidDistance = rapidDistance;
|
||||
IndependentlyReplayed = independentlyReplayed;
|
||||
}
|
||||
|
||||
public CuttingPlanStatus Status { get; }
|
||||
public IReadOnlyList<FixedProgramPlacement> ProposedOrder { get; }
|
||||
public IReadOnlyList<CuttingPlanFinding> Findings { get; }
|
||||
public int Expansions { get; }
|
||||
public double RapidDistance { get; }
|
||||
public bool IndependentlyReplayed { get; }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
public static class CuttingPlanService
|
||||
{
|
||||
/// <summary>
|
||||
/// Read stable caller-owned sources once, before dispatching worker work. No Program.Clone,
|
||||
/// private Plates, settings aliases, quantity updates, or subcall rebinding are involved.
|
||||
/// </summary>
|
||||
public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default)
|
||||
{
|
||||
var placements = new List<FixedProgramPlacement>();
|
||||
Part source = null;
|
||||
int? ordinal = null;
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (request?.Parts == null || request.Parts.Count == 0 || request.ExpansionBudget <= 0)
|
||||
throw new ArgumentException("A nonempty source list and positive expansion budget are required.");
|
||||
// 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.");
|
||||
if (source.BaseDrawing.IsCutOff)
|
||||
throw new NotSupportedException("Cutoff dependency ordering is outside the fixed-program route slice.");
|
||||
var clean = ExecutionMotionReader.Read(source.BaseDrawing.Program, Vector.Zero, null, token);
|
||||
if (!clean.HasCuttingContour)
|
||||
throw new NotSupportedException("Scribe-only or noncutting source drawings are outside this route slice.");
|
||||
var execution = ExecutionMotionReader.Read(source.Program, source.Location, request.StartPoint, token);
|
||||
if (!execution.HasCuttingContour)
|
||||
throw new ArgumentException("Placed program has no nonzero cutting contour motions.");
|
||||
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked, execution));
|
||||
}
|
||||
// The start can be invalid even when the first rapid has no cutting geometry.
|
||||
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
|
||||
return new(placements, request.StartPoint, request.ExpansionBudget);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return Failure(CuttingPlanStatus.Cancelled, "Capture cancelled.");
|
||||
}
|
||||
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)]);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
return new(CuttingPlanStatus.Cancelled);
|
||||
if (snapshot == null)
|
||||
return new(CuttingPlanStatus.InvalidInput);
|
||||
if (snapshot.Failure is { } failure)
|
||||
return new(failure, findings: snapshot.Findings);
|
||||
try
|
||||
{
|
||||
var fixedFindings = new List<CuttingPlanFinding>();
|
||||
foreach (var placement in snapshot.Placements)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
|
||||
var findings = new ReleasedContourState().Check(placement.Execution, null,
|
||||
placement.SourceOrdinal + 1, token);
|
||||
fixedFindings.AddRange(Map(snapshot, findings));
|
||||
}
|
||||
if (fixedFindings.Count != 0)
|
||||
return new(fixedFindings.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
||||
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
|
||||
findings: fixedFindings);
|
||||
|
||||
var search = FixedProgramSearch.Run(snapshot, token);
|
||||
if (search.Status != CuttingPlanStatus.Ready)
|
||||
return new(search.Status, findings: Map(snapshot, search.Findings), expansions: search.Expansions);
|
||||
return Replay(snapshot, search.Order, search.Expansions, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(CuttingPlanStatus.Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
var checker = new ReleasedContourState();
|
||||
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, 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: Map(snapshot, findings), expansions: expansions);
|
||||
return new(CuttingPlanStatus.Ready, order.Select(index => snapshot.Placements[index]),
|
||||
expansions: expansions, rapidDistance: distance, independentlyReplayed: true);
|
||||
}
|
||||
|
||||
private static IEnumerable<CuttingPlanFinding> Map(CuttingPlanSnapshot snapshot,
|
||||
IEnumerable<PostVerificationFinding> findings) => findings.Select(finding =>
|
||||
{
|
||||
var source = finding.PartNumber is { } part ? snapshot.Placements[part - 1] : null;
|
||||
var other = finding.OtherPartNumber is { } otherPart ? snapshot.Placements[otherPart - 1] : null;
|
||||
return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart,
|
||||
other?.SourceOrdinal, other?.SourcePart, finding.Kind, finding.Message);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>Bounded forward depth-first search, nearest feasible branch then source ordinal.</summary>
|
||||
internal static class FixedProgramSearch
|
||||
{
|
||||
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<int> Order,
|
||||
IReadOnlyList<PostVerificationFinding> Findings, int Expansions);
|
||||
|
||||
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||
{
|
||||
var stack = new Stack<Frame>();
|
||||
stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState()));
|
||||
var rejected = new HashSet<PostVerificationFinding>();
|
||||
var expansions = 0;
|
||||
while (stack.Count != 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var frame = stack.Peek();
|
||||
if (frame.Order.Length == snapshot.Placements.Count)
|
||||
return new(CuttingPlanStatus.Ready, frame.Order, [], expansions);
|
||||
if (frame.Next == frame.Candidates.Length)
|
||||
{
|
||||
stack.Pop();
|
||||
continue;
|
||||
}
|
||||
if (expansions == snapshot.ExpansionBudget)
|
||||
return new(CuttingPlanStatus.NoSolutionWithinBudget, [], rejected.ToArray(), expansions);
|
||||
var candidate = frame.Candidates[frame.Next++];
|
||||
expansions++;
|
||||
var placement = snapshot.Placements[candidate];
|
||||
var checker = frame.Checker.Copy();
|
||||
var findings = checker.Check(placement.Execution, frame.Position, placement.SourceOrdinal + 1, token);
|
||||
if (findings.Count != 0)
|
||||
{
|
||||
rejected.UnionWith(findings);
|
||||
continue;
|
||||
}
|
||||
stack.Push(Create([.. frame.Order, candidate], placement.Execution.DeparturePoint, checker));
|
||||
}
|
||||
return new(CuttingPlanStatus.ConstraintConflict, [], rejected.ToArray(), expansions);
|
||||
|
||||
Frame Create(int[] order, Vector position, ReleasedContourState checker) => new(order, position, checker,
|
||||
Enumerable.Range(0, snapshot.Placements.Count).Where(index => !order.Contains(index))
|
||||
.OrderBy(index => snapshot.Placements[index].Execution.RapidDistanceFrom(position))
|
||||
.ThenBy(index => snapshot.Placements[index].SourceOrdinal).ToArray());
|
||||
}
|
||||
|
||||
private sealed class Frame(int[] order, Vector position, ReleasedContourState checker, int[] candidates)
|
||||
{
|
||||
internal int[] Order { get; } = order;
|
||||
internal Vector Position { get; } = position;
|
||||
internal ReleasedContourState Checker { get; } = checker;
|
||||
internal int[] Candidates { get; } = candidates;
|
||||
internal int Next { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user