diff --git a/OpenNest.Core/CNC/CuttingPlanning/ExecutionMotionReader.cs b/OpenNest.Core/CNC/CuttingPlanning/ExecutionMotionReader.cs
new file mode 100644
index 0000000..7393597
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/ExecutionMotionReader.cs
@@ -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;
+
+/// Guarded read of stable placed programs; never clones or mutates their graphs.
+public static class ExecutionMotionReader
+{
+ public static OwnedExecution Read(Program program, Vector origin, Vector? previous,
+ CancellationToken token)
+ {
+ var moves = new List();
+ var visiting = new HashSet(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;
+ }
+ }
+
+}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/OwnedExecution.cs b/OpenNest.Core/CNC/CuttingPlanning/OwnedExecution.cs
new file mode 100644
index 0000000..e3fef39
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/OwnedExecution.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+
+namespace OpenNest.CNC.CuttingPlanning;
+
+/// Owned value motions; no references to live programs, instructions or settings.
+public sealed class OwnedExecution
+{
+ internal OwnedExecution(IEnumerable motions)
+ {
+ Motions = Array.AsReadOnly(motions.ToArray());
+ }
+
+ public IReadOnlyList 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;
+ }
+}
+
+/// An immutable motion value; native line/arc geometry stays private to Core.
+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);
+}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
new file mode 100644
index 0000000..8d1ab15
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
@@ -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;
+
+/// Direct XY completed-contour checker. This is not physical machine safety.
+public sealed class ReleasedContourState
+{
+ private readonly List obstacles = new();
+
+ public ReleasedContourState Copy()
+ {
+ var copy = new ReleasedContourState();
+ copy.obstacles.AddRange(obstacles);
+ return copy;
+ }
+
+ /// Consumes one whole owned program; part numbers are caller identity keys.
+ public IReadOnlyList 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();
+ AnalyzeMoves(moves, false, findings, 1, partNumber, token);
+ return findings.AsReadOnly();
+ }
+
+ internal void AnalyzeMoves(IReadOnlyList moves, bool cutoff,
+ List findings, int plate, int part, CancellationToken token)
+ {
+ var contour = new List();
+ var unfinished = new List();
+ 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 Curves);
+}
diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
index 134238d..aa5097f 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
@@ -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();
if (nest.Plates == null)
@@ -50,8 +56,8 @@ public static class PostVerificationAnalyzer
}
var materialParts = new List();
var indices = new List();
- var obstacles = new List();
- 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 Read(Program program, Vector origin, Vector? previous,
- CancellationToken token)
- {
- var moves = new List();
- var visiting = new HashSet(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 moves, bool cutoff, List obstacles,
- List findings, int plate, int part, CancellationToken token)
- {
- var contour = new List();
- var unfinished = new List();
- 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 Curves);
}
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
new file mode 100644
index 0000000..9d451ec
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
@@ -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;
+
+///
+/// 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.
+///
+public sealed class CuttingPlanRequest
+{
+ public CuttingPlanRequest(IEnumerable parts, Vector startPoint = default, int expansionBudget = 20000)
+ {
+ Parts = parts == null ? null : Array.AsReadOnly(parts.ToArray());
+ StartPoint = startPoint;
+ ExpansionBudget = expansionBudget;
+ }
+
+ public IReadOnlyList Parts { get; }
+ public Vector StartPoint { get; }
+ public int ExpansionBudget { get; }
+}
+
+/// Owned worker input. SourcePart references are identity handles only, never worker data.
+public sealed class CuttingPlanSnapshot
+{
+ internal CuttingPlanSnapshot(IEnumerable placements, Vector startPoint,
+ int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable findings = null)
+ {
+ Placements = Array.AsReadOnly(placements.ToArray());
+ StartPoint = startPoint;
+ ExpansionBudget = expansionBudget;
+ Failure = failure;
+ Findings = Array.AsReadOnly((findings ?? []).ToArray());
+ }
+
+ public IReadOnlyList Placements { get; }
+ public Vector StartPoint { get; }
+ public int ExpansionBudget { get; }
+ internal CuttingPlanStatus? Failure { get; }
+ internal IReadOnlyList 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
+}
+
+/// Ordinals are zero-based source positions, not proposed sequence positions.
+public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart,
+ int? OtherSourceOrdinal, Part OtherSourcePart, PostVerificationKind? Kind, string Message);
+
+///
+/// A checked fixed-program route, not full cutting-plan readiness, posting consent,
+/// physical safety, or an atomic Apply payload. Failures contain no proposed order.
+///
+public sealed class CuttingPlanResult
+{
+ internal CuttingPlanResult(CuttingPlanStatus status, IEnumerable order = null,
+ IEnumerable 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 ProposedOrder { get; }
+ public IReadOnlyList Findings { get; }
+ public int Expansions { get; }
+ public double RapidDistance { get; }
+ public bool IndependentlyReplayed { get; }
+}
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
new file mode 100644
index 0000000..f00663b
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
@@ -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
+{
+ ///
+ /// Read stable caller-owned sources once, before dispatching worker work. No Program.Clone,
+ /// private Plates, settings aliases, quantity updates, or subcall rebinding are involved.
+ ///
+ public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default)
+ {
+ var placements = new List();
+ 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(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);
+
+ /// Worker-only planning uses owned values; live identities are never dereferenced.
+ 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();
+ 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 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();
+ 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 Map(CuttingPlanSnapshot snapshot,
+ IEnumerable 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);
+ });
+}
diff --git a/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs b/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs
new file mode 100644
index 0000000..00a555e
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs
@@ -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;
+
+/// Bounded forward depth-first search, nearest feasible branch then source ordinal.
+internal static class FixedProgramSearch
+{
+ internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList Order,
+ IReadOnlyList Findings, int Expansions);
+
+ internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
+ {
+ var stack = new Stack();
+ stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState()));
+ var rejected = new HashSet();
+ 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; }
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs
new file mode 100644
index 0000000..ec4313c
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs
@@ -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)snapshot.Placements).IsReadOnly);
+ Assert.True(((ICollection)snapshot.Placements[0].Execution.Motions).IsReadOnly);
+ Assert.True(((ICollection)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())
+ .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);
+ }
+}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
new file mode 100644
index 0000000..edff6df
--- /dev/null
+++ b/docs/cutting-planner.md
@@ -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.
diff --git a/docs/post-verification.md b/docs/post-verification.md
index e53deb1..704a621 100644
--- a/docs/post-verification.md
+++ b/docs/post-verification.md
@@ -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