feat(posting): require verification and explicit risk acknowledgment

This commit is contained in:
aj
2026-09-28 22:50:03 -04:00
parent a5fddec0e9
commit 45d2dfced4
16 changed files with 1800 additions and 24 deletions
+36 -12
View File
@@ -7,6 +7,7 @@ using System.Linq;
using System.Reflection;
using System.Threading;
using OpenNest;
using OpenNest.Diagnostics;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
@@ -120,8 +121,8 @@ static class NestConsole
var overlapCount = CheckOverlaps(plate, options);
PrintResults(success, plate, elapsed);
Save(nest, options);
PostProcess(nest, options);
if (!SaveAndPost(nest, options))
return 1;
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
}
@@ -194,6 +195,9 @@ static class NestConsole
case "--post" when i + 1 < args.Length:
o.PostName = args[++i];
break;
case "--acknowledge-post-risks":
o.AcknowledgePostRisks = true;
break;
case "--post-output" when i + 1 < args.Length:
o.PostOutput = args[++i];
break;
@@ -557,18 +561,16 @@ static class NestConsole
if (options.NoSave)
return;
var firstInput = options.InputFiles[0];
var outputFile =
options.OutputFile
?? Path.Combine(
Path.GetDirectoryName(firstInput),
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}"
);
var outputFile = NestOutputPath(options);
new NestWriter(nest).Write(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)
{
if (options.PostsDir != null)
@@ -633,10 +635,13 @@ static class NestConsole
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)
return;
{
Save(nest, options);
return true;
}
var postsDir = ResolvePostsDir(options);
var processors = LoadPostProcessors(postsDir);
@@ -655,7 +660,15 @@ static class NestConsole
else
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;
@@ -673,9 +686,18 @@ static class NestConsole
? multiFile.GetOutputFiles(nest, 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);
foreach (var file in outputFiles)
Console.WriteLine($"Post: {post.Name} -> {file}");
return true;
}
static void PrintUsage()
@@ -710,6 +732,7 @@ static class NestConsole
--no-save Skip saving output file
--post <name> Run a post processor after nesting
--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/)
--list-posts List available post processors and exit
-h, --help Show this help
@@ -733,6 +756,7 @@ static class NestConsole
public string Engine = "Default";
public string TemplateFile;
public string PostName;
public bool AcknowledgePostRisks;
public string PostOutput;
public string PostsDir;
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." : "."));
}
}
}
+13
View File
@@ -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;
}
}
+1
View File
@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
<ProjectReference Include="..\OpenNest.Console\OpenNest.Console.csproj" />
<ProjectReference Include="..\OpenNest.Benchmark\OpenNest.Benchmark.csproj" />
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.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
View File
@@ -1488,36 +1488,60 @@ namespace OpenNest.Forms
var menuItem = (ToolStripMenuItem)sender;
var postProcessor = menuItem.Tag as IPostProcessor;
if (postProcessor == null)
var editForm = activeForm;
if (postProcessor == null || editForm == null)
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)
nestAware.PrepareForNest(activeForm.Nest);
nestAware.PrepareForNest(nest);
if (postProcessor is IConfigurablePostProcessor configurable)
{
using var configForm = new PostProcessorConfigForm(configurable);
if (configForm.ShowDialog() != DialogResult.OK)
if (configForm.ShowDialog(this) != DialogResult.OK)
return;
}
var dialog = new SaveFileDialog();
dialog.Filter = "CNC File (*.cnc) | *.cnc";
dialog.FileName = activeForm.Nest.Name;
using (var verification = new PostVerificationForm(nest, postProcessor))
{
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;
if (postProcessor is IMultiFilePostProcessor multiFile)
{
var files = multiFile.GetOutputFiles(activeForm.Nest, path);
var files = multiFile.GetOutputFiles(nest, path);
if (!ConfirmOverwrite(files, path))
return;
try
{
postProcessor.Post(activeForm.Nest, path);
postProcessor.Post(nest, path);
}
catch (Exception ex)
when (ex is InvalidOperationException or IOException or UnauthorizedAccessException)
@@ -1545,7 +1569,7 @@ namespace OpenNest.Forms
return;
}
postProcessor.Post(activeForm.Nest, path);
postProcessor.Post(nest, path);
}
}
+124
View File
@@ -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);
}
}
+122
View File
@@ -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>
public sealed class CIFiberPostProcessor
: IConfigurablePostProcessor,
IMultiFilePostProcessor
IMultiFilePostProcessor,
IPostVerificationSupport
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -27,6 +28,7 @@ namespace OpenNest.Posts.CincinnatiCIFiber
};
public string Name => "Cincinnati CI Fiber";
public bool PreservesPlacedProgramOrder => true;
public string Author => "OpenNest";
public string Description =>
"CI Fiber family laser (TF5200 / nLight CLX), e.g. CI Fiber 4020 8kW";
+1 -1
View File
@@ -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.
- **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).
- **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
+112
View File
@@ -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.