feat(cutting): plan verified part order from fixed programs

This commit is contained in:
aj committed 2026-10-02 21:56:14 -04:00
1 parent c49c387af5
commit 761cee7c1f
10 files changed
+1098 -175

No files matched your search

@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Guarded read of stable placed programs; never clones or mutates their graphs.</summary>
public static class ExecutionMotionReader
{
public static OwnedExecution Read(Program program, Vector origin, Vector? previous,
CancellationToken token)
{
var moves = new List<ExecutionMotion>();
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var budget = 1000000;
Walk(program, origin, previous);
return new OwnedExecution(moves);
Vector Walk(Program current, Vector frame, Vector? arrival)
{
token.ThrowIfCancellationRequested();
PostVerificationGeometry.Validate(frame);
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
throw new ArgumentException("Missing, recursive or excessively nested program.");
var pos = frame;
var first = true;
var countBefore = moves.Count;
foreach (var code in current.Codes)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
if (code is SubProgramCall call)
{
if (!double.IsFinite(call.Rotation))
throw new ArgumentException("Subprogram rotation is not finite.");
// Call rotation is baked into the shared program by its setter. Do not
// rotate again; offsets are frame-relative even in incremental mode.
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
first = false;
continue;
}
if (code is not Motion motion)
{
if (code is not (Comment or Feedrate or Kerf))
throw new NotSupportedException("Unsupported program instruction.");
continue;
}
// Posts disagree about incremental position after suppressed instructions.
// Never silently certify a trajectory whose semantics are ambiguous.
if (motion.Suppressed)
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
if (motion is not (RapidMove or LinearMove or ArcMove))
throw new NotSupportedException("Unsupported motion.");
var reference = current.Mode == Mode.Incremental ? pos : frame;
var end = reference + motion.EndPoint;
PostVerificationGeometry.Validate(end);
var rapid = motion is RapidMove;
if (first && !rapid)
moves.Add(new(arrival, frame, true, LayerType.Display, null));
var start = first && rapid ? arrival : pos;
var layer = motion switch
{
LinearMove line => line.Layer,
ArcMove arc => arc.Layer,
_ => LayerType.Display
};
if (!Enum.IsDefined(layer))
throw new NotSupportedException("Unsupported motion layer.");
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
throw new NotSupportedException("Unsupported arc direction.");
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve));
pos = end;
first = false;
}
visiting.Remove(current);
if (moves.Count == countBefore)
throw new ArgumentException("Program has no motions.");
return pos;
}
}
}
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Owned value motions; no references to live programs, instructions or settings.</summary>
public sealed class OwnedExecution
{
internal OwnedExecution(IEnumerable<ExecutionMotion> motions)
{
Motions = Array.AsReadOnly(motions.ToArray());
}
public IReadOnlyList<ExecutionMotion> Motions { get; }
public Vector DeparturePoint => Motions[^1].End;
public bool HasCuttingContour => Motions.Any(move => !move.Rapid
&& move.Layer is LayerType.Cut or LayerType.Display
&& move.Length > PostVerificationGeometry.Epsilon);
public double RapidDistanceFrom(Vector arrival)
{
PostVerificationGeometry.Validate(arrival);
var total = 0.0;
for (var index = 0; index < Motions.Count; index++)
{
var move = Motions[index];
if (move.Rapid && (index == 0 ? arrival : move.Start) is { } start)
total += start.DistanceTo(move.End);
}
return total;
}
}
/// <summary>An immutable motion value; native line/arc geometry stays private to Core.</summary>
public sealed class ExecutionMotion
{
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
PostVerificationGeometry.Curve curve)
{
Start = start;
End = end;
Rapid = rapid;
Layer = layer;
Curve = curve;
}
public Vector? Start { get; }
public Vector End { get; }
public bool Rapid { get; }
public LayerType Layer { get; }
public double Length => Curve?.Length ?? 0;
internal PostVerificationGeometry.Curve Curve { get; }
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
}
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Direct XY completed-contour checker. This is not physical machine safety.</summary>
public sealed class ReleasedContourState
{
private readonly List<Obstacle> obstacles = new();
public ReleasedContourState Copy()
{
var copy = new ReleasedContourState();
copy.obstacles.AddRange(obstacles);
return copy;
}
/// <summary>Consumes one whole owned program; part numbers are caller identity keys.</summary>
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
int partNumber, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(execution);
if (arrival is { } point)
PostVerificationGeometry.Validate(point);
var moves = execution.Motions.ToArray();
moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, false, findings, 1, partNumber, token);
return findings.AsReadOnly();
}
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
{
var contour = new List<PostVerificationGeometry.Curve>();
var unfinished = new List<PostVerificationGeometry.Curve[]>();
var hasLead = false;
var contourNumber = 0;
foreach (var move in moves)
{
token.ThrowIfCancellationRequested();
if (move.Rapid)
{
Finish();
hasLead = false;
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
continue;
foreach (var obstacle in obstacles)
{
token.ThrowIfCancellationRequested();
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}."));
}
}
else if (move.Layer == LayerType.Leadin)
{
Finish();
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
}
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
{
if (move.Layer == LayerType.Leadout && contour.Count > 0
&& !PostVerificationGeometry.Closed(contour)
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
"Rapid safety for that contour requires manual review."));
Finish();
hasLead = false;
}
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
{
if (contour.Count == 0)
{
contourNumber++;
if (!cutoff && !hasLead)
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles
// until an actually continuous chain closes.
var previous = unfinished.FindIndex(chain =>
chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
if (previous >= 0)
{
contour.AddRange(unfinished[previous]);
unfinished.RemoveAt(previous);
}
}
contour.Add(move.Curve);
// A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(contour))
Finish();
}
}
Finish();
if (unfinished.Count > 1)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
"they cannot be assumed to be retained by tabs. Review rapid travel manually."));
void Finish()
{
if (contour.Count == 0)
return;
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
// no flag or tab configuration is used as evidence of retention.
if (!cutoff && PostVerificationGeometry.Closed(contour))
obstacles.Add(new(part, contourNumber, contour.ToArray()));
else if (!cutoff)
unfinished.Add(contour.ToArray());
contour.Clear();
}
}
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves);
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
@@ -27,9 +28,14 @@ public static class PostVerificationAnalyzer
return new PostVerificationReport(findings);
}
public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default)
public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default) =>
Analyze(nest, Vector.Zero, cancellationToken);
public static PostVerificationReport Analyze(Nest nest, Vector startPoint,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(nest);
PostVerificationGeometry.Validate(startPoint);
cancellationToken.ThrowIfCancellationRequested();
var findings = new List<PostVerificationFinding>();
if (nest.Plates == null)
@@ -50,8 +56,8 @@ public static class PostVerificationAnalyzer
}
var materialParts = new List<Part>();
var indices = new List<int>();
var obstacles = new List<Obstacle>();
Vector? position = Vector.Zero;
var obstacles = new ReleasedContourState();
Vector? position = startPoint;
for (var index = 0; index < plate.Parts.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -65,7 +71,7 @@ public static class PostVerificationAnalyzer
{
try
{
var clean = Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken);
var clean = ExecutionMotionReader.Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken).Motions;
expectsCuts = clean.Any(move => !move.Rapid && move.Layer != LayerType.Scribe);
if (!clean.Any(move => move.Layer == LayerType.Scribe)
|| expectsCuts)
@@ -83,12 +89,12 @@ public static class PostVerificationAnalyzer
{
if (part == null || !double.IsFinite(part.Rotation))
throw new ArgumentException("Missing part or invalid rotation.");
var moves = Read(part.Program, part.Location, position, cancellationToken);
var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions;
if (expectsCuts && !moves.Any(move => !move.Rapid
&& move.Layer is LayerType.Cut or LayerType.Display
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
Incomplete("Placed program has no cutting contour motions for this drawing.");
AnalyzeMoves(moves, cutoff, obstacles, findings, plateNumber, partNumber, cancellationToken);
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
position = moves[^1].End;
}
catch (Exception exception) when (IsInvalid(exception))
@@ -119,172 +125,4 @@ public static class PostVerificationAnalyzer
private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
private static List<Move> Read(Program program, Vector origin, Vector? previous,
CancellationToken token)
{
var moves = new List<Move>();
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var budget = 1000000;
Walk(program, origin, previous);
return moves;
Vector Walk(Program current, Vector frame, Vector? arrival)
{
token.ThrowIfCancellationRequested();
PostVerificationGeometry.Validate(frame);
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
throw new ArgumentException("Missing, recursive or excessively nested program.");
var pos = frame;
var first = true;
var countBefore = moves.Count;
foreach (var code in current.Codes)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
if (code is SubProgramCall call)
{
if (!double.IsFinite(call.Rotation))
throw new ArgumentException("Subprogram rotation is not finite.");
// Call rotation is baked into the shared program by its setter. Do not
// rotate again; offsets are frame-relative even in incremental mode.
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
first = false;
continue;
}
if (code is not Motion motion)
{
if (code is not (Comment or Feedrate or Kerf))
throw new NotSupportedException("Unsupported program instruction.");
continue;
}
// Posts disagree about incremental position after suppressed instructions.
// Never silently certify a trajectory whose semantics are ambiguous.
if (motion.Suppressed)
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
if (motion is not (RapidMove or LinearMove or ArcMove))
throw new NotSupportedException("Unsupported motion.");
var reference = current.Mode == Mode.Incremental ? pos : frame;
var end = reference + motion.EndPoint;
PostVerificationGeometry.Validate(end);
var rapid = motion is RapidMove;
if (first && !rapid)
moves.Add(new(arrival, frame, true, LayerType.Display, null));
var start = first && rapid ? arrival : pos;
var layer = motion switch
{
LinearMove line => line.Layer,
ArcMove arc => arc.Layer,
_ => LayerType.Display
};
if (!Enum.IsDefined(layer))
throw new NotSupportedException("Unsupported motion layer.");
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
throw new NotSupportedException("Unsupported arc direction.");
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve));
pos = end;
first = false;
}
visiting.Remove(current);
if (moves.Count == countBefore)
throw new ArgumentException("Program has no motions.");
return pos;
}
}
private static void AnalyzeMoves(List<Move> moves, bool cutoff, List<Obstacle> obstacles,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
{
var contour = new List<PostVerificationGeometry.Curve>();
var unfinished = new List<PostVerificationGeometry.Curve[]>();
var hasLead = false;
var contourNumber = 0;
foreach (var move in moves)
{
token.ThrowIfCancellationRequested();
if (move.Rapid)
{
Finish();
hasLead = false;
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
continue;
foreach (var obstacle in obstacles)
{
token.ThrowIfCancellationRequested();
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}."));
}
}
else if (move.Layer == LayerType.Leadin)
{
Finish();
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
}
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
{
if (move.Layer == LayerType.Leadout && contour.Count > 0
&& !PostVerificationGeometry.Closed(contour)
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
"Rapid safety for that contour requires manual review."));
Finish();
hasLead = false;
}
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
{
if (contour.Count == 0)
{
contourNumber++;
if (!cutoff && !hasLead)
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles
// until an actually continuous chain closes.
var previous = unfinished.FindIndex(chain =>
chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
if (previous >= 0)
{
contour.AddRange(unfinished[previous]);
unfinished.RemoveAt(previous);
}
}
contour.Add(move.Curve);
// A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(contour))
Finish();
}
}
Finish();
if (unfinished.Count > 1)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
"they cannot be assumed to be retained by tabs. Review rapid travel manually."));
void Finish()
{
if (contour.Count == 0)
return;
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
// no flag or tab configuration is used as evidence of retention.
if (!cutoff && PostVerificationGeometry.Closed(contour))
obstacles.Add(new(part, contourNumber, contour.ToArray()));
else if (!cutoff)
unfinished.Add(contour.ToArray());
contour.Clear();
}
}
private sealed record Move(Vector? Start, Vector End, bool Rapid, LayerType Layer,
PostVerificationGeometry.Curve Curve);
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves);
}
@@ -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; }
}
}
@@ -0,0 +1,413 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingPlanServiceTests
{
[Fact]
public void FixedPrograms_OriginalOrderCrossesA_ReorderedWholeProgramsDoNot()
{
var parts = Fixture();
var original = Analyze(parts);
Assert.Equal(new int?[] { 2, 3 }, original.Findings.Select(f => f.PartNumber));
Assert.All(original.Findings, crossing =>
{
Assert.Equal(PostVerificationKind.RapidCrossing, crossing.Kind);
Assert.Equal(1, crossing.OtherPartNumber);
});
var reordered = Analyze(parts[1], parts[0], parts[2]);
Assert.Empty(reordered.Findings);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Plan_ReordersWholeFixedPrograms_AndIndependentlyReplaysEveryPlacement(bool locked)
{
var parts = Fixture();
foreach (var part in parts)
part.LeadInsLocked = locked;
var unchanged = Unchanged(parts);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, result.ProposedOrder.Select(p => p.SourcePart));
Assert.Equal(new[] { 1, 0, 2 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
Assert.Equal(parts.Length, result.ProposedOrder.Select(p => p.SourcePart).Distinct().Count());
Assert.Empty(result.Findings);
Assert.Empty(Analyze(result.ProposedOrder.Select(p => p.SourcePart).ToArray()).Findings);
Assert.Equal(new Vector(12.25, 2), result.ProposedOrder[^1].Execution.DeparturePoint);
unchanged();
}
[Fact]
public void Plan_BacktracksRatherThanReturningUnsafeNearestOrder()
{
var parts = Fixture();
var start = new Vector(8.25, 2); // A is nearest but no continuation after A can reach B safely.
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, start));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, result.ProposedOrder.Select(p => p.SourcePart));
Assert.True(result.Expansions > parts.Length);
Assert.Empty(AnalyzeFrom(start, result.ProposedOrder.Select(p => p.SourcePart).ToArray()).Findings);
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void Plan_LockedFixedInternalCrossingIsConflictWithExactSourceIdentity(Mode mode)
{
var part = Rectangle("holes", 100, 50, 20, 20);
var hole = new Program();
hole.MoveTo(0.5, 0);
hole.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
hole.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
hole.Mode = mode;
var placed = new Program();
placed.SubPrograms[-7] = hole;
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(5, 5), Id = -7 });
placed.MoveTo(8, 5);
placed.MoveTo(2, 5);
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(15, 5), Id = -7 });
placed.Mode = mode;
Assert.True(part.RestoreLeadInProgram(placed, true));
var parts = new[] { Fixture()[0], part };
var unchanged = Unchanged(parts);
Assert.Contains(Analyze(part).Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.NotEmpty(result.Findings);
Assert.All(result.Findings, finding =>
{
Assert.Equal(PostVerificationKind.RapidCrossing, finding.Kind);
Assert.Equal(1, finding.SourceOrdinal);
Assert.Equal(1, finding.OtherSourceOrdinal);
Assert.Same(part, finding.SourcePart);
Assert.Same(part, finding.OtherSourcePart);
});
unchanged();
}
[Theory]
[InlineData("recursive")]
[InlineData("missing-call")]
[InlineData("null-code")]
[InlineData("nonfinite")]
[InlineData("bad-arc")]
[InlineData("empty")]
[InlineData("motionless")]
[InlineData("null-codes")]
public void Plan_MalformedOrEmptyCuttingStateNeverReady(string fault)
{
var part = Fixture()[0];
var program = part.Program;
switch (fault)
{
case "recursive": program.Codes.Add(new SubProgramCall { Program = program }); break;
case "missing-call": program.Codes.Add(new SubProgramCall()); break;
case "null-code": program.Codes.Add(null!); break;
case "nonfinite": program.MoveTo(double.NaN, 0); break;
case "bad-arc": program.ArcTo(1, 1, 0, 0, RotationType.CW); break;
case "empty": program.Codes.Clear(); break;
case "motionless": program.Codes.Clear(); program.MoveTo(1, 1); break;
case "null-codes": program.Codes = null!; break;
}
var beforeCodes = program.Codes?.ToArray();
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
Assert.Equal(CuttingPlanStatus.InvalidInput, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.Same(program, part.Program);
Assert.Equal(beforeCodes, program.Codes?.ToArray());
}
[Theory]
[InlineData("suppressed")]
[InlineData("missing-lead")]
[InlineData("retention-unknown")]
[InlineData("cutoff")]
public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault)
{
var part = Fixture()[0];
var expected = CuttingPlanStatus.UnsupportedGeometry;
switch (fault)
{
case "suppressed": ((Motion)part.Program.Codes[2]).Suppressed = true; break;
case "missing-lead":
part.Program.Codes.RemoveAt(1);
((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
expected = CuttingPlanStatus.ConstraintConflict; break;
case "retention-unknown": ((LinearMove)part.Program.Codes[^2]).EndPoint = new Vector(4, 2.25); break;
case "cutoff": part.BaseDrawing.IsCutOff = true; break;
}
var unchanged = Unchanged([part]);
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
Assert.Equal(expected, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.NotEmpty(result.Findings);
unchanged();
}
[Fact]
public void Plan_BudgetExhaustionHasNoFallbackOrMutation()
{
var parts = Fixture();
var unchanged = Unchanged(parts);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, expansionBudget: 1));
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
Assert.Equal(1, result.Expansions);
Assert.Empty(result.ProposedOrder);
Assert.False(result.IndependentlyReplayed);
unchanged();
}
[Fact]
public void Plan_CancelledCaptureAndWorkerHaveNoMutation()
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var unchanged = Unchanged(parts);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.Equal(CuttingPlanStatus.Cancelled,
CuttingPlanService.Plan(new CuttingPlanRequest(parts), cancellation.Token).Status);
Assert.Equal(CuttingPlanStatus.Cancelled, CuttingPlanService.Plan(snapshot, cancellation.Token).Status);
unchanged();
}
[Fact]
public void Snapshot_OwnsMotionsPosesLocksAndOrder_NotLiveProgramsOrSettings()
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var ownedEnds = snapshot.Placements.SelectMany(p => p.Execution.Motions.Select(m => m.End)).ToArray();
foreach (var part in parts)
{
part.Program.Codes.Clear();
part.BaseDrawing.Program.Codes.Clear();
part.Location = new Vector(999, 999);
part.CuttingParameters.TabsEnabled = true;
part.LeadInsLocked = true;
}
Array.Reverse(parts);
var result = CuttingPlanService.Plan(snapshot);
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(new[] { 1, 0, 2 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
Assert.Equal(ownedEnds, snapshot.Placements.SelectMany(p => p.Execution.Motions.Select(m => m.End)));
Assert.Equal(new Vector(4, 0), snapshot.Placements[0].Location);
Assert.All(snapshot.Placements, p => Assert.False(p.LeadInsLocked));
Assert.True(((ICollection<FixedProgramPlacement>)snapshot.Placements).IsReadOnly);
Assert.True(((ICollection<ExecutionMotion>)snapshot.Placements[0].Execution.Motions).IsReadOnly);
Assert.True(((ICollection<FixedProgramPlacement>)result.ProposedOrder).IsReadOnly);
}
[Fact]
public void Plan_DeterministicReferenceIdentity_NotDrawingNamesOrNameEquality()
{
var parts = Fixture();
foreach (var part in parts)
part.BaseDrawing.Name = "same";
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
for (var repeat = 0; repeat < 5; repeat++)
Assert.Equal(new[] { parts[1], parts[0], parts[2] },
CuttingPlanService.Plan(snapshot).ProposedOrder.Select(p => p.SourcePart));
Assert.Equal(CuttingPlanStatus.InvalidInput,
CuttingPlanService.Plan(new CuttingPlanRequest([parts[0], parts[0]])).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(new CuttingPlanRequest([])).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput,
CuttingPlanService.Plan(new CuttingPlanRequest(parts, expansionBudget: 0)).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput,
CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(double.NaN, 0))).Status);
}
[Fact]
public void ExplicitStart_UsesActualLeadoutDeparture_AndDefaultRemainsZero()
{
var part = Fixture()[1];
var defaultResult = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
var explicitResult = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(2.25, 2)));
Assert.Equal(CuttingPlanStatus.Ready, defaultResult.Status);
Assert.Equal(CuttingPlanStatus.Ready, explicitResult.Status);
Assert.Equal(Vector.Zero.DistanceTo(new Vector(2.25, 2)), defaultResult.RapidDistance, 10);
Assert.Equal(0, explicitResult.RapidDistance);
Assert.Equal(new Vector(2.25, 2), explicitResult.ProposedOrder[0].Execution.DeparturePoint);
Assert.Empty(Analyze(part).Findings);
Assert.Empty(AnalyzeFrom(Vector.Zero, part).Findings);
}
[Theory]
[InlineData("unsafe")]
[InlineData("duplicate")]
[InlineData("omitted")]
[InlineData("unknown")]
public void IndependentReplay_RefusesUnsafeOrIncompleteProposals(string fault)
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var unchanged = Unchanged(parts);
var order = fault switch
{
"unsafe" => new[] { 0, 1, 2 },
"duplicate" => [1, 0, 0],
"omitted" => [1, 0],
_ => [1, 0, 3]
};
var result = CuttingPlanService.Replay(snapshot, order, 0, default);
Assert.Equal(fault == "unsafe" ? CuttingPlanStatus.ConstraintConflict : CuttingPlanStatus.InvalidInput,
result.Status);
Assert.Empty(result.ProposedOrder);
Assert.False(result.IndependentlyReplayed);
if (fault == "unsafe")
{
Assert.Equal(new int?[] { 1, 2 }, result.Findings.Select(f => f.SourceOrdinal));
Assert.All(result.Findings, f => Assert.Same(parts[0], f.OtherSourcePart));
}
unchanged();
}
[Fact]
public void EqualRapidDistance_TiesKeepSourceOrdinal()
{
var parts = new[] { Rectangle("same", 10, -3, 2, 2), Rectangle("same", 10, 1, 2, 2) };
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(parts, result.ProposedOrder.Select(p => p.SourcePart));
Assert.Empty(Analyze(parts).Findings);
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void OwnedReader_SharedSignedSubcallsPreserveTrueLeadoutDepartureAndOwnership(Mode mode)
{
var sub = new Program();
sub.MoveTo(1.25, 0);
sub.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
sub.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
sub.Codes.Add(new LinearMove(1.25, 0) { Layer = LayerType.Leadout });
sub.Mode = mode;
var program = new Program();
program.SubPrograms[-7] = sub;
program.Codes.Add(new SubProgramCall { Id = -7, Program = sub, Offset = new Vector(5, 5) });
program.Codes.Add(new SubProgramCall { Id = -7, Program = sub, Offset = new Vector(15, 5) });
var before = sub.ToString();
var owned = ExecutionMotionReader.Read(program, new Vector(100, 50), Vector.Zero, default);
Assert.Equal(new Vector(116.25, 55), owned.DeparturePoint);
Assert.Equal(new Vector(106.25, 55), owned.Motions[3].End);
Assert.Empty(new ReleasedContourState().Check(owned, Vector.Zero, 1));
sub.Codes.Clear();
program.Codes.Clear();
Assert.Equal(new Vector(116.25, 55), owned.DeparturePoint);
Assert.Empty(new ReleasedContourState().Check(owned, Vector.Zero, 1));
Assert.NotEmpty(before);
}
[Fact]
public void Check_CopiedReleasedStateDoesNotLeakBranchObstacles()
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var parent = new ReleasedContourState();
var branch = parent.Copy();
Assert.Empty(branch.Check(snapshot.Placements[0].Execution, Vector.Zero, 1));
Assert.NotEmpty(branch.Check(snapshot.Placements[1].Execution, new Vector(8.25, 2), 2));
Assert.Empty(parent.Check(snapshot.Placements[1].Execution, new Vector(8.25, 2), 2));
}
private static Action Unchanged(Part[] parts)
{
var order = parts.ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var codeRefs = parts.Select(p => p.Program.Codes.ToArray()).ToArray();
var text = parts.Select(p => p.Program.ToString()).ToArray();
var locations = parts.Select(p => p.Location).ToArray();
var rotations = parts.Select(p => p.Rotation).ToArray();
var bounds = parts.Select(p => p.BoundingBox).ToArray();
var parameters = parts.Select(p => p.CuttingParameters).ToArray();
var locks = parts.Select(p => p.LeadInsLocked).ToArray();
var manual = parts.Select(p => p.HasManualLeadIns).ToArray();
var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
var subs = parts.SelectMany(p => p.Program.Codes.OfType<SubProgramCall>())
.Select(call => (call, call.Program, text: call.Program.ToString(), call.Offset, call.Rotation, call.Id)).ToArray();
return () =>
{
Assert.Equal(order, parts);
for (var index = 0; index < parts.Length; index++)
{
var part = parts[index];
Assert.Same(programs[index], part.Program);
Assert.Equal(codeRefs[index], part.Program.Codes);
Assert.Equal(text[index], part.Program.ToString());
Assert.Equal(locations[index], part.Location);
Assert.Equal(rotations[index], part.Rotation);
Assert.Same(bounds[index], part.BoundingBox);
Assert.Same(parameters[index], part.CuttingParameters);
Assert.Equal(locks[index], part.LeadInsLocked);
Assert.Equal(manual[index], part.HasManualLeadIns);
Assert.Equal(quantities[index], part.BaseDrawing.Quantity.Nested);
}
foreach (var sub in subs)
{
Assert.Same(sub.Program, sub.call.Program);
Assert.Equal(sub.text, sub.call.Program.ToString());
Assert.Equal(sub.Offset, sub.call.Offset);
Assert.Equal(sub.Rotation, sub.call.Rotation);
Assert.Equal(sub.Id, sub.call.Id);
}
};
}
private static Part[] Fixture() =>
[
Rectangle("A", 4, 0, 4, 4),
Rectangle("B", 0, 1, 2, 2),
Rectangle("C", 10, 1, 2, 2)
];
private static Part Rectangle(string name, double x, double y, double width, double height)
{
var clean = new Program();
clean.MoveTo(width, height / 2);
Contour(clean, width, height);
var part = new Part(new Drawing(name, clean), new Vector(x, y));
var placed = new Program();
placed.MoveTo(width + 0.25, height / 2);
placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
Contour(placed, width, height);
placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
Assert.True(part.RestoreLeadInProgram(placed, false));
part.CuttingParameters = new CuttingParameters();
return part;
}
private static void Contour(Program program, double width, double height)
{
program.LineTo(width, 0);
program.LineTo(0, 0);
program.LineTo(0, height);
program.LineTo(width, height);
program.LineTo(width, height / 2);
}
// Independent replay uses private placements/drawings, never an event-wired plate
// around the source drawings (which would change their quantity accounting).
private static PostVerificationReport Analyze(params Part[] parts) => AnalyzeFrom(Vector.Zero, parts);
private static PostVerificationReport AnalyzeFrom(Vector start, params Part[] parts)
{
var nest = new Nest();
var plate = nest.CreatePlate();
foreach (var source in parts)
{
var copy = new Part(new Drawing("replay", (Program)source.BaseDrawing.Program.Clone()), source.Location);
Assert.True(copy.RestoreLeadInProgram((Program)source.Program.Clone(), source.LeadInsLocked));
plate.Parts.Add(copy);
}
return PostVerificationAnalyzer.Analyze(nest, start);
}
}
+84
View File
@@ -0,0 +1,84 @@
# Fixed-program cutting-route foundation
`OpenNest.Engine.CuttingPlanning.CuttingPlanService` currently plans a direct-XY
route through fixed, contiguous whole-part programs. This is the first foundation
of the unified cutting planner, not a replacement desktop command or an Apply API.
Existing desktop sequencing, lead assignment and per-attempt posting review are
unchanged.
## Calling the service
Create a `CuttingPlanRequest` from source placements and an explicit modeled start
point, then call `Capture` while their order, programs and poses are stable. Pass
the resulting `CuttingPlanSnapshot` to `Plan` on a worker. `Plan(request)` combines
both steps for synchronous callers. The default start is `Vector.Zero`; this is
not a discovered controller position. The diagnostic overload
`PostVerificationAnalyzer.Analyze(nest, startPoint)` uses the same modeled start.
Capture reads absolute/incremental instructions and shared subprogram calls into
owned immutable motion values. Placed rotations are already baked into programs;
placement translation is applied once. It does not clone recursive graphs, rebind
subcalls through rotating setters, or attach live drawings to preview plates.
Source `Part` references are identity handles only; worker planning never reads
their mutable state. A captured snapshot is deliberately historical, not a
freshness check against later edits.
Both locked and unlocked programs remain fixed. Search changes only the proposed
whole-part order and never edits source order, programs, settings, locks, poses or
quantity accounting. Branches are ranked by modeled rapid distance and source
ordinal, with bounded deterministic backtracking. The expansion budget defaults
to 20,000 attempted placements. Actual departure motions, including lead-outs and
subprograms, determine the next approach.
## Results and refusal
`Ready` means only that every captured placement occurs once and the full
fixed-program route was replayed with a fresh completed-contour checker without
rapid, missing-lead or incomplete-motion findings. `IndependentlyReplayed` records
that replay; it does not certify final NC or machine safety. Result ordinals and
finding identities refer to the original zero-based source list, not the proposed
sequence positions.
The checker uses the same native line/arc contact, contour completion and actual
gap semantics as pre-post diagnostics. Future contours are not yet obstacles;
completed holes in the same part are obstacles immediately. Stale tab settings
are not evidence of retention.
- `ConstraintConflict`: a fixed internal program or all explored whole-part orders
violate the modeled route constraints. Reordering cannot repair a fixed rapid
crossing its own completed hole.
- `UnsupportedGeometry`: unsupported motion semantics or incomplete retention
checks. Cutoff dependencies and scribe-only drawings are outside this slice.
- `InvalidInput`: malformed/missing/duplicate placements, empty input, invalid
geometry or a nonpositive budget.
- `NoSolutionWithinBudget`: search reached its bound, not proof that no route exists.
- `Cancelled`: capture or worker cancellation, with no live mutation.
Every non-ready result has no proposed order and no unsafe fallback.
## Limits and next hardening
This service does not check clean-material overlap, enclosing-hole/insert
release dependencies, contour coverage against clean geometry, lead paths through
other material, cutoff order, or physical retention strength. It does not generate
entries/leads, change internal contour order, install programs atomically, check
staleness at Apply, write CNC, or set posting consent. A `Ready` fixed-program route
can still be unsuitable for cutting. Do not apply it as a complete cutting plan.
Next slices must add explicit contour emission and lead validation, containment
and cutoff dependencies, exact freshness/atomic application, then desktop caller
migration and legacy retirement. Native Windows interaction, supplied-job routing
coverage and actual posted order remain separate acceptance gates. Fresh
[pre-post verification](post-verification.md) is still required at posting
boundaries, and it is not a physical safety qualification.
## Portable regression gate
```sh
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~CuttingPlanning|FullyQualifiedName~PostVerificationAnalyzerTests'
```
The synthetic three-part fixture starts with A,B,C crossing completed A; B,A,C
replays without findings. Controls cover a locked internal-hole crossing,
backtracking, native shared-hole motions, bounded refusal, cancellation, immutable
ownership, source-reference identity and complete-proposal replay rejection.
+3 -1
View File
@@ -84,7 +84,9 @@ The overlap diagnostic's documented numeric/tessellation limits also apply.
Future hardening should inspect a shared post-specific emitted motion stream,
including controller retracts and final parking, rather than pretending direct XY
geometry proves physical head clearance.
geometry proves physical head clearance. The [fixed-program route foundation](cutting-planner.md)
reuses these motion/completion checks but is not yet a desktop planner or an Apply
API; its route-only `Ready` result does not replace fresh posting review.
## Verification on Windows