From 45d2dfced4bfeae00be3453f8f73c61d83dcb063 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 28 Sep 2026 22:50:03 -0400 Subject: [PATCH] feat(posting): require verification and explicit risk acknowledgment --- OpenNest.Console/Program.cs | 48 +- .../Diagnostics/PostVerificationAnalyzer.cs | 290 +++++++++++ .../Diagnostics/PostVerificationGeometry.cs | 173 +++++++ .../Diagnostics/PostVerificationReport.cs | 61 +++ OpenNest.Core/IPostVerificationSupport.cs | 13 + .../ConsolePostVerificationTests.cs | 69 +++ .../PostVerificationAnalyzerTests.cs | 482 ++++++++++++++++++ .../PostVerificationPostContractTests.cs | 56 ++ OpenNest.Tests/OpenNest.Tests.csproj | 1 + .../Forms/PostVerificationFormTests.cs | 223 ++++++++ OpenNest/Forms/MainForm.cs | 44 +- .../Forms/PostVerificationForm.Designer.cs | 124 +++++ OpenNest/Forms/PostVerificationForm.cs | 122 +++++ .../CIFiberPostProcessor.cs | 4 +- README.md | 2 +- docs/post-verification.md | 112 ++++ 16 files changed, 1800 insertions(+), 24 deletions(-) create mode 100644 OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs create mode 100644 OpenNest.Core/Diagnostics/PostVerificationGeometry.cs create mode 100644 OpenNest.Core/Diagnostics/PostVerificationReport.cs create mode 100644 OpenNest.Core/IPostVerificationSupport.cs create mode 100644 OpenNest.Tests/Diagnostics/ConsolePostVerificationTests.cs create mode 100644 OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs create mode 100644 OpenNest.Tests/Diagnostics/PostVerificationPostContractTests.cs create mode 100644 OpenNest.WinForms.Tests/Forms/PostVerificationFormTests.cs create mode 100644 OpenNest/Forms/PostVerificationForm.Designer.cs create mode 100644 OpenNest/Forms/PostVerificationForm.cs create mode 100644 docs/post-verification.md diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index aecc222..5f8b671 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -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 Run a post processor after nesting --post-output Output file for post processor (default: .cnc) + --acknowledge-post-risks Explicitly accept displayed verification risks for this invocation --posts-dir 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; diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs new file mode 100644 index 0000000..134238d --- /dev/null +++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs @@ -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; + +/// +/// 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. +/// +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(); + 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(); + var indices = new List(); + var obstacles = new List(); + 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 Read(Program program, Vector origin, Vector? previous, + CancellationToken token) + { + var moves = new List(); + var visiting = new HashSet(ReferenceEqualityComparer.Instance); + var budget = 1000000; + Walk(program, origin, previous); + return moves; + + Vector Walk(Program current, Vector frame, Vector? arrival) + { + token.ThrowIfCancellationRequested(); + PostVerificationGeometry.Validate(frame); + if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64) + throw new ArgumentException("Missing, recursive or excessively nested program."); + var pos = frame; + var first = true; + var countBefore = moves.Count; + foreach (var code in current.Codes) + { + token.ThrowIfCancellationRequested(); + if (--budget < 0) + throw new ArgumentException("Program expansion exceeds the verification limit."); + if (code == null) + throw new ArgumentException("Program contains a missing instruction."); + if (code is SubProgramCall call) + { + if (!double.IsFinite(call.Rotation)) + throw new ArgumentException("Subprogram rotation is not finite."); + // Call rotation is baked into the shared program by its setter. Do not + // rotate again; offsets are frame-relative even in incremental mode. + pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos); + first = false; + continue; + } + if (code is not Motion motion) + { + if (code is not (Comment or Feedrate or Kerf)) + throw new NotSupportedException("Unsupported program instruction."); + continue; + } + // Posts disagree about incremental position after suppressed instructions. + // Never silently certify a trajectory whose semantics are ambiguous. + if (motion.Suppressed) + throw new NotSupportedException("Suppressed motion requires post-specific verification."); + if (motion is not (RapidMove or LinearMove or ArcMove)) + throw new NotSupportedException("Unsupported motion."); + var reference = current.Mode == Mode.Incremental ? pos : frame; + var end = reference + motion.EndPoint; + PostVerificationGeometry.Validate(end); + var rapid = motion is RapidMove; + if (first && !rapid) + moves.Add(new(arrival, frame, true, LayerType.Display, null)); + var start = first && rapid ? arrival : pos; + var layer = motion switch + { + LinearMove line => line.Layer, + ArcMove arc => arc.Layer, + _ => LayerType.Display + }; + if (!Enum.IsDefined(layer)) + throw new NotSupportedException("Unsupported motion layer."); + if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation)) + throw new NotSupportedException("Unsupported arc direction."); + var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end, + motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null, + motion is ArcMove { Rotation: RotationType.CW }); + moves.Add(new(start, end, rapid, layer, curve)); + pos = end; + first = false; + } + visiting.Remove(current); + if (moves.Count == countBefore) + throw new ArgumentException("Program has no motions."); + return pos; + } + } + + private static void AnalyzeMoves(List moves, bool cutoff, List obstacles, + List findings, int plate, int part, CancellationToken token) + { + var contour = new List(); + var unfinished = new List(); + var hasLead = false; + var contourNumber = 0; + foreach (var move in moves) + { + token.ThrowIfCancellationRequested(); + if (move.Rapid) + { + Finish(); + hasLead = false; + if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon) + continue; + foreach (var obstacle in obstacles) + { + token.ThrowIfCancellationRequested(); + if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token)) + findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part, + $"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " + + $"of part {obstacle.Part}.")); + } + } + else if (move.Layer == LayerType.Leadin) + { + Finish(); + hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon; + } + else if (move.Layer is LayerType.Leadout or LayerType.Scribe) + { + if (move.Layer == LayerType.Leadout && contour.Count > 0 + && !PostVerificationGeometry.Closed(contour) + && move.Curve.Length > PostVerificationGeometry.Epsilon) + findings.Add(new(PostVerificationKind.Incomplete, plate, part, null, + "A lead-out follows an open cutting contour and may cut through its retention gap. " + + "Rapid safety for that contour requires manual review.")); + Finish(); + hasLead = false; + } + else if (move.Curve.Length > PostVerificationGeometry.Epsilon) + { + if (contour.Count == 0) + { + contourNumber++; + if (!cutoff && !hasLead) + findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null, + $"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")); + hasLead = false; + // A rapid can pause/reposition without leaving any material gap. + // Retain already-cut fragments, but do not turn them into obstacles + // until an actually continuous chain closes. + var previous = unfinished.FindIndex(chain => + chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon); + if (previous >= 0) + { + contour.AddRange(unfinished[previous]); + unfinished.RemoveAt(previous); + } + } + contour.Add(move.Curve); + // A completed contour becomes an obstacle immediately, not at part end. + if (PostVerificationGeometry.Closed(contour)) + Finish(); + } + } + Finish(); + if (unfinished.Count > 1) + findings.Add(new(PostVerificationKind.Incomplete, plate, part, null, + "Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " + + "they cannot be assumed to be retained by tabs. Review rapid travel manually.")); + + void Finish() + { + if (contour.Count == 0) + return; + // A real uncut gap leaves the contour attached. CuttingParameters can be stale; + // no flag or tab configuration is used as evidence of retention. + if (!cutoff && PostVerificationGeometry.Closed(contour)) + obstacles.Add(new(part, contourNumber, contour.ToArray())); + else if (!cutoff) + unfinished.Add(contour.ToArray()); + contour.Clear(); + } + } + + private sealed record Move(Vector? Start, Vector End, bool Rapid, LayerType Layer, + PostVerificationGeometry.Curve Curve); + private sealed record Obstacle(int Part, int Contour, IReadOnlyList Curves); +} diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs new file mode 100644 index 0000000..c3c64e9 --- /dev/null +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest.Diagnostics; + +/// Local native line/arc queries, without changing the engine's geometry semantics. +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 curves) => curves.Count > 0 + && curves[0].Start.DistanceTo(curves[^1].End) <= Epsilon; + + internal static bool Crosses(Vector start, Vector end, IReadOnlyList 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 { 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; + } + } +} diff --git a/OpenNest.Core/Diagnostics/PostVerificationReport.cs b/OpenNest.Core/Diagnostics/PostVerificationReport.cs new file mode 100644 index 0000000..835176f --- /dev/null +++ b/OpenNest.Core/Diagnostics/PostVerificationReport.cs @@ -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 +} + +/// Plate and part numbers are one-based; plate zero denotes a whole-post limitation. +public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber, + int? PartNumber, int? OtherPartNumber, string Message); + +/// Owned, immutable findings. Consent is evaluated afresh, never stored. +public sealed class PostVerificationReport +{ + internal PostVerificationReport(IEnumerable findings) + { + Findings = Array.AsReadOnly(findings.ToArray()); + } + + public IReadOnlyList 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." : ".")); + } + } +} diff --git a/OpenNest.Core/IPostVerificationSupport.cs b/OpenNest.Core/IPostVerificationSupport.cs new file mode 100644 index 0000000..8e21d74 --- /dev/null +++ b/OpenNest.Core/IPostVerificationSupport.cs @@ -0,0 +1,13 @@ +namespace OpenNest; + +/// +/// 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. +/// +public interface IPostVerificationSupport +{ + bool PreservesPlacedProgramOrder { get; } +} diff --git a/OpenNest.Tests/Diagnostics/ConsolePostVerificationTests.cs b/OpenNest.Tests/Diagnostics/ConsolePostVerificationTests.cs new file mode 100644 index 0000000..43bc4c2 --- /dev/null +++ b/OpenNest.Tests/Diagnostics/ConsolePostVerificationTests.cs @@ -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 })!; + } +} diff --git a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs new file mode 100644 index 0000000..a14757e --- /dev/null +++ b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs @@ -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(() => + 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)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 } + }; +} diff --git a/OpenNest.Tests/Diagnostics/PostVerificationPostContractTests.cs b/OpenNest.Tests/Diagnostics/PostVerificationPostContractTests.cs new file mode 100644 index 0000000..318e73d --- /dev/null +++ b/OpenNest.Tests/Diagnostics/PostVerificationPostContractTests.cs @@ -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; + } +} diff --git a/OpenNest.Tests/OpenNest.Tests.csproj b/OpenNest.Tests/OpenNest.Tests.csproj index d11ff88..844af08 100644 --- a/OpenNest.Tests/OpenNest.Tests.csproj +++ b/OpenNest.Tests/OpenNest.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/OpenNest.WinForms.Tests/Forms/PostVerificationFormTests.cs b/OpenNest.WinForms.Tests/Forms/PostVerificationFormTests.cs new file mode 100644 index 0000000..280fb7c --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/PostVerificationFormTests.cs @@ -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(form, "acknowledgeBox"); + var post = Control