mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-04 11:22:12 -04:00
feat(cutting): plan verified part order from fixed programs
This commit is contained in:
1 parent
c49c387af5
commit
761cee7c1f
10 files changed
+1098
-175
No files matched your search
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user