feat(posting): require verification and explicit risk acknowledgment
This commit is contained in:
+36
-12
@@ -7,6 +7,7 @@ using System.Linq;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest;
|
using OpenNest;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
using OpenNest.Engine;
|
using OpenNest.Engine;
|
||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.Engine.Jobs.Adapters;
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
@@ -120,8 +121,8 @@ static class NestConsole
|
|||||||
var overlapCount = CheckOverlaps(plate, options);
|
var overlapCount = CheckOverlaps(plate, options);
|
||||||
|
|
||||||
PrintResults(success, plate, elapsed);
|
PrintResults(success, plate, elapsed);
|
||||||
Save(nest, options);
|
if (!SaveAndPost(nest, options))
|
||||||
PostProcess(nest, options);
|
return 1;
|
||||||
|
|
||||||
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
|
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
|
||||||
}
|
}
|
||||||
@@ -194,6 +195,9 @@ static class NestConsole
|
|||||||
case "--post" when i + 1 < args.Length:
|
case "--post" when i + 1 < args.Length:
|
||||||
o.PostName = args[++i];
|
o.PostName = args[++i];
|
||||||
break;
|
break;
|
||||||
|
case "--acknowledge-post-risks":
|
||||||
|
o.AcknowledgePostRisks = true;
|
||||||
|
break;
|
||||||
case "--post-output" when i + 1 < args.Length:
|
case "--post-output" when i + 1 < args.Length:
|
||||||
o.PostOutput = args[++i];
|
o.PostOutput = args[++i];
|
||||||
break;
|
break;
|
||||||
@@ -557,18 +561,16 @@ static class NestConsole
|
|||||||
if (options.NoSave)
|
if (options.NoSave)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var firstInput = options.InputFiles[0];
|
var outputFile = NestOutputPath(options);
|
||||||
var outputFile =
|
|
||||||
options.OutputFile
|
|
||||||
?? Path.Combine(
|
|
||||||
Path.GetDirectoryName(firstInput),
|
|
||||||
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}"
|
|
||||||
);
|
|
||||||
|
|
||||||
new NestWriter(nest).Write(outputFile);
|
new NestWriter(nest).Write(outputFile);
|
||||||
Console.WriteLine($"Saved: {outputFile}");
|
Console.WriteLine($"Saved: {outputFile}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string NestOutputPath(Options options) => options.OutputFile
|
||||||
|
?? Path.Combine(Path.GetDirectoryName(options.InputFiles[0]),
|
||||||
|
$"{Path.GetFileNameWithoutExtension(options.InputFiles[0])}-result{NestFormat.FileExtension}");
|
||||||
|
|
||||||
static string ResolvePostsDir(Options options)
|
static string ResolvePostsDir(Options options)
|
||||||
{
|
{
|
||||||
if (options.PostsDir != null)
|
if (options.PostsDir != null)
|
||||||
@@ -633,10 +635,13 @@ static class NestConsole
|
|||||||
Console.WriteLine($" {p.Name,-30} {p.Description}");
|
Console.WriteLine($" {p.Name,-30} {p.Description}");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void PostProcess(Nest nest, Options options)
|
static bool SaveAndPost(Nest nest, Options options)
|
||||||
{
|
{
|
||||||
if (options.PostName == null)
|
if (options.PostName == null)
|
||||||
return;
|
{
|
||||||
|
Save(nest, options);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
var postsDir = ResolvePostsDir(options);
|
var postsDir = ResolvePostsDir(options);
|
||||||
var processors = LoadPostProcessors(postsDir);
|
var processors = LoadPostProcessors(postsDir);
|
||||||
@@ -655,7 +660,15 @@ static class NestConsole
|
|||||||
else
|
else
|
||||||
Console.Error.WriteLine($"No post processors found in: {postsDir}");
|
Console.Error.WriteLine($"No post processors found in: {postsDir}");
|
||||||
|
|
||||||
return;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var verification = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
|
||||||
|
Console.WriteLine(verification.ToDisplayText());
|
||||||
|
if (!verification.CanPost(options.AcknowledgePostRisks))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Posting blocked: review these warnings. To accept the risks, including possible head crashes and machine or material damage, explicitly use --acknowledge-post-risks for this invocation.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var outputFile = options.PostOutput;
|
var outputFile = options.PostOutput;
|
||||||
@@ -673,9 +686,18 @@ static class NestConsole
|
|||||||
? multiFile.GetOutputFiles(nest, outputFile)
|
? multiFile.GetOutputFiles(nest, outputFile)
|
||||||
: new[] { outputFile };
|
: new[] { outputFile };
|
||||||
|
|
||||||
|
if (!options.NoSave && outputFiles.Any(file => string.Equals(
|
||||||
|
Path.GetFullPath(file), Path.GetFullPath(NestOutputPath(options)), StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Error: nest save and CNC output paths must be different. No output was written.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Save(nest, options);
|
||||||
post.Post(nest, outputFile);
|
post.Post(nest, outputFile);
|
||||||
foreach (var file in outputFiles)
|
foreach (var file in outputFiles)
|
||||||
Console.WriteLine($"Post: {post.Name} -> {file}");
|
Console.WriteLine($"Post: {post.Name} -> {file}");
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void PrintUsage()
|
static void PrintUsage()
|
||||||
@@ -710,6 +732,7 @@ static class NestConsole
|
|||||||
--no-save Skip saving output file
|
--no-save Skip saving output file
|
||||||
--post <name> Run a post processor after nesting
|
--post <name> Run a post processor after nesting
|
||||||
--post-output <path> Output file for post processor (default: <input>.cnc)
|
--post-output <path> Output file for post processor (default: <input>.cnc)
|
||||||
|
--acknowledge-post-risks Explicitly accept displayed verification risks for this invocation
|
||||||
--posts-dir <path> Directory containing post processor DLLs (default: Posts/)
|
--posts-dir <path> Directory containing post processor DLLs (default: Posts/)
|
||||||
--list-posts List available post processors and exit
|
--list-posts List available post processors and exit
|
||||||
-h, --help Show this help
|
-h, --help Show this help
|
||||||
@@ -733,6 +756,7 @@ static class NestConsole
|
|||||||
public string Engine = "Default";
|
public string Engine = "Default";
|
||||||
public string TemplateFile;
|
public string TemplateFile;
|
||||||
public string PostName;
|
public string PostName;
|
||||||
|
public bool AcknowledgePostRisks;
|
||||||
public string PostOutput;
|
public string PostOutput;
|
||||||
public string PostsDir;
|
public string PostsDir;
|
||||||
public bool ListPosts;
|
public bool ListPosts;
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only pre-post diagnostics. The caller must keep the nest stable for the entire call.
|
||||||
|
/// Placed programs are already rotated; only their placement translation is applied here.
|
||||||
|
/// This intentionally does not invoke a post or promise machine collision avoidance.
|
||||||
|
/// </summary>
|
||||||
|
public static class PostVerificationAnalyzer
|
||||||
|
{
|
||||||
|
public static PostVerificationReport AnalyzeForPost(Nest nest, IPostProcessor postProcessor,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var report = Analyze(nest, cancellationToken);
|
||||||
|
if (postProcessor is IPostVerificationSupport { PreservesPlacedProgramOrder: true })
|
||||||
|
return report;
|
||||||
|
var findings = report.Findings.ToList();
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, 0, null, null,
|
||||||
|
$"Post '{postProcessor?.Name ?? "unknown"}' does not declare that it preserves placed part/contour order " +
|
||||||
|
"and pierce positions. Nest-level checks ran, but the final rapid sequence requires manual review."));
|
||||||
|
return new PostVerificationReport(findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(nest);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var findings = new List<PostVerificationFinding>();
|
||||||
|
if (nest.Plates == null)
|
||||||
|
{
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, 1, null, null, "Nest has no plate collection."));
|
||||||
|
return new PostVerificationReport(findings);
|
||||||
|
}
|
||||||
|
for (var plateIndex = 0; plateIndex < nest.Plates.Count; plateIndex++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var plate = nest.Plates[plateIndex];
|
||||||
|
var plateNumber = plateIndex + 1;
|
||||||
|
if (plate?.Parts == null)
|
||||||
|
{
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||||
|
"Plate has no part collection."));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var materialParts = new List<Part>();
|
||||||
|
var indices = new List<int>();
|
||||||
|
var obstacles = new List<Obstacle>();
|
||||||
|
Vector? position = Vector.Zero;
|
||||||
|
for (var index = 0; index < plate.Parts.Count; index++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var part = plate.Parts[index];
|
||||||
|
var partNumber = index + 1;
|
||||||
|
var cutoff = part?.BaseDrawing?.IsCutOff == true;
|
||||||
|
var expectsCuts = cutoff;
|
||||||
|
// Preflight even clean programs before calling the overlap converter: malformed
|
||||||
|
// recursive graphs must never reach Program.Clone or unguarded conversion.
|
||||||
|
if (!cutoff)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var clean = Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken);
|
||||||
|
expectsCuts = clean.Any(move => !move.Rapid && move.Layer != LayerType.Scribe);
|
||||||
|
if (!clean.Any(move => move.Layer == LayerType.Scribe)
|
||||||
|
|| expectsCuts)
|
||||||
|
{
|
||||||
|
materialParts.Add(part);
|
||||||
|
indices.Add(partNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
|
{
|
||||||
|
Incomplete("Overlap check: " + exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (part == null || !double.IsFinite(part.Rotation))
|
||||||
|
throw new ArgumentException("Missing part or invalid rotation.");
|
||||||
|
var moves = Read(part.Program, part.Location, position, cancellationToken);
|
||||||
|
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);
|
||||||
|
position = moves[^1].End;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
|
{
|
||||||
|
Incomplete("Lead-in/rapid check: " + exception.Message);
|
||||||
|
// Subsequent internal moves can still be checked, but the incoming segment
|
||||||
|
// cannot be reconstructed after an invalid program.
|
||||||
|
position = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Incomplete(string message) => findings.Add(new(PostVerificationKind.Incomplete,
|
||||||
|
plateNumber, partNumber, null, message));
|
||||||
|
}
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var overlap = PlateOverlapAnalyzer.Analyze(
|
||||||
|
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
|
||||||
|
foreach (var pair in overlap.Pairs)
|
||||||
|
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||||
|
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted)."));
|
||||||
|
foreach (var issue in overlap.Issues)
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
|
||||||
|
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
|
||||||
|
}
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return new PostVerificationReport(findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,173 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>Local native line/arc queries, without changing the engine's geometry semantics.</summary>
|
||||||
|
internal static class PostVerificationGeometry
|
||||||
|
{
|
||||||
|
internal const double Epsilon = 1e-8;
|
||||||
|
private const double TwoPi = 2 * System.Math.PI;
|
||||||
|
|
||||||
|
internal static void Validate(Vector point)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y)
|
||||||
|
|| System.Math.Abs(point.X) > 1e12 || System.Math.Abs(point.Y) > 1e12)
|
||||||
|
throw new ArgumentException("Nonfinite or numerically unsupported program coordinates.");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool Closed(IReadOnlyList<Curve> curves) => curves.Count > 0
|
||||||
|
&& curves[0].Start.DistanceTo(curves[^1].End) <= Epsilon;
|
||||||
|
|
||||||
|
internal static bool Crosses(Vector start, Vector end, IReadOnlyList<Curve> curves,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
var delta = end - start;
|
||||||
|
var length = start.DistanceTo(end);
|
||||||
|
if (length <= Epsilon)
|
||||||
|
return false;
|
||||||
|
var direction = delta * (1 / length);
|
||||||
|
foreach (var curve in curves)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (curve.ContactAfterStart(start, direction, length))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// If there are no contacts except possibly departure, all open-segment points
|
||||||
|
// have the same inside/outside status. A midpoint catches travel entirely inside
|
||||||
|
// and departure into the interior, without flagging start-only outward contact.
|
||||||
|
var midpoint = start + delta * 0.5;
|
||||||
|
var inside = false;
|
||||||
|
foreach (var curve in curves)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (curve.CrossesRay(midpoint))
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
||||||
|
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||||
|
private static double Normalize(double angle)
|
||||||
|
{
|
||||||
|
angle %= TwoPi;
|
||||||
|
return angle < 0 ? angle + TwoPi : angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Curve
|
||||||
|
{
|
||||||
|
private Curve(Vector start, Vector end, Vector? center, double radius, double sweep)
|
||||||
|
{
|
||||||
|
Start = start;
|
||||||
|
End = end;
|
||||||
|
Center = center;
|
||||||
|
Radius = radius;
|
||||||
|
Sweep = sweep;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Vector Start { get; }
|
||||||
|
internal Vector End { get; }
|
||||||
|
private Vector? Center { get; }
|
||||||
|
private double Radius { get; }
|
||||||
|
private double Sweep { get; }
|
||||||
|
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
|
||||||
|
|
||||||
|
internal static Curve Create(Vector start, Vector end, Vector? center, bool clockwise)
|
||||||
|
{
|
||||||
|
if (center is not { } c)
|
||||||
|
return new Curve(start, end, null, 0, 0);
|
||||||
|
Validate(c);
|
||||||
|
var radius = start.DistanceTo(c);
|
||||||
|
if (radius <= Epsilon || !double.IsFinite(radius)
|
||||||
|
|| System.Math.Abs(radius - end.DistanceTo(c)) > Epsilon * System.Math.Max(1, radius))
|
||||||
|
throw new ArgumentException("Arc has zero or inconsistent radius.");
|
||||||
|
var a = System.Math.Atan2(start.Y - c.Y, start.X - c.X);
|
||||||
|
var b = System.Math.Atan2(end.Y - c.Y, end.X - c.X);
|
||||||
|
var sweep = start.DistanceTo(end) <= Epsilon ? TwoPi
|
||||||
|
: Normalize(clockwise ? a - b : b - a);
|
||||||
|
return new Curve(start, end, c, radius, clockwise ? -sweep : sweep);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double StartAngle => System.Math.Atan2(Start.Y - Center.Value.Y, Start.X - Center.Value.X);
|
||||||
|
private double Travel(double angle) => Normalize(Sweep < 0 ? StartAngle - angle : angle - StartAngle);
|
||||||
|
private bool OnArc(Vector point) => Travel(System.Math.Atan2(point.Y - Center.Value.Y,
|
||||||
|
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|
||||||
|
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
|
||||||
|
|
||||||
|
internal bool ContactAfterStart(Vector origin, Vector direction, double length)
|
||||||
|
{
|
||||||
|
if (Center is { } center)
|
||||||
|
{
|
||||||
|
// Intersect the actual circle, not an inscribed chord polygon: tangencies
|
||||||
|
// and short arcs must not vanish between tessellation vertices.
|
||||||
|
var relative = center - origin;
|
||||||
|
var projection = Dot(relative, direction);
|
||||||
|
var perpendicular = Cross(relative, direction);
|
||||||
|
var square = Radius * Radius - perpendicular * perpendicular;
|
||||||
|
if (square < -Epsilon * System.Math.Max(1, Radius * 2))
|
||||||
|
return false;
|
||||||
|
var offset = System.Math.Sqrt(System.Math.Max(0, square));
|
||||||
|
return Hit(projection - offset) || Hit(projection + offset);
|
||||||
|
|
||||||
|
bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon
|
||||||
|
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length));
|
||||||
|
}
|
||||||
|
var edge = End - Start;
|
||||||
|
var relativeStart = Start - origin;
|
||||||
|
var denominator = Cross(direction, edge);
|
||||||
|
if (System.Math.Abs(denominator) <= 1e-12 * System.Math.Max(1, Length))
|
||||||
|
{
|
||||||
|
if (System.Math.Abs(Cross(relativeStart, direction)) > Epsilon)
|
||||||
|
return false;
|
||||||
|
var a = Dot(relativeStart, direction);
|
||||||
|
var b = Dot(End - origin, direction);
|
||||||
|
var low = System.Math.Max(0, System.Math.Min(a, b));
|
||||||
|
var high = System.Math.Min(length, System.Math.Max(a, b));
|
||||||
|
return high > Epsilon && low <= high + Epsilon;
|
||||||
|
}
|
||||||
|
var distanceAlongRapid = Cross(relativeStart, edge) / denominator;
|
||||||
|
var fractionAlongEdge = Cross(relativeStart, direction) / denominator;
|
||||||
|
return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon
|
||||||
|
&& fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon)
|
||||||
|
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool CrossesRay(Vector point)
|
||||||
|
{
|
||||||
|
if (Center is not { } center)
|
||||||
|
return (Start.Y > point.Y) != (End.Y > point.Y)
|
||||||
|
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
|
||||||
|
|
||||||
|
// Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
|
||||||
|
// half-open endpoint rule as a polygon ray test, solving X on the native
|
||||||
|
// circle. This handles full circles, reversed arcs and shared vertices.
|
||||||
|
var breaks = new List<double> { 0, System.Math.Abs(Sweep) };
|
||||||
|
foreach (var angle in new[] { System.Math.PI / 2, 3 * System.Math.PI / 2 })
|
||||||
|
{
|
||||||
|
var travel = Travel(angle);
|
||||||
|
if (travel > 0 && travel < System.Math.Abs(Sweep))
|
||||||
|
breaks.Add(travel);
|
||||||
|
}
|
||||||
|
breaks.Sort();
|
||||||
|
var inside = false;
|
||||||
|
for (var i = 1; i < breaks.Count; i++)
|
||||||
|
{
|
||||||
|
var a = StartAngle + System.Math.Sign(Sweep) * breaks[i - 1];
|
||||||
|
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
|
||||||
|
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
|
||||||
|
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
|
||||||
|
if ((ya > point.Y) == (yb > point.Y))
|
||||||
|
continue;
|
||||||
|
var dy = point.Y - center.Y;
|
||||||
|
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
|
||||||
|
var x = center.X + (System.Math.Cos((a + b) / 2) >= 0 ? dx : -dx);
|
||||||
|
if (x > point.X)
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
public enum PostVerificationKind
|
||||||
|
{
|
||||||
|
Overlap,
|
||||||
|
MissingLeadIn,
|
||||||
|
RapidCrossing,
|
||||||
|
Incomplete
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
||||||
|
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber,
|
||||||
|
int? PartNumber, int? OtherPartNumber, string Message);
|
||||||
|
|
||||||
|
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
|
||||||
|
public sealed class PostVerificationReport
|
||||||
|
{
|
||||||
|
internal PostVerificationReport(IEnumerable<PostVerificationFinding> findings)
|
||||||
|
{
|
||||||
|
Findings = Array.AsReadOnly(findings.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<PostVerificationFinding> Findings { get; }
|
||||||
|
public bool HasWarnings => Findings.Count != 0;
|
||||||
|
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
|
||||||
|
|
||||||
|
public string ToDisplayText()
|
||||||
|
{
|
||||||
|
var text = new StringBuilder();
|
||||||
|
text.AppendLine("Pre-post verification");
|
||||||
|
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
|
||||||
|
Summary(PostVerificationKind.Overlap, "Overlap");
|
||||||
|
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
|
||||||
|
Summary(PostVerificationKind.RapidCrossing, "Rapid crossings");
|
||||||
|
foreach (var finding in Findings)
|
||||||
|
{
|
||||||
|
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
|
||||||
|
if (finding.PartNumber is { } part)
|
||||||
|
text.Append($", part {part}");
|
||||||
|
if (finding.OtherPartNumber is { } other)
|
||||||
|
text.Append($", other part {other}");
|
||||||
|
text.AppendLine($": {finding.Kind}: {finding.Message}");
|
||||||
|
}
|
||||||
|
text.AppendLine("This is not a physical safety certification. The check uses direct XY rapids " +
|
||||||
|
"in plate/program order; the post may change order, routing or retracts. Inspect the posted " +
|
||||||
|
"machine program and machine setup. Actual contour gaps are not proof of adequate retention.");
|
||||||
|
return text.ToString();
|
||||||
|
|
||||||
|
void Summary(PostVerificationKind kind, string label)
|
||||||
|
{
|
||||||
|
var count = Findings.Count(finding => finding.Kind == kind);
|
||||||
|
text.AppendLine($"{label}: {count} warning(s)" +
|
||||||
|
(incomplete ? "; verification incomplete — do not treat as clear." : "."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional post contract for nest-level rapid verification. Opt in only when the
|
||||||
|
/// post preserves Plate.Parts order and each placed Program's contour order and
|
||||||
|
/// pierce positions. This does not certify retract height, parking or controller macros.
|
||||||
|
/// Unknown/reordering posts still get nest diagnostics, but require acknowledgment
|
||||||
|
/// that their final rapid sequence has not been verified.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPostVerificationSupport
|
||||||
|
{
|
||||||
|
bool PreservesPlacedProgramOrder { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Diagnostics;
|
||||||
|
|
||||||
|
public class ConsolePostVerificationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RealConsoleBlocksOutputUntilExplicitRiskSwitchAndNeverRemembersConsent()
|
||||||
|
{
|
||||||
|
var directory = Path.Combine(Path.GetTempPath(), "opennest-post-verification-" + Guid.NewGuid());
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var input = Path.Combine(directory, "job.nest");
|
||||||
|
var output = Path.Combine(directory, "job.cnc");
|
||||||
|
var nest = new Nest("VerificationFixture");
|
||||||
|
var program = new OpenNest.CNC.Program();
|
||||||
|
program.Codes.Add(new RapidMove(0, 0));
|
||||||
|
program.Codes.Add(new LinearMove(2, 0));
|
||||||
|
program.Codes.Add(new LinearMove(2, 2));
|
||||||
|
program.Codes.Add(new LinearMove(0, 2));
|
||||||
|
program.Codes.Add(new LinearMove(0, 0));
|
||||||
|
var drawing = new Drawing("square", program);
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
var plate = nest.CreatePlate();
|
||||||
|
plate.Size = new Size(10, 10);
|
||||||
|
plate.Parts.Add(new Part(drawing));
|
||||||
|
Assert.True(new NestWriter(nest).Write(input));
|
||||||
|
var args = new[] { input, "--quantity", "1", "--no-save", "--post", "Cincinnati CL-707",
|
||||||
|
"--posts-dir", AppContext.BaseDirectory, "--post-output", output };
|
||||||
|
|
||||||
|
Assert.Equal(1, Run(args));
|
||||||
|
Assert.False(File.Exists(output));
|
||||||
|
|
||||||
|
File.WriteAllText(output, "existing CNC must survive refusal");
|
||||||
|
Assert.Equal(1, Run(args));
|
||||||
|
Assert.Equal("existing CNC must survive refusal", File.ReadAllText(output));
|
||||||
|
|
||||||
|
var colliding = args.Where(arg => arg != "--no-save")
|
||||||
|
.Concat(new[] { "--output", Path.Combine(directory, ".", "job.cnc") }).ToArray();
|
||||||
|
Assert.Equal(1, Run(colliding));
|
||||||
|
Assert.Equal("existing CNC must survive refusal", File.ReadAllText(output));
|
||||||
|
Assert.Equal(1, Run(colliding.Append("--acknowledge-post-risks").ToArray()));
|
||||||
|
Assert.Equal("existing CNC must survive refusal", File.ReadAllText(output));
|
||||||
|
|
||||||
|
Assert.Equal(0, Run(args.Append("--acknowledge-post-risks").ToArray()));
|
||||||
|
var posted = File.ReadAllText(output);
|
||||||
|
Assert.Contains("M30", posted);
|
||||||
|
Assert.Contains("VerificationFixture", posted);
|
||||||
|
|
||||||
|
Assert.Equal(1, Run(args));
|
||||||
|
Assert.Equal(posted, File.ReadAllText(output));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Run(string[] args)
|
||||||
|
{
|
||||||
|
var entry = Assembly.Load("OpenNest.Console").GetType("NestConsole")!
|
||||||
|
.GetMethod("Run", BindingFlags.Public | BindingFlags.Static)!;
|
||||||
|
return (int)entry.Invoke(null, new object[] { args })!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Diagnostics;
|
||||||
|
|
||||||
|
public class PostVerificationAnalyzerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EmptyReport_ListsAllChecksAndLimits()
|
||||||
|
{
|
||||||
|
var report = PostVerificationAnalyzer.Analyze(new Nest());
|
||||||
|
Assert.Empty(report.Findings);
|
||||||
|
Assert.False(report.HasWarnings);
|
||||||
|
Assert.True(report.CanPost(false));
|
||||||
|
Assert.True(report.CanPost(true));
|
||||||
|
var text = report.ToDisplayText();
|
||||||
|
Assert.Contains("Overlap", text);
|
||||||
|
Assert.Contains("Missing lead-ins", text);
|
||||||
|
Assert.Contains("Rapid crossings", text);
|
||||||
|
Assert.Contains("not a physical safety certification", text);
|
||||||
|
Assert.Contains("retract", text);
|
||||||
|
Assert.Contains("routing", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Overlap_UsesCleanMaterialAndOriginalOneBasedIndices()
|
||||||
|
{
|
||||||
|
var a = Rectangle(0, 0, 4);
|
||||||
|
var b = Rectangle(1, 1, 1);
|
||||||
|
// Deliberately unrelated placed toolpaths: overlap must use clean drawings.
|
||||||
|
a.Program.Codes.Clear();
|
||||||
|
a.Program.MoveTo(100, 100);
|
||||||
|
a.Program.LineTo(101, 101);
|
||||||
|
var report = Analyze(Cutoff(30, 30), a, Scribe(40, 40), b);
|
||||||
|
var overlap = Assert.Single(Find(report, PostVerificationKind.Overlap));
|
||||||
|
Assert.Equal((1, 2, 4), (overlap.PlateNumber, overlap.PartNumber, overlap.OtherPartNumber));
|
||||||
|
Assert.True(report.HasWarnings);
|
||||||
|
Assert.False(report.CanPost(false));
|
||||||
|
Assert.True(report.CanPost(true));
|
||||||
|
Assert.False(report.CanPost(false)); // Consent is supplied per invocation, never latched.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Overlap_SubtractsHoles()
|
||||||
|
{
|
||||||
|
var program = Square(0, 0, 10);
|
||||||
|
program.Codes.AddRange(Square(2, 2, 6).Codes);
|
||||||
|
var frame = Part(program);
|
||||||
|
var report = Analyze(frame, Rectangle(3, 3, 1));
|
||||||
|
Assert.Empty(Find(report, PostVerificationKind.Overlap));
|
||||||
|
Assert.Empty(Find(report, PostVerificationKind.Incomplete));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingLeadIn_ManualFlagDoesNotProveCoverage()
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 4);
|
||||||
|
part.HasManualLeadIns = true;
|
||||||
|
Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingLeadIn_ActualLeadDoesNotRequireManualFlag()
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 4);
|
||||||
|
part.ApplyLeadIns(Leads(), new Vector(-2, -2));
|
||||||
|
part.HasManualLeadIns = false;
|
||||||
|
Assert.Empty(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void MissingLeadIn_NoLeadOrZeroLengthWarns(bool zeroLength)
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 4);
|
||||||
|
var parameters = Leads();
|
||||||
|
parameters.ExternalLeadIn = zeroLength ? new LineLeadIn { Length = 0 } : new NoLeadIn();
|
||||||
|
part.ApplyLeadIns(parameters, new Vector(-2, -2));
|
||||||
|
Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingLeadIn_PartialApplySingleLeavesOtherContourUncovered()
|
||||||
|
{
|
||||||
|
var program = Square(0, 0, 10);
|
||||||
|
program.Codes.AddRange(Square(2, 2, 2).Codes);
|
||||||
|
var part = Part(program);
|
||||||
|
part.ApplySingleLeadIn(Leads(), Vector.Zero,
|
||||||
|
new Line(Vector.Zero, new Vector(10, 0)), ContourType.External);
|
||||||
|
Assert.True(part.HasManualLeadIns);
|
||||||
|
Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScribeAndCutoff_AreExemptFromOverlapAndLeadInChecks()
|
||||||
|
{
|
||||||
|
var report = Analyze(Scribe(0, 0), Cutoff(0, 0));
|
||||||
|
Assert.Empty(report.Findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rapid_ThroughPreviouslyCutPartWarns()
|
||||||
|
{
|
||||||
|
var report = Analyze(Rectangle(0, 0, 4), Scribe(-2, 2), Scribe(6, 2));
|
||||||
|
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f =>
|
||||||
|
f.PartNumber == 3 && f.OtherPartNumber == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rapid_DoesNotUseFuturePartsAsObstacles()
|
||||||
|
{
|
||||||
|
var report = Analyze(Scribe(-2, 2), Scribe(6, 2), Rectangle(0, 0, 4));
|
||||||
|
Assert.Empty(Find(report, PostVerificationKind.RapidCrossing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-2, -2, false)] // Start-only departure.
|
||||||
|
[InlineData(2, 2, true)] // Departure into the interior.
|
||||||
|
[InlineData(4, 0, true)] // Along edge / arrival on another endpoint.
|
||||||
|
public void Rapid_HandlesDepartureVersusInteriorAndBoundary(double x, double y, bool warning)
|
||||||
|
{
|
||||||
|
var report = Analyze(Rectangle(0, 0, 4), Scribe(x, y));
|
||||||
|
Assert.Equal(warning, Find(report, PostVerificationKind.RapidCrossing).Any());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-2, 4, 6, 4)] // Tangent along top edge.
|
||||||
|
[InlineData(-2, 2, 0, 2)] // Endpoint arrival.
|
||||||
|
[InlineData(1, 1, 2, 2)] // Entirely inside.
|
||||||
|
public void Rapid_ConservativelyFlagsContactAndInterior(double x1, double y1, double x2, double y2)
|
||||||
|
{
|
||||||
|
var report = Analyze(Rectangle(0, 0, 4), Scribe(x1, y1), Scribe(x2, y2));
|
||||||
|
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f => f.PartNumber == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void Rapid_ActualGapNotStaleTabSettingControlsObstacle(bool staleFlag)
|
||||||
|
{
|
||||||
|
var closed = Rectangle(0, 0, 4);
|
||||||
|
closed.CuttingParameters = new CuttingParameters { TabsEnabled = staleFlag };
|
||||||
|
Assert.Contains(Find(Analyze(closed, Scribe(-2, 2), Scribe(6, 2)),
|
||||||
|
PostVerificationKind.RapidCrossing), f => f.PartNumber == 3);
|
||||||
|
|
||||||
|
var gapped = Rectangle(0, 0, 4);
|
||||||
|
((LinearMove)gapped.Program.Codes[^1]).EndPoint = new Vector(0, 0.25);
|
||||||
|
gapped.CuttingParameters = new CuttingParameters { TabsEnabled = staleFlag };
|
||||||
|
Assert.Empty(Find(Analyze(gapped, Scribe(-2, 2), Scribe(6, 2)),
|
||||||
|
PostVerificationKind.RapidCrossing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rapid_RealGeneratedTabRemainsGapWithStaleDisabledParameters()
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 4);
|
||||||
|
var parameters = Leads();
|
||||||
|
parameters.TabsEnabled = true;
|
||||||
|
parameters.TabConfig = new NormalTab { Size = 0.25 };
|
||||||
|
part.ApplyLeadIns(parameters, new Vector(-2, 0));
|
||||||
|
parameters.TabsEnabled = false;
|
||||||
|
Assert.Empty(Find(Analyze(part, Scribe(-2, 2), Scribe(6, 2)), PostVerificationKind.RapidCrossing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rapid_CutoffTravelStillCrossesEarlierRealCuts()
|
||||||
|
{
|
||||||
|
var report = Analyze(Rectangle(0, 0, 4), Scribe(-2, 2), Cutoff(6, 2));
|
||||||
|
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f =>
|
||||||
|
f.PartNumber == 3 && f.OtherPartNumber == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(Mode.Absolute)]
|
||||||
|
[InlineData(Mode.Incremental)]
|
||||||
|
public void Rapid_SamePartSharedHoleCallsUseOffsetsAndNonzeroLeadOrigins(Mode mode)
|
||||||
|
{
|
||||||
|
var part = Rectangle(100, 50, 20);
|
||||||
|
var hole = CircleWithLead();
|
||||||
|
hole.Mode = mode;
|
||||||
|
var placed = new Program();
|
||||||
|
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(5, 5), Id = 7 });
|
||||||
|
placed.MoveTo(2, 5);
|
||||||
|
placed.LineTo(2, 5); // Zero-length cut doesn't create an obstacle.
|
||||||
|
placed.MoveTo(8, 5); // Cross completed first hole.
|
||||||
|
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(15, 5), Id = 7 });
|
||||||
|
placed.Mode = mode;
|
||||||
|
Assert.True(part.RestoreLeadInProgram(placed, false));
|
||||||
|
var report = Analyze(part);
|
||||||
|
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f =>
|
||||||
|
f.PartNumber == 1 && f.OtherPartNumber == 1);
|
||||||
|
Assert.Empty(Find(report, PostVerificationKind.Incomplete));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(Mode.Absolute)]
|
||||||
|
[InlineData(Mode.Incremental)]
|
||||||
|
public void Rapid_NonzeroInitialLeadRapidIsNotAppliedTwice(Mode mode)
|
||||||
|
{
|
||||||
|
var part = Rectangle(100, 50, 4);
|
||||||
|
var placed = Square(0, 0, 4);
|
||||||
|
placed.Codes.Insert(0, new RapidMove(-2, 0));
|
||||||
|
placed.Codes[1] = new LinearMove(0, 0) { Layer = LayerType.Leadin };
|
||||||
|
placed.MoveTo(-2, 2);
|
||||||
|
placed.MoveTo(6, 2);
|
||||||
|
placed.Mode = mode;
|
||||||
|
part.RestoreLeadInProgram(placed, false);
|
||||||
|
var crossings = Find(Analyze(part), PostVerificationKind.RapidCrossing);
|
||||||
|
Assert.Single(crossings);
|
||||||
|
Assert.Empty(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(RotationType.CW)]
|
||||||
|
[InlineData(RotationType.CCW)]
|
||||||
|
public void Rapid_FullCircleArcTangentIsNotLostToChordApproximation(RotationType rotation)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(1, 0);
|
||||||
|
program.ArcTo(1, 0, 0, 0, rotation);
|
||||||
|
var report = Analyze(Part(program), Scribe(-2, 1), Scribe(2, 1));
|
||||||
|
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f => f.PartNumber == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rapid_SamePartFutureContourIsNotAnObstacle()
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 10);
|
||||||
|
var placed = new Program();
|
||||||
|
placed.MoveTo(-2, 2);
|
||||||
|
placed.MoveTo(6, 2);
|
||||||
|
placed.Codes.AddRange(Square(0, 0, 4).Codes);
|
||||||
|
part.RestoreLeadInProgram(placed, false);
|
||||||
|
Assert.Empty(Find(Analyze(part), PostVerificationKind.RapidCrossing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MultiplePlates_AreAllCheckedWithoutSharingObstacles()
|
||||||
|
{
|
||||||
|
var nest = NestWith(Rectangle(0, 0, 4));
|
||||||
|
var second = nest.CreatePlate();
|
||||||
|
second.Parts.Add(Scribe(-2, 2));
|
||||||
|
second.Parts.Add(Scribe(6, 2));
|
||||||
|
second.Parts.Add(Rectangle(0, 0, 4));
|
||||||
|
second.Parts.Add(Rectangle(1, 1, 1));
|
||||||
|
var report = PostVerificationAnalyzer.Analyze(nest);
|
||||||
|
Assert.Contains(Find(report, PostVerificationKind.MissingLeadIn), f => f.PlateNumber == 1);
|
||||||
|
Assert.Contains(Find(report, PostVerificationKind.MissingLeadIn), f => f.PlateNumber == 2);
|
||||||
|
Assert.All(Find(report, PostVerificationKind.Overlap), f => Assert.Equal(2, f.PlateNumber));
|
||||||
|
Assert.DoesNotContain(Find(report, PostVerificationKind.RapidCrossing), f =>
|
||||||
|
f.PlateNumber == 2 && f.PartNumber <= 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("recursive")]
|
||||||
|
[InlineData("missing-call")]
|
||||||
|
[InlineData("null-code")]
|
||||||
|
[InlineData("nonfinite")]
|
||||||
|
[InlineData("bad-arc")]
|
||||||
|
[InlineData("empty")]
|
||||||
|
[InlineData("null-codes")]
|
||||||
|
public void MalformedPlacedPrograms_WarnInsteadOfClearing(string fault)
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 4);
|
||||||
|
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 "null-codes": program.Codes = null; break;
|
||||||
|
}
|
||||||
|
var report = Analyze(part);
|
||||||
|
Assert.NotEmpty(Find(report, PostVerificationKind.Incomplete));
|
||||||
|
Assert.True(report.HasWarnings);
|
||||||
|
Assert.False(report.CanPost(false));
|
||||||
|
Assert.DoesNotContain("No warnings", report.ToDisplayText());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MalformedCleanProgram_OverlapIssuePreservesInputIndex()
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 4);
|
||||||
|
part.BaseDrawing.Program.Codes.RemoveAt(4);
|
||||||
|
var report = Analyze(Cutoff(30, 30), part);
|
||||||
|
Assert.Contains(Find(report, PostVerificationKind.Incomplete), f => f.PartNumber == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rapid_ResumedContourBecomesObstacleOnlyAfterItsLastCut()
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 4);
|
||||||
|
var placed = Square(0, 0, 4);
|
||||||
|
placed.Codes.Insert(3, new RapidMove(4, 4)); // Pause, with no uncut gap.
|
||||||
|
placed.MoveTo(-2, 2);
|
||||||
|
placed.MoveTo(6, 2);
|
||||||
|
part.RestoreLeadInProgram(placed, false);
|
||||||
|
Assert.Single(Find(Analyze(part), PostVerificationKind.RapidCrossing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void Rapid_InternalGapDoesNotBecomeAnObstacle(bool staleFlag)
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 4);
|
||||||
|
var placed = Square(0, 0, 4);
|
||||||
|
placed.Codes.Insert(3, new RapidMove(3.75, 4)); // Leave a gap on the top edge.
|
||||||
|
placed.MoveTo(-2, 2);
|
||||||
|
placed.MoveTo(6, 2);
|
||||||
|
part.RestoreLeadInProgram(placed, false);
|
||||||
|
part.CuttingParameters = new CuttingParameters { TabsEnabled = staleFlag };
|
||||||
|
Assert.Empty(Find(Analyze(part), PostVerificationKind.RapidCrossing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingPlacedCuts_CannotPassForACleanMaterialPart()
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 4);
|
||||||
|
part.Program.Codes.Clear();
|
||||||
|
part.Program.MoveTo(0, 0);
|
||||||
|
var report = Analyze(part);
|
||||||
|
Assert.NotEmpty(Find(report, PostVerificationKind.Incomplete));
|
||||||
|
Assert.False(report.CanPost(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidArcDirection_IsIncomplete()
|
||||||
|
{
|
||||||
|
var part = Part(CircleWithLead());
|
||||||
|
((ArcMove)part.Program.Codes[^1]).Rotation = (RotationType)99;
|
||||||
|
Assert.NotEmpty(Find(Analyze(part), PostVerificationKind.Incomplete));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rapid_LeadOutThatCompletesTabGapIsNotTreatedAsRetained()
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 4);
|
||||||
|
((LinearMove)part.Program.Codes[^1]).EndPoint = new Vector(0, 0.25);
|
||||||
|
part.Program.Codes.Add(new LinearMove(0, 0) { Layer = LayerType.Leadout });
|
||||||
|
var report = Analyze(part, Scribe(-2, 2), Scribe(6, 2));
|
||||||
|
Assert.True(Find(report, PostVerificationKind.RapidCrossing).Any()
|
||||||
|
|| Find(report, PostVerificationKind.Incomplete).Any());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rapid_OutOfOrderFragmentsCannotSilentlyPassAsTabbed()
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 4);
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(4, 0);
|
||||||
|
program.MoveTo(4, 4);
|
||||||
|
program.LineTo(0, 4);
|
||||||
|
program.MoveTo(4, 0);
|
||||||
|
program.LineTo(4, 4);
|
||||||
|
program.MoveTo(0, 4);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
part.RestoreLeadInProgram(program, false);
|
||||||
|
var report = Analyze(part, Scribe(-2, 2), Scribe(6, 2));
|
||||||
|
Assert.True(Find(report, PostVerificationKind.RapidCrossing).Any()
|
||||||
|
|| Find(report, PostVerificationKind.Incomplete).Any());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Cancellation_ThrowsWithoutChangingInputs()
|
||||||
|
{
|
||||||
|
var part = Rectangle(0, 0, 4);
|
||||||
|
var before = part.Program.ToString();
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
cancellation.Cancel();
|
||||||
|
Assert.Throws<OperationCanceledException>(() =>
|
||||||
|
PostVerificationAnalyzer.Analyze(NestWith(part), cancellation.Token));
|
||||||
|
Assert.Equal(before, part.Program.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_PreservesProgramsPosesSharedCallsAndCuttingState()
|
||||||
|
{
|
||||||
|
var part = Rectangle(100, 50, 20);
|
||||||
|
var sub = CircleWithLead();
|
||||||
|
var program = Square(0, 0, 20);
|
||||||
|
program.Codes.Insert(0, new SubProgramCall { Program = sub, Offset = new Vector(5, 5) });
|
||||||
|
program.Codes.Insert(1, new SubProgramCall { Program = sub, Offset = new Vector(15, 5) });
|
||||||
|
program.Mode = Mode.Incremental;
|
||||||
|
part.RestoreLeadInProgram(program, true);
|
||||||
|
part.CuttingParameters = Leads();
|
||||||
|
var nest = NestWith(part);
|
||||||
|
var codes = program.Codes.ToArray();
|
||||||
|
var before = program.ToString();
|
||||||
|
var beforeSub = sub.ToString();
|
||||||
|
var parameters = part.CuttingParameters;
|
||||||
|
var bounds = part.BoundingBox;
|
||||||
|
var quantity = part.BaseDrawing.Quantity.Nested;
|
||||||
|
var report = PostVerificationAnalyzer.Analyze(nest);
|
||||||
|
Assert.Same(program, part.Program);
|
||||||
|
Assert.Equal(codes, program.Codes);
|
||||||
|
Assert.Equal(before, program.ToString());
|
||||||
|
Assert.Equal(beforeSub, sub.ToString());
|
||||||
|
Assert.Same(sub, ((SubProgramCall)program[0]).Program);
|
||||||
|
Assert.Same(sub, ((SubProgramCall)program[1]).Program);
|
||||||
|
Assert.Equal(new Vector(100, 50), part.Location);
|
||||||
|
Assert.Same(bounds, part.BoundingBox);
|
||||||
|
Assert.True(part.HasManualLeadIns);
|
||||||
|
Assert.True(part.LeadInsLocked);
|
||||||
|
Assert.Same(parameters, part.CuttingParameters);
|
||||||
|
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
|
||||||
|
Assert.True(((ICollection<PostVerificationFinding>)report.Findings).IsReadOnly);
|
||||||
|
var display = report.ToDisplayText();
|
||||||
|
part.Program.Codes.Clear();
|
||||||
|
Assert.Equal(display, report.ToDisplayText());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PostVerificationFinding[] Find(PostVerificationReport report, PostVerificationKind kind) =>
|
||||||
|
report.Findings.Where(f => f.Kind == kind).ToArray();
|
||||||
|
|
||||||
|
private static PostVerificationReport Analyze(params Part[] parts) =>
|
||||||
|
PostVerificationAnalyzer.Analyze(NestWith(parts));
|
||||||
|
|
||||||
|
private static Nest NestWith(params Part[] parts)
|
||||||
|
{
|
||||||
|
var nest = new Nest();
|
||||||
|
var plate = nest.CreatePlate();
|
||||||
|
foreach (var part in parts)
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
return nest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part Part(Program program) => new(new Drawing("fixture", program));
|
||||||
|
|
||||||
|
private static Part Rectangle(double x, double y, double size) =>
|
||||||
|
new(new Drawing("rectangle", Square(0, 0, size)), new Vector(x, y));
|
||||||
|
|
||||||
|
private static Program Square(double x, double y, double size)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(x, y);
|
||||||
|
program.LineTo(x + size, y);
|
||||||
|
program.LineTo(x + size, y + size);
|
||||||
|
program.LineTo(x, y + size);
|
||||||
|
program.LineTo(x, y);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part Scribe(double x, double y)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(x, y);
|
||||||
|
program.Codes.Add(new LinearMove(x, y) { Layer = LayerType.Scribe });
|
||||||
|
return Part(program);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part Cutoff(double x, double y)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(x, y);
|
||||||
|
program.LineTo(x + 1, y);
|
||||||
|
return new Part(new Drawing("cutoff", program) { IsCutOff = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program CircleWithLead()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0.5, 0);
|
||||||
|
program.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
|
||||||
|
program.Codes.Add(new ArcMove(1, 0, 0, 0) { Layer = LayerType.Display });
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CuttingParameters Leads() => new()
|
||||||
|
{
|
||||||
|
ExternalLeadIn = new LineLeadIn { Length = 0.5 },
|
||||||
|
InternalLeadIn = new LineLeadIn { Length = 0.5 },
|
||||||
|
ArcCircleLeadIn = new LineLeadIn { Length = 0.5 }
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Posts.Cincinnati;
|
||||||
|
using OpenNest.Posts.CincinnatiCIFiber;
|
||||||
|
using OpenNest.Posts.GravographIS;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Diagnostics;
|
||||||
|
|
||||||
|
public class PostVerificationPostContractTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ReorderingPostsRequireAcknowledgmentEvenWhenNestChecksAreClear()
|
||||||
|
{
|
||||||
|
var nest = ClearNest();
|
||||||
|
Assert.False(PostVerificationAnalyzer.Analyze(nest).HasWarnings);
|
||||||
|
IPostProcessor[] posts =
|
||||||
|
[
|
||||||
|
new CincinnatiPostProcessor(new CincinnatiPostConfig()),
|
||||||
|
new GravographISPostProcessor(new GravographISPostConfig())
|
||||||
|
];
|
||||||
|
foreach (var post in posts)
|
||||||
|
{
|
||||||
|
var report = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
|
||||||
|
Assert.Contains(report.Findings, finding => finding.Kind == PostVerificationKind.Incomplete);
|
||||||
|
Assert.False(report.CanPost(false));
|
||||||
|
Assert.True(report.CanPost(true));
|
||||||
|
Assert.Contains(post.Name, report.ToDisplayText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CIFiberUsesPlacedOrderWithoutAnExtraOrderWarning()
|
||||||
|
{
|
||||||
|
var report = PostVerificationAnalyzer.AnalyzeForPost(ClearNest(),
|
||||||
|
new CIFiberPostProcessor(new CIFiberPostConfig()));
|
||||||
|
Assert.Empty(report.Findings);
|
||||||
|
Assert.True(report.CanPost(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Nest ClearNest()
|
||||||
|
{
|
||||||
|
var program = new OpenNest.CNC.Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(4, 0);
|
||||||
|
program.LineTo(4, 4);
|
||||||
|
program.LineTo(0, 4);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
var part = new Part(new Drawing("square", program));
|
||||||
|
part.ApplyLeadIns(new CuttingParameters { ExternalLeadIn = new LineLeadIn { Length = 0.5 } },
|
||||||
|
new Vector(-2, -2));
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.CreatePlate().Parts.Add(part);
|
||||||
|
return nest;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
|
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
|
||||||
|
<ProjectReference Include="..\OpenNest.Console\OpenNest.Console.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.Benchmark\OpenNest.Benchmark.csproj" />
|
<ProjectReference Include="..\OpenNest.Benchmark\OpenNest.Benchmark.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
|
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Forms;
|
||||||
|
|
||||||
|
namespace OpenNest.WinForms.Tests.Forms;
|
||||||
|
|
||||||
|
public class PostVerificationFormTests
|
||||||
|
{
|
||||||
|
#pragma warning disable xUnit1031 // Dedicated STA; only await completed tasks, or explicitly pump until completion.
|
||||||
|
[Fact]
|
||||||
|
public void WarningsRequireFreshAcknowledgmentAndUncheckingRevokesPermission()
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
var nest = WarningNest();
|
||||||
|
using var form = new PostVerificationForm(nest,
|
||||||
|
(_, _) => Task.FromResult(PostVerificationAnalyzer.Analyze(nest)));
|
||||||
|
form.VerifyAsync().GetAwaiter().GetResult();
|
||||||
|
var consent = Control<CheckBox>(form, "acknowledgeBox");
|
||||||
|
var post = Control<Button>(form, "postButton");
|
||||||
|
Assert.False(consent.Checked);
|
||||||
|
Assert.False(post.Enabled);
|
||||||
|
Invoke(form, "PostButton_Click", null, EventArgs.Empty);
|
||||||
|
Assert.NotEqual(DialogResult.OK, form.DialogResult);
|
||||||
|
consent.Checked = true;
|
||||||
|
Assert.True(post.Enabled);
|
||||||
|
consent.Checked = false;
|
||||||
|
Assert.False(post.Enabled);
|
||||||
|
consent.Checked = true;
|
||||||
|
Invoke(form, "PostButton_Click", null, EventArgs.Empty);
|
||||||
|
Assert.Equal(DialogResult.OK, form.DialogResult);
|
||||||
|
|
||||||
|
using var retry = new PostVerificationForm(nest,
|
||||||
|
(_, _) => Task.FromResult(PostVerificationAnalyzer.Analyze(nest)));
|
||||||
|
retry.VerifyAsync().GetAwaiter().GetResult();
|
||||||
|
Assert.False(Control<CheckBox>(retry, "acknowledgeBox").Checked);
|
||||||
|
Assert.False(Control<Button>(retry, "postButton").Enabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearReportIsDisplayedAndCancelIsDefault()
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
var nest = WarningNest();
|
||||||
|
foreach (var move in nest.Plates[0].Parts[0].Program.Codes.OfType<LinearMove>())
|
||||||
|
move.Layer = LayerType.Scribe;
|
||||||
|
foreach (var move in nest.Plates[0].Parts[0].BaseDrawing.Program.Codes.OfType<LinearMove>())
|
||||||
|
move.Layer = LayerType.Scribe;
|
||||||
|
using var form = new PostVerificationForm(nest,
|
||||||
|
(_, _) => Task.FromResult(PostVerificationAnalyzer.Analyze(nest)));
|
||||||
|
form.VerifyAsync().GetAwaiter().GetResult();
|
||||||
|
Assert.NotEmpty(Control<TextBox>(form, "reportBox").Text);
|
||||||
|
Assert.True(Control<Button>(form, "postButton").Enabled);
|
||||||
|
Assert.False(Control<CheckBox>(form, "acknowledgeBox").Enabled);
|
||||||
|
Assert.Equal("Continue Posting", Control<Button>(form, "postButton").Text);
|
||||||
|
Assert.Same(Control<Button>(form, "cancelButton"), form.AcceptButton);
|
||||||
|
Assert.Same(Control<Button>(form, "cancelButton"), form.CancelButton);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnexpectedFailureCannotBeOverridden()
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
using var form = new PostVerificationForm(WarningNest(),
|
||||||
|
(_, _) => Task.FromException<PostVerificationReport>(new InvalidOperationException("test failure")));
|
||||||
|
form.VerifyAsync().GetAwaiter().GetResult();
|
||||||
|
Control<CheckBox>(form, "acknowledgeBox").Checked = true;
|
||||||
|
Assert.False(Control<Button>(form, "postButton").Enabled);
|
||||||
|
Assert.Contains("test failure", Control<TextBox>(form, "reportBox").Text);
|
||||||
|
Invoke(form, "PostButton_Click", null, EventArgs.Empty);
|
||||||
|
Assert.NotEqual(DialogResult.OK, form.DialogResult);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DisposalIsIdempotent()
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
var form = new PostVerificationForm(WarningNest());
|
||||||
|
form.Dispose();
|
||||||
|
form.Dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClosingDuringVerificationWaitsForWorkerAndDiscardsLateResult()
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
var pending = new TaskCompletionSource<PostVerificationReport>();
|
||||||
|
var nest = WarningNest();
|
||||||
|
var token = CancellationToken.None;
|
||||||
|
using var form = new PostVerificationForm(nest, (_, cancellation) =>
|
||||||
|
{
|
||||||
|
token = cancellation;
|
||||||
|
return pending.Task;
|
||||||
|
});
|
||||||
|
var work = form.VerifyAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var closing = new FormClosingEventArgs(CloseReason.UserClosing, false);
|
||||||
|
Invoke(form, "OnFormClosing", closing);
|
||||||
|
Assert.True(closing.Cancel);
|
||||||
|
Assert.True(token.IsCancellationRequested);
|
||||||
|
Assert.False(work.IsCompleted);
|
||||||
|
pending.SetResult(PostVerificationAnalyzer.Analyze(nest));
|
||||||
|
var until = DateTime.UtcNow.AddSeconds(10);
|
||||||
|
while (!work.IsCompleted && DateTime.UtcNow < until)
|
||||||
|
Application.DoEvents();
|
||||||
|
Assert.True(work.IsCompleted);
|
||||||
|
work.GetAwaiter().GetResult();
|
||||||
|
Assert.NotEqual(DialogResult.OK, form.DialogResult);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
pending.TrySetCanceled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Cancel")]
|
||||||
|
[InlineData("Escape")]
|
||||||
|
[InlineData("Close")]
|
||||||
|
public void RealModalCancellationKeepsOwnerDisabledUntilWorkerStops(string action)
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
var pending = new TaskCompletionSource<PostVerificationReport>();
|
||||||
|
using var owner = new Form();
|
||||||
|
using var form = new PostVerificationForm(WarningNest(), (_, _) => pending.Task);
|
||||||
|
Exception? failure = null;
|
||||||
|
owner.Show();
|
||||||
|
using var timeout = new System.Windows.Forms.Timer { Interval = 10000 };
|
||||||
|
timeout.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
failure ??= new TimeoutException("Modal cancellation did not finish.");
|
||||||
|
pending.TrySetCanceled();
|
||||||
|
form.Close();
|
||||||
|
};
|
||||||
|
form.Shown += (_, _) => form.BeginInvoke(new System.Action(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.False(IsWindowEnabled(owner.Handle));
|
||||||
|
if (action == "Cancel")
|
||||||
|
Control<Button>(form, "cancelButton").PerformClick();
|
||||||
|
else if (action == "Escape")
|
||||||
|
Invoke(form, "ProcessDialogKey", Keys.Escape);
|
||||||
|
else
|
||||||
|
form.Close();
|
||||||
|
|
||||||
|
// ShowDialog must stay active while the worker ignores cancellation.
|
||||||
|
Assert.True(form.Visible);
|
||||||
|
Assert.False(IsWindowEnabled(owner.Handle));
|
||||||
|
Assert.False(Control<Button>(form, "postButton").Enabled);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failure = ex;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
pending.TrySetCanceled();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
timeout.Start();
|
||||||
|
var result = form.ShowDialog(owner);
|
||||||
|
timeout.Stop();
|
||||||
|
Assert.Equal(DialogResult.Cancel, result);
|
||||||
|
Assert.True(IsWindowEnabled(owner.Handle));
|
||||||
|
if (failure != null)
|
||||||
|
ExceptionDispatchInfo.Capture(failure).Throw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||||
|
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
|
||||||
|
private static extern bool IsWindowEnabled(IntPtr window);
|
||||||
|
|
||||||
|
private static Nest WarningNest()
|
||||||
|
{
|
||||||
|
var nest = new Nest("verification");
|
||||||
|
var program = new CNC.Program();
|
||||||
|
program.Codes.Add(new RapidMove(0, 0));
|
||||||
|
program.Codes.Add(new LinearMove(5, 0));
|
||||||
|
program.Codes.Add(new LinearMove(5, 5));
|
||||||
|
program.Codes.Add(new LinearMove(0, 5));
|
||||||
|
program.Codes.Add(new LinearMove(0, 0));
|
||||||
|
nest.CreatePlate().Parts.Add(new Part(new Drawing("square", program)));
|
||||||
|
return nest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T Control<T>(Form form, string name) where T : Control =>
|
||||||
|
Assert.IsType<T>(form.Controls.Find(name, true).Single());
|
||||||
|
|
||||||
|
private static void Invoke(Form form, string method, params object?[] arguments) =>
|
||||||
|
form.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
|
.Invoke(form, arguments);
|
||||||
|
|
||||||
|
private static void RunSta(System.Action action)
|
||||||
|
{
|
||||||
|
Exception? failure = null;
|
||||||
|
var thread = new Thread(() =>
|
||||||
|
{
|
||||||
|
try { action(); }
|
||||||
|
catch (Exception ex) { failure = ex; }
|
||||||
|
});
|
||||||
|
thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
thread.Start();
|
||||||
|
Assert.True(thread.Join(TimeSpan.FromSeconds(30)), "STA verification test timed out.");
|
||||||
|
if (failure != null)
|
||||||
|
ExceptionDispatchInfo.Capture(failure).Throw();
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-10
@@ -1488,36 +1488,60 @@ namespace OpenNest.Forms
|
|||||||
var menuItem = (ToolStripMenuItem)sender;
|
var menuItem = (ToolStripMenuItem)sender;
|
||||||
var postProcessor = menuItem.Tag as IPostProcessor;
|
var postProcessor = menuItem.Tag as IPostProcessor;
|
||||||
|
|
||||||
if (postProcessor == null)
|
var editForm = activeForm;
|
||||||
|
if (postProcessor == null || editForm == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// Closed progress windows can still have a fill awaiting its final commit.
|
||||||
|
// Keep the nest stable throughout verification and all owned modal dialogs.
|
||||||
|
var views = MdiChildren.OfType<EditNestForm>()
|
||||||
|
.Where(form => ReferenceEquals(form.Nest, editForm.Nest))
|
||||||
|
.Select(form => form.PlateView).ToArray();
|
||||||
|
if (nestingInProgress || Application.OpenForms.OfType<NestProgressForm>().Any()
|
||||||
|
|| views.Any(view => view.IsFillInProgress || view.Actions.CurrentAction?.IsBusy() == true))
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "Finish or cancel the current nesting or plate action before posting.",
|
||||||
|
"Verify Nest Before Posting", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var view in views)
|
||||||
|
view.SetAction(typeof(ActionSelect));
|
||||||
|
var nest = editForm.Nest;
|
||||||
|
|
||||||
if (postProcessor is IPostProcessorNestAware nestAware)
|
if (postProcessor is IPostProcessorNestAware nestAware)
|
||||||
nestAware.PrepareForNest(activeForm.Nest);
|
nestAware.PrepareForNest(nest);
|
||||||
|
|
||||||
if (postProcessor is IConfigurablePostProcessor configurable)
|
if (postProcessor is IConfigurablePostProcessor configurable)
|
||||||
{
|
{
|
||||||
using var configForm = new PostProcessorConfigForm(configurable);
|
using var configForm = new PostProcessorConfigForm(configurable);
|
||||||
if (configForm.ShowDialog() != DialogResult.OK)
|
if (configForm.ShowDialog(this) != DialogResult.OK)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var dialog = new SaveFileDialog();
|
using (var verification = new PostVerificationForm(nest, postProcessor))
|
||||||
dialog.Filter = "CNC File (*.cnc) | *.cnc";
|
{
|
||||||
dialog.FileName = activeForm.Nest.Name;
|
if (verification.ShowDialog(this) != DialogResult.OK)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (dialog.ShowDialog() == DialogResult.OK)
|
using var dialog = new SaveFileDialog();
|
||||||
|
dialog.Filter = "CNC File (*.cnc) | *.cnc";
|
||||||
|
dialog.FileName = nest.Name;
|
||||||
|
|
||||||
|
if (dialog.ShowDialog(this) == DialogResult.OK)
|
||||||
{
|
{
|
||||||
var path = dialog.FileName;
|
var path = dialog.FileName;
|
||||||
|
|
||||||
if (postProcessor is IMultiFilePostProcessor multiFile)
|
if (postProcessor is IMultiFilePostProcessor multiFile)
|
||||||
{
|
{
|
||||||
var files = multiFile.GetOutputFiles(activeForm.Nest, path);
|
var files = multiFile.GetOutputFiles(nest, path);
|
||||||
if (!ConfirmOverwrite(files, path))
|
if (!ConfirmOverwrite(files, path))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
postProcessor.Post(activeForm.Nest, path);
|
postProcessor.Post(nest, path);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
when (ex is InvalidOperationException or IOException or UnauthorizedAccessException)
|
when (ex is InvalidOperationException or IOException or UnauthorizedAccessException)
|
||||||
@@ -1545,7 +1569,7 @@ namespace OpenNest.Forms
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
postProcessor.Post(activeForm.Nest, path);
|
postProcessor.Post(nest, path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace OpenNest.Forms;
|
||||||
|
|
||||||
|
partial class PostVerificationForm
|
||||||
|
{
|
||||||
|
private System.ComponentModel.IContainer components = null;
|
||||||
|
private TableLayoutPanel layout;
|
||||||
|
private Label summaryLabel;
|
||||||
|
private TextBox reportBox;
|
||||||
|
private CheckBox acknowledgeBox;
|
||||||
|
private FlowLayoutPanel buttons;
|
||||||
|
private Button postButton;
|
||||||
|
private Button cancelButton;
|
||||||
|
private bool disposedResources;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing && !disposedResources)
|
||||||
|
{
|
||||||
|
disposedResources = true;
|
||||||
|
cancellation.Cancel();
|
||||||
|
cancellation.Dispose();
|
||||||
|
components?.Dispose();
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
layout = new TableLayoutPanel();
|
||||||
|
summaryLabel = new Label();
|
||||||
|
reportBox = new TextBox();
|
||||||
|
acknowledgeBox = new CheckBox();
|
||||||
|
buttons = new FlowLayoutPanel();
|
||||||
|
postButton = new Button();
|
||||||
|
cancelButton = new Button();
|
||||||
|
layout.SuspendLayout();
|
||||||
|
buttons.SuspendLayout();
|
||||||
|
SuspendLayout();
|
||||||
|
|
||||||
|
layout.ColumnCount = 1;
|
||||||
|
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
||||||
|
layout.RowCount = 4;
|
||||||
|
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||||
|
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
|
||||||
|
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||||
|
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||||
|
layout.Dock = DockStyle.Fill;
|
||||||
|
layout.Padding = new Padding(12);
|
||||||
|
layout.Controls.Add(summaryLabel, 0, 0);
|
||||||
|
layout.Controls.Add(reportBox, 0, 1);
|
||||||
|
layout.Controls.Add(acknowledgeBox, 0, 2);
|
||||||
|
layout.Controls.Add(buttons, 0, 3);
|
||||||
|
|
||||||
|
summaryLabel.Name = "summaryLabel";
|
||||||
|
summaryLabel.AutoSize = true;
|
||||||
|
summaryLabel.Dock = DockStyle.Fill;
|
||||||
|
summaryLabel.Margin = new Padding(3, 3, 3, 10);
|
||||||
|
summaryLabel.Text = "Checking all plates: overlaps, missing lead-ins, and rapid crossings...";
|
||||||
|
summaryLabel.TabIndex = 0;
|
||||||
|
|
||||||
|
reportBox.Name = "reportBox";
|
||||||
|
reportBox.Dock = DockStyle.Fill;
|
||||||
|
reportBox.Multiline = true;
|
||||||
|
reportBox.ReadOnly = true;
|
||||||
|
reportBox.ScrollBars = ScrollBars.Vertical;
|
||||||
|
reportBox.WordWrap = true;
|
||||||
|
reportBox.MaxLength = 0;
|
||||||
|
reportBox.TabIndex = 1;
|
||||||
|
reportBox.AccessibleName = "Pre-post verification findings";
|
||||||
|
reportBox.Text = "Verification is running. No CNC output has been written.";
|
||||||
|
|
||||||
|
acknowledgeBox.Name = "acknowledgeBox";
|
||||||
|
acknowledgeBox.AutoSize = true;
|
||||||
|
acknowledgeBox.Dock = DockStyle.Fill;
|
||||||
|
acknowledgeBox.Margin = new Padding(3, 12, 3, 12);
|
||||||
|
acknowledgeBox.Enabled = false;
|
||||||
|
acknowledgeBox.Checked = false;
|
||||||
|
acknowledgeBox.Text = "I understand the listed risks, including possible head crashes and machine or material damage.\r\nI choose to bypass these warnings and continue posting this nest.";
|
||||||
|
acknowledgeBox.TabIndex = 2;
|
||||||
|
acknowledgeBox.CheckedChanged += AcknowledgeBox_CheckedChanged;
|
||||||
|
|
||||||
|
buttons.Dock = DockStyle.Fill;
|
||||||
|
buttons.AutoSize = true;
|
||||||
|
buttons.FlowDirection = FlowDirection.RightToLeft;
|
||||||
|
buttons.Controls.Add(cancelButton);
|
||||||
|
buttons.Controls.Add(postButton);
|
||||||
|
buttons.TabIndex = 3;
|
||||||
|
|
||||||
|
cancelButton.Name = "cancelButton";
|
||||||
|
cancelButton.Text = "Cancel";
|
||||||
|
cancelButton.AutoSize = true;
|
||||||
|
cancelButton.DialogResult = DialogResult.Cancel;
|
||||||
|
cancelButton.TabIndex = 0;
|
||||||
|
|
||||||
|
postButton.Name = "postButton";
|
||||||
|
postButton.Text = "Continue Posting";
|
||||||
|
postButton.AutoSize = true;
|
||||||
|
postButton.Enabled = false;
|
||||||
|
postButton.TabIndex = 1;
|
||||||
|
postButton.Click += PostButton_Click;
|
||||||
|
|
||||||
|
AcceptButton = cancelButton;
|
||||||
|
CancelButton = cancelButton;
|
||||||
|
AutoScaleDimensions = new SizeF(7F, 15F);
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(850, 560);
|
||||||
|
MinimumSize = new Size(700, 440);
|
||||||
|
Controls.Add(layout);
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
Name = "PostVerificationForm";
|
||||||
|
ShowInTaskbar = false;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
Text = "Verify Nest Before Posting";
|
||||||
|
layout.ResumeLayout(false);
|
||||||
|
layout.PerformLayout();
|
||||||
|
buttons.ResumeLayout(false);
|
||||||
|
buttons.PerformLayout();
|
||||||
|
ResumeLayout(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
namespace OpenNest.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One posting attempt. The owner must prevent background nest edits before opening
|
||||||
|
/// this modal form; cancellation keeps it modal until the read-only worker stops.
|
||||||
|
/// </summary>
|
||||||
|
public partial class PostVerificationForm : Form
|
||||||
|
{
|
||||||
|
private readonly Nest nest;
|
||||||
|
private readonly Func<Nest, CancellationToken, Task<PostVerificationReport>> analyze;
|
||||||
|
private readonly CancellationTokenSource cancellation = new();
|
||||||
|
private PostVerificationReport report;
|
||||||
|
private bool started;
|
||||||
|
private bool analyzing;
|
||||||
|
private bool closeRequested;
|
||||||
|
|
||||||
|
public PostVerificationForm(Nest nest, IPostProcessor postProcessor = null)
|
||||||
|
: this(nest, (value, token) => Task.Run(
|
||||||
|
() => PostVerificationAnalyzer.AnalyzeForPost(value, postProcessor, token), token))
|
||||||
|
{ }
|
||||||
|
|
||||||
|
internal PostVerificationForm(Nest nest,
|
||||||
|
Func<Nest, CancellationToken, Task<PostVerificationReport>> analyze)
|
||||||
|
{
|
||||||
|
this.nest = nest ?? throw new ArgumentNullException(nameof(nest));
|
||||||
|
this.analyze = analyze ?? throw new ArgumentNullException(nameof(analyze));
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnShown(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnShown(e);
|
||||||
|
await VerifyAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task VerifyAsync()
|
||||||
|
{
|
||||||
|
if (started)
|
||||||
|
return;
|
||||||
|
started = true;
|
||||||
|
analyzing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await analyze(nest, cancellation.Token);
|
||||||
|
if (closeRequested || IsDisposed)
|
||||||
|
return;
|
||||||
|
report = result;
|
||||||
|
reportBox.Text = report.ToDisplayText();
|
||||||
|
reportBox.SelectionStart = 0;
|
||||||
|
reportBox.SelectionLength = 0;
|
||||||
|
summaryLabel.Text = report.HasWarnings
|
||||||
|
? "Warnings found. Review every finding before deciding whether to post."
|
||||||
|
: "Verification complete. No warnings found by these checks.";
|
||||||
|
acknowledgeBox.Visible = report.HasWarnings;
|
||||||
|
acknowledgeBox.Enabled = report.HasWarnings;
|
||||||
|
acknowledgeBox.Checked = false;
|
||||||
|
postButton.Text = report.HasWarnings ? "Post Anyway" : "Continue Posting";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
closeRequested = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!IsDisposed)
|
||||||
|
{
|
||||||
|
report = null;
|
||||||
|
summaryLabel.Text = "Verification failed. Posting is blocked.";
|
||||||
|
reportBox.Text = "The verification could not finish. Cancel, correct the problem, and try again."
|
||||||
|
+ Environment.NewLine + Environment.NewLine + ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
analyzing = false;
|
||||||
|
if (!IsDisposed)
|
||||||
|
{
|
||||||
|
UpdatePostPermission();
|
||||||
|
if (closeRequested)
|
||||||
|
{
|
||||||
|
DialogResult = DialogResult.Cancel;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePostPermission() =>
|
||||||
|
postButton.Enabled = !analyzing && !closeRequested
|
||||||
|
&& report?.CanPost(acknowledgeBox.Checked) == true;
|
||||||
|
|
||||||
|
private void AcknowledgeBox_CheckedChanged(object sender, EventArgs e) => UpdatePostPermission();
|
||||||
|
|
||||||
|
private void PostButton_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
// Check again here; an enabled button alone is not consent enforcement.
|
||||||
|
if (analyzing || closeRequested || report?.CanPost(acknowledgeBox.Checked) != true)
|
||||||
|
return;
|
||||||
|
DialogResult = DialogResult.OK;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||||
|
{
|
||||||
|
if (analyzing)
|
||||||
|
{
|
||||||
|
closeRequested = true;
|
||||||
|
cancellation.Cancel();
|
||||||
|
postButton.Enabled = false;
|
||||||
|
cancelButton.Enabled = false;
|
||||||
|
summaryLabel.Text = "Canceling verification...";
|
||||||
|
e.Cancel = true;
|
||||||
|
}
|
||||||
|
base.OnFormClosing(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ namespace OpenNest.Posts.CincinnatiCIFiber
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CIFiberPostProcessor
|
public sealed class CIFiberPostProcessor
|
||||||
: IConfigurablePostProcessor,
|
: IConfigurablePostProcessor,
|
||||||
IMultiFilePostProcessor
|
IMultiFilePostProcessor,
|
||||||
|
IPostVerificationSupport
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -27,6 +28,7 @@ namespace OpenNest.Posts.CincinnatiCIFiber
|
|||||||
};
|
};
|
||||||
|
|
||||||
public string Name => "Cincinnati CI Fiber";
|
public string Name => "Cincinnati CI Fiber";
|
||||||
|
public bool PreservesPlacedProgramOrder => true;
|
||||||
public string Author => "OpenNest";
|
public string Author => "OpenNest";
|
||||||
public string Description =>
|
public string Description =>
|
||||||
"CI Fiber family laser (TF5200 / nLight CLX), e.g. CI Fiber 4020 8kW";
|
"CI Fiber family laser (TF5200 / nLight CLX), e.g. CI Fiber 4020 8kW";
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges
|
|||||||
- **Nesting** — pluggable whole-job engines (Default, Strip, Vertical/Horizontal Remnant, StockLadder, plus DLL plugins), NFP-based interlocking pair evaluation, gravity compaction, rotation sweeps, multi-plate/multi-material jobs.
|
- **Nesting** — pluggable whole-job engines (Default, Strip, Vertical/Horizontal Remnant, StockLadder, plus DLL plugins), NFP-based interlocking pair evaluation, gravity compaction, rotation sweeps, multi-plate/multi-material jobs.
|
||||||
- **Plate operations** — manual sheet cut-offs, [automatic scrap cutoffs with unused-tail preservation](docs/automatic-scrap-cutoffs.md), oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing, and spacing-aware pushes that can slide along or away from touching parts.
|
- **Plate operations** — manual sheet cut-offs, [automatic scrap cutoffs with unused-tail preservation](docs/automatic-scrap-cutoffs.md), oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing, and spacing-aware pushes that can slide along or away from touching parts.
|
||||||
- **Visual overlap check** — manually highlight shared material on the active plate, including containment and cutouts, with area shading, pair centroids, and hover details through View > Overlap Check. [Usage and limitations](docs/geometry/visual-overlap-check.md).
|
- **Visual overlap check** — manually highlight shared material on the active plate, including containment and cutouts, with area shading, pair centroids, and hover details through View > Overlap Check. [Usage and limitations](docs/geometry/visual-overlap-check.md).
|
||||||
- **CNC output** — configurable lead-ins/outs and tabs, contour editing, user-defined G-code variables (`$name` → `#200+` machine variables), plugin post-processors (Cincinnati CL-707/800/900/940/CLX included).
|
- **CNC output** — configurable lead-ins/outs and tabs, contour editing, user-defined G-code variables (`$name` → `#200+` machine variables), plugin post-processors (Cincinnati CL-707/800/900/940/CLX included). [Pre-post verification](docs/post-verification.md) checks overlaps, missing lead-ins, and rapid crossings, with explicit risk acknowledgment required to bypass warnings.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Verify a nest before posting
|
||||||
|
|
||||||
|
The desktop's **Nest > Post > (post processor)** workflow checks every plate after
|
||||||
|
post settings are accepted and before asking where to save CNC output. The review
|
||||||
|
shows all three check categories, even when there are no findings:
|
||||||
|
|
||||||
|
- **Overlapping parts:** shared material between placed parts, including containment.
|
||||||
|
Holes are subtracted; placing a part inside a sufficiently large cutout is not
|
||||||
|
itself an overlap. This uses clean drawing geometry, not added lead-in/out paths.
|
||||||
|
- **Missing lead-ins:** cutting contours without an actual, nonzero lead-in in the
|
||||||
|
placed program. Applying a lead-in to only one contour does not clear the other
|
||||||
|
contours. Scribe-only work and scrap cutoff lines do not require lead-ins here.
|
||||||
|
- **Rapid crossings:** direct XY moves crossing closed, already-cut, untabbed
|
||||||
|
contours in cutting order. This includes earlier holes within the same part and
|
||||||
|
previously cut parts. A future cut is not an obstacle yet. Actual uncut gaps are
|
||||||
|
respected; enabling tabs in settings alone is not evidence that a gap exists.
|
||||||
|
|
||||||
|
Findings identify the plate and part's position in the cutting sequence. Read the
|
||||||
|
scrollable report, cancel to fix the nest, then post again to rerun verification.
|
||||||
|
Uncheckable geometry is reported as incomplete, not passed. Multiple interrupted
|
||||||
|
cut fragments, or a lead-out after an open contour that might cut through a tab,
|
||||||
|
also require manual review rather than being assumed retained.
|
||||||
|
|
||||||
|
## Explicit override
|
||||||
|
|
||||||
|
With any warning, **Post Anyway** is disabled until the user selects:
|
||||||
|
|
||||||
|
> I understand the listed risks, including possible head crashes and machine or
|
||||||
|
> material damage. I choose to bypass these warnings and continue posting this nest.
|
||||||
|
|
||||||
|
Consent applies only to that posting attempt. Unchecking disables posting again;
|
||||||
|
opening another review always starts unchecked. Cancel, Escape, and the window's
|
||||||
|
close button do not write CNC files. Cancel is the default button. A clear report
|
||||||
|
still requires **Continue Posting**. An unexpected verification failure blocks
|
||||||
|
posting rather than allowing an override of an absent report.
|
||||||
|
|
||||||
|
The editor is modal during verification and posting dialogs. Posting is refused
|
||||||
|
while auto-nesting, interactive fill, or a busy plate action can still change the
|
||||||
|
nest. Cancellation keeps the modal dialog open until its worker has stopped.
|
||||||
|
No automatic repair, resequencing, lead-in insertion, or tab insertion is performed.
|
||||||
|
|
||||||
|
## Console
|
||||||
|
|
||||||
|
`--post` prints the same report. Warnings prevent CNC output (including overwriting
|
||||||
|
an existing file) and return exit code 1. After reviewing them, explicitly pass
|
||||||
|
`--acknowledge-post-risks` to accept the risks for that invocation. The switch does
|
||||||
|
not skip verification and is not persisted. When `--post` is requested, verification
|
||||||
|
must pass or be acknowledged before either nest saving or CNC writing. Nest-save
|
||||||
|
and CNC destinations must differ; collisions are refused even with acknowledgment.
|
||||||
|
Without `--post`, ordinary nest saving is unchanged; `--no-save` skips that save.
|
||||||
|
|
||||||
|
The raw `IPostProcessor` plugin interface remains a low-level writer. Third-party
|
||||||
|
callers must apply their own review/consent flow using `PostVerificationAnalyzer`;
|
||||||
|
the gate is installed at OpenNest's desktop and console posting entry points.
|
||||||
|
|
||||||
|
## Selected post and cut order
|
||||||
|
|
||||||
|
Cincinnati CI Fiber preserves the placed part/contour order and pierce positions
|
||||||
|
used by this check. The older Cincinnati CL post sorts parts and moves cutoffs;
|
||||||
|
Gravograph builds and optimizes separate tool passes. Those two posts, and unknown
|
||||||
|
plugins, therefore show an additional **incomplete final rapid sequence** warning
|
||||||
|
requiring acknowledgment even if the nest-level checks have no findings. This
|
||||||
|
change does not silently alter their existing CNC output order.
|
||||||
|
|
||||||
|
## Limits and maintainer contract
|
||||||
|
|
||||||
|
These checks are warnings, not a machine safety certification. They inspect the
|
||||||
|
nest's placed programs and direct XY travel, not a simulation of final NC output.
|
||||||
|
A post processor or controller may change routing, cutting order, lead-ins,
|
||||||
|
retract height, or parking moves. The checks do not model tipping, slats, clamps,
|
||||||
|
actual tab strength, kerf, or safe Z height. Review the generated CNC program and
|
||||||
|
machine setup before cutting, even when this report has no warnings.
|
||||||
|
|
||||||
|
`OpenNest.Core/Diagnostics/PostVerificationAnalyzer` owns the cross-platform logic;
|
||||||
|
its report exposes findings, display text, and `CanPost(risksAcknowledged)`. Use
|
||||||
|
`AnalyzeForPost` at posting entry points so unknown post ordering is not silently
|
||||||
|
certified. `IPostVerificationSupport.PreservesPlacedProgramOrder` is an opt-in
|
||||||
|
contract, not a machine-safety claim; only declare it after verifying the writer's
|
||||||
|
part order, contour order, and pierce positions. The WinForms dialog renders the
|
||||||
|
report and enforces per-attempt consent. New callers
|
||||||
|
must keep inputs stable until analysis completes, honor cancellation, and never
|
||||||
|
treat incomplete analysis as a clear result. Keep analysis out of paint handlers.
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Verification on Windows
|
||||||
|
|
||||||
|
The core/console tests run cross-platform. Dialog tests and actual desktop behavior
|
||||||
|
require Windows; Linux cross-compilation is not runtime verification.
|
||||||
|
|
||||||
|
1. Run `dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj`.
|
||||||
|
2. Post a nest containing two overlapping parts and a part without lead-ins.
|
||||||
|
Verify the report lists the plates/parts, shows all check categories, and disables
|
||||||
|
**Post Anyway** until consent is checked. Uncheck it to verify disabling again.
|
||||||
|
3. Cancel with the button, Escape, and X on separate attempts; none should open the
|
||||||
|
output save dialog. Reopen the review and confirm consent is reset.
|
||||||
|
4. Use a sequence that cuts a closed untabbed part, moves to its far side, then
|
||||||
|
rapids back across it. Verify a crossing warning. Change the sequence so the
|
||||||
|
perimeter has not yet been cut, then assign a real tab gap and recheck the
|
||||||
|
appropriate control cases. Include a hole crossed later in the same part.
|
||||||
|
5. Acknowledge the warnings and post through both a single-file and multi-file post.
|
||||||
|
Confirm output and normal overwrite prompts. Post a clear, leaded nest with Cincinnati CI Fiber and
|
||||||
|
verify the report still appears without requiring a risk override.
|
||||||
|
6. During a large verification, cancel; the editor must stay disabled until the
|
||||||
|
worker exits, with no output. A fill still finishing after its progress dialog
|
||||||
|
closes must prevent posting until it has completed.
|
||||||
|
|
||||||
|
For failures, record the menu action, nest fixture, selected post, visible report,
|
||||||
|
and exact exception text.
|
||||||
Reference in New Issue
Block a user