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.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." : "."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<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
@@ -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
@@ -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>
|
||||
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";
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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