From 1581260e315949271e34c673db0c799651d8f3ed Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 28 Sep 2026 20:50:53 -0400 Subject: [PATCH] feat(cutoffs): add automatic skeleton cutoffs with tail preservation --- AGENTS.md | 1 + OpenNest.Core/AutomaticCutOffPlanner.cs | 364 ++++++++++ .../CutOffs/AutomaticCutOffLifecycleTests.cs | 353 +++++++++ .../CutOffs/AutomaticCutOffPlannerTests.cs | 683 ++++++++++++++++++ .../Forms/AutomaticCutOffFormTests.cs | 241 ++++++ .../Forms/PlateViewFillLifetimeTests.cs | 307 ++++++++ OpenNest/Controls/PlateView.cs | 109 +-- .../Forms/AutomaticCutOffForm.Designer.cs | 261 +++++++ OpenNest/Forms/AutomaticCutOffForm.cs | 194 +++++ OpenNest/Forms/MainForm.Designer.cs | 11 +- OpenNest/Forms/MainForm.cs | 34 + README.md | 2 +- docs/automatic-scrap-cutoffs.md | 38 + 13 files changed, 2551 insertions(+), 47 deletions(-) create mode 100644 OpenNest.Core/AutomaticCutOffPlanner.cs create mode 100644 OpenNest.Tests/CutOffs/AutomaticCutOffLifecycleTests.cs create mode 100644 OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs create mode 100644 OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs create mode 100644 OpenNest.WinForms.Tests/Forms/PlateViewFillLifetimeTests.cs create mode 100644 OpenNest/Forms/AutomaticCutOffForm.Designer.cs create mode 100644 OpenNest/Forms/AutomaticCutOffForm.cs create mode 100644 docs/automatic-scrap-cutoffs.md diff --git a/AGENTS.md b/AGENTS.md index 4d31a8e..c061d81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,6 +155,7 @@ Keep vendor programming manuals and full-text extracts outside source control un - `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. After its null/empty guards, `DefaultFillComparer` decides unequal counts without scoring; equal counts still use scores, and exact ties retain the current layout. `FillHelpers.FillPattern` computes eager scores only when no custom comparer is supplied; custom comparers remain authoritative and may perform their own scoring. - **Extents column pitch**: for finite valid geometry, finite pair height, and finite nonnegative spacing, `FillExtents.BuildColumn` uses `pair.Bbox.Width + partSpacing` directly. The old vertical slide calculation clamps to the same pitch, so it need not prepare boundaries or temporary test clones. Negative/nonfinite spacing or nonfinite pair height retains the legacy calculation: public/interactive callers do not all validate spacing. Do not remove `BuildPair` boundary preparation or the adjusted-column overlap fallback, or turn this shortcut into a geometry/validation policy change. - **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds each at its previous index in `Plate.Parts` (its cut sequence number; new cut-offs go at the end). Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load; `CutOffDto.Sequence` restores each one's place in the cut sequence). Posts must follow `Plate.Parts` order for cut-offs too, not move them to the end. +- **Automatic scrap cutoffs**: `AutomaticCutOffPlanner.Create` in Core proposes detached vertical `CutOff` definitions and preview parts from the real-part envelope, with quadrant-aware pitch and a verified full-width tail separator beyond `max(PartSpacing, PartClearance)` plus tolerance. Planning must not mutate the plate. Apply only an unblocked, current plan by adding its definitions to `Plate.CutOffs` and calling `Plate.RegenerateCutOffs`; never accept preview Parts directly. Existing equivalent full-span lines are suppressed, while same-line limited cuts require manual review. Nominal spacing is not certification of disconnected hopper-sized scrap. See [operator workflow and limitations](docs/automatic-scrap-cutoffs.md). - **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity. - **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). MCP, API, and Training projects use `ImportDrawing` for headless conversion. The console uses `Import` followed by `BuildDrawing` so it can report bend-repair outcomes. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata. - **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` and the saved `SCRIBE` layer (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth). diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs new file mode 100644 index 0000000..d6c0c3b --- /dev/null +++ b/OpenNest.Core/AutomaticCutOffPlanner.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest; + +public sealed class AutomaticCutOffOptions +{ + /// + /// Nominal distance between vertical cut lines in model units. Must be finite and + /// greater than AutomaticCutOffPlanner.MinimumSpacing; Create also bounds candidate count. + /// + public double Spacing { get; set; } +} + +public enum AutomaticCutOffDiagnosticCode +{ + ExistingCutOff, + LimitedCutOffConflict, + EmptyCut, + SegmentedCut, + NoSafeTailSeparator, +} + +public sealed record AutomaticCutOffDiagnostic( + AutomaticCutOffDiagnosticCode Code, string Message, bool IsBlocking = false, double? X = null); + +/// +/// Detached proposal. Collections are read-only; contained definitions/preview parts belong +/// to the caller. Never accept a blocked plan or a preview made for an older layout/settings. +/// +public sealed class AutomaticCutOffPlan +{ + /// Only new, usable definitions; existing equivalent definitions are not returned. + public IReadOnlyList Definitions { get; internal set; } = Array.Empty(); + + /// Detached display parts, in the same order as Definitions. Not for acceptance. + public IReadOnlyList PreviewParts { get; internal set; } = Array.Empty(); + + public IReadOnlyList Diagnostics { get; internal set; } = + Array.Empty(); + + /// Furthest real-part X distance from the origin, excluding cut-off parts. + public double OccupiedSpan { get; internal set; } + + /// + /// Distance to the verified separator, or full sheet length when no separated tail can + /// be claimed. Zero on empty sheets. This is not a disconnected-scrap-size guarantee. + /// + public double UsedSpan { get; internal set; } + + /// Length beyond a verified separator, otherwise zero (also on empty sheets). + public double TailLength { get; internal set; } + + /// Signed X coordinate of a verified new or equivalent existing separator. + public double? TailSeparatorX { get; internal set; } + + /// True only for a verified full-width separator, never just a nominal boundary. + public bool HasSeparatedTail => TailSeparatorX.HasValue; + + public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking); +} + +/// Pure proposals for vertical scrap cuts, measured in the plate's model units. +public static class AutomaticCutOffPlanner +{ + public const double GeometryTolerance = Tolerance.Epsilon; + public const double MinimumSpacing = 2 * GeometryTolerance; + public const int MaximumCandidateCount = 10000; + + /// + /// Plans without changing the plate, its parts, or existing cut-off definitions/programs. + /// Invalid inputs throw ArgumentException. Blocking diagnostics return no definitions. + /// Accept by adding Definitions to Plate.CutOffs and calling RegenerateCutOffs with the + /// same settings, only while the layout is unchanged. Do not add PreviewParts to the plate. + /// Nominal spacing is not a guarantee of fully disconnected, hopper-sized scrap. + /// + public static AutomaticCutOffPlan Create(Plate plate, AutomaticCutOffOptions options, + CutOffSettings settings) + { + ArgumentNullException.ThrowIfNull(plate); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(settings); + ValidateInputs(plate, options, settings); + var bounds = plate.BoundingBox(false); + Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel), + "Physical sheet bounds and overtravel must be finite.", nameof(plate)); + + var sign = plate.Quadrant is 2 or 3 ? -1 : 1; + var occupied = 0.0; + var hasParts = false; + foreach (var part in plate.Parts) + { + Require(part?.BaseDrawing != null, "Every part must have a drawing.", nameof(plate)); + if (part.BaseDrawing.IsCutOff) + continue; + + var partBounds = ValidatePart(part, bounds); + occupied = System.Math.Max(occupied, sign > 0 ? partBounds.Right : -partBounds.Left); + hasParts = true; + } + if (!hasParts) + return new AutomaticCutOffPlan(); + + // Use distances from the coordinate origin, not the sheet's lower-left corner. + var length = plate.Size.Length; + occupied = System.Math.Min(occupied, length); + var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance) + + GeometryTolerance; + var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance; + var usedSpan = hasTailCandidate ? separator : length; + var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing); + Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount, + $"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options)); + + // Validate and bound the candidate count before preparing geometry or allocating lines. + ValidateExisting(plate, bounds, settings); + var cache = Plate.BuildPerimeterCache(plate); + var definitions = new List(); + var diagnostics = new List(); + var separated = false; + var separatorX = (double?)null; + + // Multiplication by an integer avoids drift from repeated floating-point addition. + for (var index = 1; index <= (int)candidateCount; index++) + { + var distance = index * options.Spacing; + if (distance >= usedSpan - GeometryTolerance) + break; + AddCandidate(sign * distance, false); + } + if (hasTailCandidate) + AddCandidate(sign * separator, true); + + if (diagnostics.Any(d => d.IsBlocking)) + { + // A partial proposal must not accidentally be accepted around a manual conflict. + definitions.Clear(); + separated = false; + separatorX = null; + } + + return new AutomaticCutOffPlan + { + Definitions = definitions.AsReadOnly(), + PreviewParts = definitions.Select(c => new Part(c.Drawing)).ToList().AsReadOnly(), + Diagnostics = diagnostics.AsReadOnly(), + OccupiedSpan = occupied, + UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length, + TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0, + TailSeparatorX = separatorX, + }; + + void AddCandidate(double x, bool isSeparator) + { + var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical && + Near(c.Position.X, x)).ToList(); + if (matches.Any(c => !FullSpanLimits(c, bounds, settings))) + { + diagnostics.Add(new AutomaticCutOffDiagnostic( + AutomaticCutOffDiagnosticCode.LimitedCutOffConflict, + "A same-line manual cut has different limits. Manual review is required; no cuts may be applied.", + true, x)); + if (isSeparator) + WarnNoSeparator(x); + return; + } + + // Even a duplicate must be regenerated detached with CURRENT settings. Its live + // drawing can be stale, and a tolerance-close line can intersect a part at the tail. + var existing = matches.FirstOrDefault(); + var candidate = existing == null + ? new CutOff(new Vector(x, 0), CutOffAxis.Vertical) + : new CutOff(existing.Position, existing.Axis) + { StartLimit = existing.StartLimit, EndLimit = existing.EndLimit }; + candidate.Regenerate(plate, settings, cache); + var program = candidate.Drawing.Program; + var usable = HasUsableSegments(program); + if (existing != null) + diagnostics.Add(new AutomaticCutOffDiagnostic( + AutomaticCutOffDiagnosticCode.ExistingCutOff, + "An equivalent full-span cut-off already exists; no duplicate was added.", X: x)); + if (!usable) + diagnostics.Add(new AutomaticCutOffDiagnostic( + AutomaticCutOffDiagnosticCode.EmptyCut, + "The line has no usable cut segments after part clearance and minimum-length filtering; it is not a partition.", + X: x)); + + if (isSeparator) + { + if (!usable || !IsFullSpanProgram(program, bounds)) + { + WarnNoSeparator(x); + return; + } + separated = true; + separatorX = candidate.Position.X; + } + else if (usable && !IsFullSpanProgram(program, bounds)) + diagnostics.Add(new AutomaticCutOffDiagnostic( + AutomaticCutOffDiagnosticCode.SegmentedCut, + "Part clearance or segment filtering interrupts this line; nominal spacing does not guarantee disconnected scrap.", + X: x)); + + if (usable && existing == null) + definitions.Add(candidate); + } + + void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic( + AutomaticCutOffDiagnosticCode.NoSafeTailSeparator, + "No safe full-width tail separator survives the current settings. No separated tail is claimed; review manually.", + X: x)); + } + + private static void ValidateInputs(Plate plate, AutomaticCutOffOptions options, CutOffSettings settings) + { + Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing, + $"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options)); + Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 && + double.IsFinite(plate.Size.Width) && plate.Size.Width > 0, + "Sheet length and width must be positive and finite.", nameof(plate)); + Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate)); + Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate)); + Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) && + Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings)); + Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings)); + Require(plate.Parts != null && plate.CutOffs != null, + "Plate parts and cut-off collections are required.", nameof(plate)); + } + + private static Box ValidatePart(Part part, Box sheet) + { + Require(Finite(part.Location) && double.IsFinite(part.Rotation), + "Part pose must be finite.", "plate"); + ValidateProgram(part.Program, new HashSet()); + var box = part.Program.BoundingBox(); + box.Offset(part.Location); + Require(ValidBox(box) && box.Length > 0 && box.Width > 0, + "Real parts must have finite, nonempty geometry.", "plate"); + var cached = part.BoundingBox; + Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) && + Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top), + "Part geometry has stale bounds; update it before planning.", "plate"); + Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate"); + + // Checking raw coordinates above prevents NaNs being hidden by min/max comparisons. + // Checking converted entities catches overflowing incremental moves and curve bounds. + var hasMaterial = false; + foreach (var entity in ConvertProgram.ToGeometry(part.Program)) + { + var entityBox = entity.BoundingBox; + Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate"); + if (!SpecialLayers.IsMaterial(entity.Layer)) + continue; + entityBox = entityBox.Translate(part.Location); + // Conversion can refit arc centers beyond the raw-program/cached bounds. + // CutOff's broad phase uses cached bounds without tolerance: even a smaller + // protrusion can be crossed by a repeated line, so require strict containment. + Require(entityBox.Left >= cached.Left && entityBox.Right <= cached.Right && + entityBox.Bottom >= cached.Bottom && entityBox.Top <= cached.Top, + "Converted material extends outside cached part bounds; repair it before planning.", "plate"); + Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate"); + hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0; + } + Require(hasMaterial, "Real parts must contain material geometry.", "plate"); + return box; + } + + private static void ValidateProgram(Program program, HashSet path) + { + Require(program?.Codes != null && path.Count < 64 && path.Add(program), + "Part program is missing, recursive, or nested too deeply.", "plate"); + foreach (var code in program.Codes) + { + Require(code != null, "Part program contains a missing instruction.", "plate"); + if (code is Motion motion) + Require(Finite(motion.EndPoint), "Part motion coordinates must be finite.", "plate"); + if (code is ArcMove arc) + Require(Finite(arc.CenterPoint) && Enum.IsDefined(arc.Rotation), + "Part arc geometry must be finite with a valid direction.", "plate"); + if (code is SubProgramCall call) + { + Require(Finite(call.Offset) && double.IsFinite(call.Rotation), + "Part sub-program pose must be finite.", "plate"); + ValidateProgram(call.Program, path); + } + } + path.Remove(program); + } + + private static void ValidateExisting(Plate plate, Box bounds, CutOffSettings settings) + { + foreach (var cut in plate.CutOffs) + { + Require(cut != null && Enum.IsDefined(cut.Axis) && Finite(cut.Position) && + (!cut.StartLimit.HasValue || double.IsFinite(cut.StartLimit.Value)) && + (!cut.EndLimit.HasValue || double.IsFinite(cut.EndLimit.Value)), + "Existing cut-off definitions must be finite with a valid axis.", nameof(plate)); + var start = cut.StartLimit ?? (cut.Axis == CutOffAxis.Vertical ? bounds.Bottom : bounds.Left); + var end = cut.EndLimit ?? ((cut.Axis == CutOffAxis.Vertical ? bounds.Top : bounds.Right) + + settings.Overtravel); + Require(double.IsFinite(end) && start < end, + "Existing cut-off limits must be finite and ordered.", nameof(plate)); + } + } + + private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) => + Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) && + Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel); + + private static bool HasUsableSegments(Program program) + { + if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0) + return false; + for (var i = 0; i < program.Codes.Count; i += 2) + { + if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to || + !Finite(from.EndPoint) || !Finite(to.EndPoint) || + !Near(from.EndPoint.X, to.EndPoint.X) || + System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance) + return false; + } + return true; + } + + private static bool IsFullSpanProgram(Program program, Box bounds) + { + // No gaps, bridges, or filtered middle segments can separate the tail. + if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from || + program.Codes[1] is not LinearMove to) + return false; + return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance && + System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance; + } + + private static bool ValidBox(Box box) => box != null && Finite(box.Location) && + Nonnegative(box.Length) && Nonnegative(box.Width) && + double.IsFinite(box.Right) && double.IsFinite(box.Top); + + private static bool Inside(Box box, Box sheet) => + box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance && + box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance; + + private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y); + private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0; + private static bool Near(double a, double b) + { + // An exact tolerance-sized offset can round just above epsilon after subtraction. + // Allow two representational steps, not a geometry-scale relative tolerance. + var magnitude = System.Math.Max(System.Math.Abs(a), System.Math.Abs(b)); + var step = System.Math.BitIncrement(magnitude) - magnitude; + return System.Math.Abs(a - b) <= GeometryTolerance + (double.IsFinite(step) ? 2 * step : 0); + } + + private static void Require(bool valid, string message, string parameter) + { + if (!valid) + throw new ArgumentException(message, parameter); + } +} diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffLifecycleTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffLifecycleTests.cs new file mode 100644 index 0000000..eae272b --- /dev/null +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffLifecycleTests.cs @@ -0,0 +1,353 @@ +using System.IO.Compression; +using System.Text.Json; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.IO; +using OpenNest.Math; + +namespace OpenNest.Tests.CutOffs; + +public class AutomaticCutOffLifecycleTests +{ + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void Apply_AppendsPlannedDefinitions_AfterExistingMixedSequence(int quadrant) + { + var settings = new CutOffSettings { PartClearance = 0.75, Overtravel = 2 }; + var nest = MakeNest(quadrant, settings); + var plate = Assert.Single(nest.Plates); + var realParts = CaptureRealParts(plate); + var manual = Assert.Single(plate.CutOffs); + var manualDefinition = Definition(manual); + var originalSequence = plate.Parts.ToArray(); + + var plan = CreatePlan(plate, settings); + + Assert.Equal(originalSequence, plate.Parts.ToArray()); + Assert.Same(manual, Assert.Single(plate.CutOffs)); + Assert.Equal(3, plan.PreviewParts.Count); + Assert.All(plan.PreviewParts, preview => Assert.DoesNotContain(preview, plate.Parts)); + var previewPrograms = plan.PreviewParts.Select(p => NestWriter.GetProgramText(p.Program)).ToArray(); + + Apply(plate, plan, settings); + + Assert.Equal(new[] { manual }.Concat(plan.Definitions), plate.CutOffs); + Assert.Equal(manualDefinition, Definition(manual)); + AssertSequence(plate, realParts[0].Part, realParts[1].Part); + AssertOnePartPerDefinition(plate); + AssertRealPartsUnchanged(plate, realParts); + for (var i = 0; i < plan.Definitions.Count; i++) + { + var materialized = plate.Parts[i + 3]; + Assert.Same(plan.Definitions[i].Drawing, materialized.BaseDrawing); + Assert.NotSame(plan.PreviewParts[i], materialized); + Assert.Equal(previewPrograms[i], NestWriter.GetProgramText(materialized.Program)); + } + + var repeated = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings); + Assert.Empty(repeated.Definitions); + Assert.Empty(repeated.PreviewParts); + Assert.False(repeated.HasBlockingDiagnostics); + Assert.True(repeated.HasSeparatedTail); + Assert.Equal(plan.TailSeparatorX, repeated.TailSeparatorX); + Assert.Equal(plan.UsedSpan, repeated.UsedSpan); + Assert.Equal(plan.TailLength, repeated.TailLength); + Assert.Equal(3, repeated.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff)); + + // Accepting the empty repeat and regenerating must not duplicate or resequence anything. + Apply(plate, repeated, settings); + Assert.Equal(new[] { manual }.Concat(plan.Definitions), plate.CutOffs); + AssertSequence(plate, realParts[0].Part, realParts[1].Part); + AssertOnePartPerDefinition(plate); + AssertRealPartsUnchanged(plate, realParts); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void SaveAndReload_PreservesDefinitionsLimitsAndExactMixedSequence(int quadrant) + { + // NestReader uses default CutOffSettings; nondefault toolpath fidelity is not this contract. + var settings = new CutOffSettings(); + var nest = MakeNest(quadrant, settings); + var plate = Assert.Single(nest.Plates); + var realParts = CaptureRealParts(plate); + Apply(plate, CreatePlan(plate, settings), settings); + var definitions = plate.CutOffs.Select(Definition).ToArray(); + + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + AssertArchiveContainsOnlyRealParts(stream, plate); + stream.Position = 0; + var loaded = new NestReader(stream).Read(); + var loadedPlate = Assert.Single(loaded.Plates); + var loadedRealParts = loadedPlate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToArray(); + + Assert.Equal(quadrant, loadedPlate.Quadrant); + Assert.Equal(plate.Size, loadedPlate.Size); + Assert.Equal(plate.PartSpacing, loadedPlate.PartSpacing); + Assert.Equal(plate.Quantity, loadedPlate.Quantity); + Assert.Equal(definitions, loadedPlate.CutOffs.Select(Definition)); + Assert.Equal(realParts.Select(p => p.Location), loadedRealParts.Select(p => p.Location)); + Assert.Equal(realParts.Select(p => p.ProgramText), loadedRealParts.Select(p => NestWriter.GetProgramText(p.Program))); + Assert.Equal(realParts.Select(p => p.Rotation), loadedRealParts.Select(p => p.Rotation)); + AssertSequence(loadedPlate, loadedRealParts[0], loadedRealParts[1]); + AssertOnePartPerDefinition(loadedPlate); + var loadedDrawing = Assert.Single(loaded.Drawings); + Assert.False(loadedDrawing.IsCutOff); + Assert.All(loadedRealParts, p => Assert.Same(loadedDrawing, p.BaseDrawing)); + Assert.Equal(realParts[0].Required, loadedDrawing.Quantity.Required); + Assert.Equal(realParts[0].Nested, loadedDrawing.Quantity.Nested); + AssertRealPartsUnchanged(plate, realParts); + + var loadedState = CaptureRealParts(loadedPlate); + loadedPlate.RegenerateCutOffs(settings); + Assert.Equal(definitions, loadedPlate.CutOffs.Select(Definition)); + AssertSequence(loadedPlate, loadedRealParts[0], loadedRealParts[1]); + AssertOnePartPerDefinition(loadedPlate); + AssertRealPartsUnchanged(loadedPlate, loadedState); + } + + [Theory] + [InlineData(1, 5)] + [InlineData(2, 5)] + [InlineData(3, 5)] + [InlineData(4, 5)] + [InlineData(1, 10)] + [InlineData(2, 10)] + [InlineData(3, 10)] + [InlineData(4, 10)] + public void MovePartAfterReload_RegeneratesSafeSegments_WithoutMovingDefinitions(int quadrant, double offsetX) + { + var settings = new CutOffSettings(); + var nest = MakeNest(quadrant, settings); + var source = Assert.Single(nest.Plates); + Apply(source, CreatePlan(source, settings), settings); + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + stream.Position = 0; + var loaded = new NestReader(stream).Read(); + var plate = Assert.Single(loaded.Plates); + var definitions = plate.CutOffs.ToArray(); + var definitionValues = definitions.Select(Definition).ToArray(); + var realParts = CaptureRealParts(plate); + var moved = realParts[1].Part; + var separator = definitions[^1]; + var separatorPart = plate.Parts[^1]; + var before = Assert.Single(Segments(separatorPart)); + var bounds = plate.BoundingBox(false); + Assert.Equal(bounds.Bottom, before.From.Y); + Assert.Equal(bounds.Top, before.To.Y); + Assert.Equal(separator.Position.X, before.From.X); + Assert.Equal(separator.Position.X, before.To.X); + AssertCutsAvoidRectangles(plate, settings.PartClearance); + + // Move the outer part across the old separator: its fixed line must now be trimmed, + // not shifted to a new tail or left as a stale full-width cut through the part. + var offset = new Vector(quadrant is 2 or 3 ? -offsetX : offsetX, quadrant is 3 or 4 ? -10 : 10); + moved.Offset(offset); + Assert.Equal(realParts[1].Location + offset, moved.Location); + var movedState = CaptureRealParts(plate); + plate.RegenerateCutOffs(settings); + + Assert.Equal(definitions, plate.CutOffs.ToArray()); + Assert.Equal(definitionValues, plate.CutOffs.Select(Definition)); + AssertSequence(plate, realParts[0].Part, moved); + AssertOnePartPerDefinition(plate); + AssertRealPartsUnchanged(plate, movedState); + AssertRealPartsUnchanged(plate, new[] { realParts[0], realParts[1] with { Location = moved.Location } }); + Assert.NotSame(separatorPart, plate.Parts[^1]); + Assert.DoesNotContain(separatorPart, plate.Parts); + var after = Segments(plate.Parts[^1]); + Assert.Equal(2, after.Length); + Assert.All(after, s => + { + Assert.Equal(separator.Position.X, s.From.X); + Assert.Equal(separator.Position.X, s.To.X); + }); + AssertCutsAvoidRectangles(plate, settings.PartClearance); + + var regeneratedPrograms = plate.Parts.Where(p => p.BaseDrawing.IsCutOff) + .Select(p => NestWriter.GetProgramText(p.Program)).ToArray(); + plate.RegenerateCutOffs(settings); + Assert.Equal(definitionValues, plate.CutOffs.Select(Definition)); + Assert.Equal(regeneratedPrograms, plate.Parts.Where(p => p.BaseDrawing.IsCutOff) + .Select(p => NestWriter.GetProgramText(p.Program))); + AssertSequence(plate, realParts[0].Part, moved); + AssertOnePartPerDefinition(plate); + AssertRealPartsUnchanged(plate, movedState); + AssertCutsAvoidRectangles(plate, settings.PartClearance); + } + + private static Nest MakeNest(int quadrant, CutOffSettings settings) + { + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(20, 0)); + program.Codes.Add(new LinearMove(20, 20)); + program.Codes.Add(new LinearMove(0, 20)); + program.Codes.Add(new LinearMove(0, 0)); + Assert.True(Assert.Single(ShapeBuilder.GetShapes(ConvertProgram.ToGeometry(program))).IsClosed()); + var drawing = new Drawing("square", program); + Assert.Equal(400, drawing.Area); + drawing.Quantity.Required = 10; + var nest = new Nest("automatic-cutoff-lifecycle"); + nest.Drawings.Add(drawing); + var plate = new Plate(81, 120) { Quadrant = quadrant, PartSpacing = 0.5, Quantity = 2 }; + var negativeX = quadrant is 2 or 3; + var negativeY = quadrant is 3 or 4; + var first = new Part(drawing, new Vector(negativeX ? -30 : 10, negativeY ? -40 : 20)); + var second = new Part(drawing, new Vector(negativeX ? -80 : 60, negativeY ? -40 : 20)); + plate.Parts.Add(first); + plate.Parts.Add(second); + var manual = new CutOff(new Vector(negativeX ? -15 : 15, negativeY ? -10 : 10), CutOffAxis.Horizontal) + { + StartLimit = negativeX ? -25 : 5, + EndLimit = negativeX ? -5 : 25, + }; + plate.CutOffs.Add(manual); + plate.RegenerateCutOffs(settings); + var manualPart = plate.Parts[^1]; + plate.Parts.Remove(manualPart); + plate.Parts.Insert(1, manualPart); + Assert.Equal(new[] { first, manualPart, second }, plate.Parts); + Assert.Equal(4, drawing.Quantity.Nested); + nest.Plates.Add(plate); + return nest; + } + + private static AutomaticCutOffPlan CreatePlan(Plate plate, CutOffSettings settings) + { + var plan = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings); + var sign = plate.Quadrant is 2 or 3 ? -1 : 1; + var separator = 80 + System.Math.Max(plate.PartSpacing, settings.PartClearance) + Tolerance.Epsilon; + Assert.False(plan.HasBlockingDiagnostics); + Assert.True(plan.HasSeparatedTail); + Assert.Equal(80, plan.OccupiedSpan); + Assert.Equal(separator, plan.UsedSpan); + Assert.Equal(120 - separator, plan.TailLength); + Assert.Equal(sign * separator, plan.TailSeparatorX); + Assert.Equal(new[] { sign * 35.0, sign * 70.0, sign * separator }, plan.Definitions.Select(c => c.Position.X)); + Assert.All(plan.Definitions, c => + { + Assert.Equal(CutOffAxis.Vertical, c.Axis); + Assert.Null(c.StartLimit); + Assert.Null(c.EndLimit); + }); + return plan; + } + + private static void Apply(Plate plate, AutomaticCutOffPlan plan, CutOffSettings settings) + { + Assert.False(plan.HasBlockingDiagnostics); + foreach (var definition in plan.Definitions) + plate.CutOffs.Add(definition); + plate.RegenerateCutOffs(settings); + } + + private static void AssertSequence(Plate plate, Part first, Part second) => + Assert.Collection(plate.Parts, + p => Assert.Same(first, p), + p => Assert.Same(plate.CutOffs[0].Drawing, p.BaseDrawing), + p => Assert.Same(second, p), + p => Assert.Same(plate.CutOffs[1].Drawing, p.BaseDrawing), + p => Assert.Same(plate.CutOffs[2].Drawing, p.BaseDrawing), + p => Assert.Same(plate.CutOffs[3].Drawing, p.BaseDrawing)); + + private static void AssertOnePartPerDefinition(Plate plate) + { + Assert.Equal(4, plate.CutOffs.Count); + Assert.Equal(4, plate.Parts.Count(p => p.BaseDrawing.IsCutOff)); + Assert.All(plate.CutOffs, c => + { + Assert.True(c.Drawing.IsCutOff); + Assert.Single(plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, c.Drawing))); + }); + } + + private static (double X, double Y, CutOffAxis Axis, double? Start, double? End) Definition(CutOff cut) => + (cut.Position.X, cut.Position.Y, cut.Axis, cut.StartLimit, cut.EndLimit); + + private sealed record RealPartState(Part Part, Program Program, string ProgramText, Vector Location, + double Rotation, Program DrawingProgram, string DrawingText, int Required, int Nested); + + private static RealPartState[] CaptureRealParts(Plate plate) => plate.Parts + .Where(p => !p.BaseDrawing.IsCutOff) + .Select(p => new RealPartState(p, p.Program, NestWriter.GetProgramText(p.Program), p.Location, + p.Rotation, p.BaseDrawing.Program, NestWriter.GetProgramText(p.BaseDrawing.Program), + p.BaseDrawing.Quantity.Required, p.BaseDrawing.Quantity.Nested)).ToArray(); + + private static void AssertRealPartsUnchanged(Plate plate, RealPartState[] expected) + { + Assert.Equal(expected.Select(s => s.Part), plate.Parts.Where(p => !p.BaseDrawing.IsCutOff)); + Assert.All(expected, s => + { + Assert.Same(s.Program, s.Part.Program); + Assert.Equal(s.ProgramText, NestWriter.GetProgramText(s.Part.Program)); + Assert.Equal(s.Location, s.Part.Location); + Assert.Equal(s.Rotation, s.Part.Rotation); + Assert.Same(s.DrawingProgram, s.Part.BaseDrawing.Program); + Assert.Equal(s.DrawingText, NestWriter.GetProgramText(s.Part.BaseDrawing.Program)); + Assert.Equal(s.Required, s.Part.BaseDrawing.Quantity.Required); + Assert.Equal(s.Nested, s.Part.BaseDrawing.Quantity.Nested); + }); + } + + private static void AssertArchiveContainsOnlyRealParts(MemoryStream stream, Plate plate) + { + stream.Position = 0; + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + using var reader = new StreamReader(archive.GetEntry("nest.json")!.Open()); + using var json = JsonDocument.Parse(reader.ReadToEnd()); + var savedPlate = Assert.Single(json.RootElement.GetProperty("plates").EnumerateArray()); + var savedParts = savedPlate.GetProperty("parts").EnumerateArray().ToArray(); + Assert.Equal(2, savedParts.Length); + Assert.Single(json.RootElement.GetProperty("drawings").EnumerateArray()); + Assert.Single(archive.Entries.Where(e => e.FullName.StartsWith("programs/", StringComparison.Ordinal))); + Assert.DoesNotContain(archive.Entries, e => e.FullName.StartsWith("parts/", StringComparison.Ordinal)); + var realParts = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToArray(); + for (var i = 0; i < realParts.Length; i++) + { + Assert.Equal(realParts[i].Location.X, savedParts[i].GetProperty("x").GetDouble()); + Assert.Equal(realParts[i].Location.Y, savedParts[i].GetProperty("y").GetDouble()); + } + var cuts = savedPlate.GetProperty("cutOffs").EnumerateArray().ToArray(); + Assert.Equal(new[] { 1, 3, 4, 5 }, cuts.Select(c => c.GetProperty("sequence").GetInt32())); + } + + private static (Vector From, Vector To)[] Segments(Part part) + { + Assert.NotEmpty(part.Program.Codes); + Assert.Equal(0, part.Program.Codes.Count % 2); + return Enumerable.Range(0, part.Program.Codes.Count / 2).Select(i => ( + Assert.IsType(part.Program.Codes[2 * i]).EndPoint + part.Location, + Assert.IsType(part.Program.Codes[2 * i + 1]).EndPoint + part.Location)).ToArray(); + } + + private static void AssertCutsAvoidRectangles(Plate plate, double clearance) + { + // Independent axis-aligned rectangle oracle: inspect every emitted cutting move, + // not rapids, counts, or the same CutOff.ComputeSegments algorithm under test. + var rectangles = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).Select(p => p.BoundingBox).ToArray(); + foreach (var cutPart in plate.Parts.Where(p => p.BaseDrawing.IsCutOff)) + foreach (var segment in Segments(cutPart)) + { + var minX = System.Math.Min(segment.From.X, segment.To.X); + var maxX = System.Math.Max(segment.From.X, segment.To.X); + var minY = System.Math.Min(segment.From.Y, segment.To.Y); + var maxY = System.Math.Max(segment.From.Y, segment.To.Y); + Assert.True(minX == maxX || minY == maxY); + Assert.True(minX < maxX || minY < maxY); + Assert.All(rectangles, box => Assert.True( + maxX <= box.Left - clearance || minX >= box.Right + clearance || + maxY <= box.Bottom - clearance || minY >= box.Top + clearance, + $"Cut {segment.From} -> {segment.To} crosses a rectangle or its clearance.")); + } + } +} diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs new file mode 100644 index 0000000..0407491 --- /dev/null +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs @@ -0,0 +1,683 @@ +using System.Reflection; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Tests.CutOffs; + +public class AutomaticCutOffPlannerTests +{ + private static Plate MakePlate(int quadrant = 1, double occupied = 80) + { + var plate = new Plate(81, 120) { Quadrant = quadrant, PartSpacing = 0.5, Quantity = 2 }; + var x = quadrant is 2 or 3 ? -occupied : 10; + var y = quadrant is 3 or 4 ? -40 : 20; + plate.Parts.Add(Rectangle(occupied - 10, 20, new Vector(x, y))); + return plate; + } + + private static Part Rectangle(double length, double width, Vector location) + { + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(length, 0)); + program.Codes.Add(new LinearMove(length, width)); + program.Codes.Add(new LinearMove(0, width)); + program.Codes.Add(new LinearMove(0, 0)); + var drawing = new Drawing("rectangle", program); + Assert.Equal(length * width, drawing.Area, 8); + return new Part(drawing, location); + } + + private static AutomaticCutOffPlan Plan(Plate plate, double spacing = 35, + CutOffSettings? settings = null) => AutomaticCutOffPlanner.Create( + plate, new AutomaticCutOffOptions { Spacing = spacing }, settings ?? new CutOffSettings()); + + private static double[] Positions(AutomaticCutOffPlan plan) => + plan.Definitions.Select(c => c.Position.X).ToArray(); + + private static (Vector From, Vector To)[] Segments(Program program) => + Enumerable.Range(0, program.Codes.Count / 2).Select(i => ( + Assert.IsType(program.Codes[2 * i]).EndPoint, + Assert.IsType(program.Codes[2 * i + 1]).EndPoint)).ToArray(); + + [Fact] + public void UsedEnvelope_StopsBeforeUnusedTail_AndPreviewsAreDetached() + { + var plate = MakePlate(); + var plan = Plan(plate); + var separator = 80 + 0.5 + Tolerance.Epsilon; + + Assert.Equal(new[] { 35, 70, separator }, Positions(plan)); + Assert.All(plan.Definitions, c => Assert.Equal(CutOffAxis.Vertical, c.Axis)); + Assert.Equal(80, plan.OccupiedSpan); + Assert.Equal(separator, plan.UsedSpan); + Assert.Equal(120 - separator, plan.TailLength); + Assert.Equal(separator, plan.TailSeparatorX); + Assert.True(plan.HasSeparatedTail); + Assert.False(plan.HasBlockingDiagnostics); + Assert.Equal(plan.Definitions.Count, plan.PreviewParts.Count); + for (var i = 0; i < plan.Definitions.Count; i++) + { + Assert.Same(plan.Definitions[i].Drawing, plan.PreviewParts[i].BaseDrawing); + Assert.DoesNotContain(plan.PreviewParts[i], plate.Parts); + } + var segment = Assert.Single(Segments(plan.Definitions[^1].Drawing.Program)); + Assert.Equal(new Vector(separator, 0), segment.From); + Assert.Equal(new Vector(separator, 81), segment.To); + } + + [Theory] + [InlineData(2, 0.5)] + [InlineData(0.5, 2)] + [InlineData(2, 2)] + [InlineData(0, 0)] + public void TailUsesGreaterClearance_NotSum(double partSpacing, double clearance) + { + var plate = MakePlate(); + plate.PartSpacing = partSpacing; + var plan = Plan(plate, settings: new CutOffSettings { PartClearance = clearance }); + Assert.Equal(80 + System.Math.Max(partSpacing, clearance) + Tolerance.Epsilon, + plan.TailSeparatorX); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(2, -1)] + [InlineData(3, -1)] + [InlineData(4, 1)] + public void AllQuadrants_AdvanceXFromOrigin_AcrossPhysicalWidth(int quadrant, int sign) + { + var plate = MakePlate(quadrant); + plate.EdgeSpacing = new Spacing(10, 10); + var settings = new CutOffSettings { Overtravel = 3 }; + var plan = Plan(plate, settings: settings); + Assert.Equal(new[] { sign * 35.0, sign * 70.0, sign * (80.5 + Tolerance.Epsilon) }, + Positions(plan)); + var bounds = plate.BoundingBox(false); + var segment = Assert.Single(Segments(plan.Definitions[^1].Drawing.Program)); + Assert.Equal(bounds.Bottom, segment.From.Y); + Assert.Equal(bounds.Top + 3, segment.To.Y); + Assert.Equal(80.5 + Tolerance.Epsilon, plan.UsedSpan); + } + + [Fact] + public void Planning_DoesNotMutateAnyLiveObjectsOrSequence() + { + var plate = MakePlate(); + var drawing = plate.Parts[0].BaseDrawing; + var nest = new Nest("test"); + nest.Drawings.Add(drawing); + nest.Plates.Add(plate); + var manual = new CutOff(new Vector(25, 0), CutOffAxis.Vertical) + { StartLimit = 5, EndLimit = 12 }; + plate.CutOffs.Add(manual); + plate.RegenerateCutOffs(new CutOffSettings()); + var cutoffPart = plate.Parts[^1]; + plate.Parts.Remove(cutoffPart); + plate.Parts.Insert(0, cutoffPart); + var parts = plate.Parts.ToArray(); + var definitions = plate.CutOffs.ToArray(); + var programs = parts.Select(p => p.Program).ToArray(); + var text = programs.Select(p => p.ToString()).ToArray(); + var locations = parts.Select(p => p.Location).ToArray(); + var bounds = parts.Select(p => p.BoundingBox).ToArray(); + var drawingProgram = drawing.Program; + var cutoffProgram = manual.Drawing.Program; + var nested = drawing.Quantity.Nested; + var changes = 0; + plate.PartAdded += (_, _) => changes++; + plate.PartRemoved += (_, _) => changes++; + plate.PartChanged += (_, _) => changes++; + + var plan = Plan(plate); + + Assert.NotEmpty(plan.Definitions); + Assert.Equal(parts, plate.Parts.ToArray()); + Assert.Equal(definitions, plate.CutOffs.ToArray()); + Assert.Equal(programs, plate.Parts.Select(p => p.Program)); + Assert.Equal(text, plate.Parts.Select(p => p.Program.ToString())); + Assert.Equal(locations, plate.Parts.Select(p => p.Location)); + Assert.Equal(bounds, plate.Parts.Select(p => p.BoundingBox)); + Assert.Same(drawingProgram, drawing.Program); + Assert.Same(cutoffProgram, manual.Drawing.Program); + Assert.Equal(nested, drawing.Quantity.Nested); + Assert.Same(drawing, Assert.Single(nest.Drawings)); + Assert.Equal(5, manual.StartLimit); + Assert.Equal(12, manual.EndLimit); + Assert.Equal(0, changes); + } + + [Theory] + [InlineData(CutDirection.AwayFromOrigin)] + [InlineData(CutDirection.TowardOrigin)] + public void RepeatedLines_UseCurrentManualSegmentation_WithoutIncreasingClearance(CutDirection direction) + { + var plate = MakePlate(); + plate.PartSpacing = 8; + var settings = new CutOffSettings + { PartClearance = 1, Overtravel = 2, MinSegmentLength = 0.5, CutDirection = direction }; + var plan = Plan(plate, settings: settings); + var automatic = plan.Definitions[0]; + var manual = new CutOff(automatic.Position, automatic.Axis); + manual.Regenerate(plate, settings, Plate.BuildPerimeterCache(plate)); + Assert.Equal(manual.Drawing.Program.ToString(), automatic.Drawing.Program.ToString()); + var segments = Segments(automatic.Drawing.Program); + Assert.Equal(2, segments.Length); + Assert.All(segments, s => Assert.True( + System.Math.Max(s.From.Y, s.To.Y) <= 19 || + System.Math.Min(s.From.Y, s.To.Y) >= 41)); + Assert.Contains(segments, s => System.Math.Abs(s.To.Y - 19) < 0.01 || + System.Math.Abs(s.From.Y - 19) < 0.01); + Assert.Equal(1, settings.PartClearance); + } + + [Fact] + public void EmptyPlate_AndCutOffOnlyPlate_AreNeverChopped() + { + var plate = new Plate(81, 120); + Assert.Empty(Plan(plate).Definitions); + var old = new CutOff(new Vector(110, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(old); + plate.RegenerateCutOffs(new CutOffSettings()); + var plan = Plan(plate); + Assert.Empty(plan.Definitions); + Assert.Empty(plan.PreviewParts); + Assert.Equal(0, plan.OccupiedSpan); + Assert.Equal(0, plan.UsedSpan); + Assert.Equal(0, plan.TailLength); + Assert.False(plan.HasSeparatedTail); + } + + [Fact] + public void OldCutOffParts_DoNotExtendTheEnvelope() + { + var plate = MakePlate(); + plate.CutOffs.Add(new CutOff(new Vector(115, 0), CutOffAxis.Vertical)); + plate.RegenerateCutOffs(new CutOffSettings()); + Assert.Equal(new[] { 35, 70, 80.5 + Tolerance.Epsilon }, Positions(Plan(plate))); + } + + [Fact] + public void UsedSpanSmallerThanPitch_OnlyGeneratesSeparator() + { + var plan = Plan(MakePlate(occupied: 20)); + Assert.Equal(new[] { 20.5 + Tolerance.Epsilon }, Positions(plan)); + } + + [Fact] + public void ExactPitchAtSeparator_DoesNotGenerateBoundaryTwice() + { + var plate = MakePlate(occupied: 69.5 - Tolerance.Epsilon); + Assert.Equal(new[] { 35.0, 70.0 }, Positions(Plan(plate))); + } + + [Theory] + [InlineData(119.5)] + [InlineData(120)] + public void NoRoomForSeparator_UsesFullSheet_NoEdgeDuplicate(double occupied) + { + var plan = Plan(MakePlate(occupied: occupied), spacing: 40); + Assert.Equal(new[] { 40.0, 80.0 }, Positions(plan)); + Assert.Equal(120, plan.UsedSpan); + Assert.False(plan.HasSeparatedTail); + Assert.Null(plan.TailSeparatorX); + Assert.Equal(0, plan.TailLength); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + [InlineData(double.Epsilon)] + [InlineData(0.000001)] + [InlineData(0.00001)] + [InlineData(0.001)] + public void InvalidOrExcessivePitch_IsRejected(double pitch) + { + Assert.ThrowsAny(() => Plan(MakePlate(), pitch)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void InvalidSheetDimensions_AreRejected(double value) + { + var plate = MakePlate(); + plate.Size = new Size(value, 120); + Assert.ThrowsAny(() => Plan(plate)); + plate.Size = new Size(81, value); + Assert.ThrowsAny(() => Plan(plate)); + } + + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void InvalidPartSpacingAndSettings_AreRejected(double value) + { + var plate = MakePlate(); + plate.PartSpacing = value; + Assert.ThrowsAny(() => Plan(plate)); + plate.PartSpacing = 0; + Assert.ThrowsAny(() => Plan(plate, + settings: new CutOffSettings { PartClearance = value })); + Assert.ThrowsAny(() => Plan(plate, + settings: new CutOffSettings { Overtravel = value })); + Assert.ThrowsAny(() => Plan(plate, + settings: new CutOffSettings { MinSegmentLength = value })); + } + + [Fact] + public void InvalidQuadrantOrDirection_IsRejected() + { + var plate = MakePlate(); + Assert.ThrowsAny(() => Plan(plate, + settings: new CutOffSettings { CutDirection = (CutDirection)42 })); + // The public setter coerces bad quadrants; simulate damaged state explicitly. + typeof(Plate).GetField("quadrant", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(plate, 0); + Assert.ThrowsAny(() => Plan(plate)); + } + + [Fact] + public void NullArguments_AreRejected() + { + Assert.Throws(() => AutomaticCutOffPlanner.Create(null!, new(), new())); + Assert.Throws(() => AutomaticCutOffPlanner.Create(MakePlate(), null!, new())); + Assert.Throws(() => AutomaticCutOffPlanner.Create(MakePlate(), new(), null!)); + } + + [Theory] + [InlineData(-11, 0)] + [InlineData(50, 0)] + [InlineData(0, -21)] + [InlineData(0, 42)] + [InlineData(double.NaN, 0)] + [InlineData(double.PositiveInfinity, 0)] + public void InvalidOrOutOfSheetGeometry_IsRejected(double dx, double dy) + { + var plate = MakePlate(); + plate.Parts[0].Offset(dx, dy); + Assert.ThrowsAny(() => Plan(plate)); + } + + [Fact] + public void EmptyOrNonfiniteProgramGeometry_IsRejected_EvenWithOldBounds() + { + var plate = MakePlate(); + plate.Parts[0].Program.Codes.Clear(); + Assert.ThrowsAny(() => Plan(plate)); + plate = MakePlate(); + ((Motion)plate.Parts[0].Program.Codes[2]).EndPoint = new Vector(double.NaN, 2); + Assert.ThrowsAny(() => Plan(plate)); + } + + [Fact] + public void EmptyRepeatedCuts_AreDiagnostic_NotAcceptedPartitions() + { + var plate = new Plate(81, 120); + plate.Parts.Add(Rectangle(80, 81, new Vector())); + var plan = Plan(plate); + Assert.Single(plan.Definitions); + Assert.True(plan.HasSeparatedTail); + Assert.Equal(2, plan.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.EmptyCut)); + } + + [Fact] + public void FilteredSeparator_DoesNotClaimSeparatedTail() + { + var plan = Plan(MakePlate(), settings: new CutOffSettings { MinSegmentLength = 100 }); + Assert.Empty(plan.Definitions); + Assert.False(plan.HasSeparatedTail); + Assert.Equal(0, plan.TailLength); + Assert.Null(plan.TailSeparatorX); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator); + } + + [Fact] + public void ApplyingDefinitions_ThenRerunning_PreservesPartsProgramsQuantitiesAndSequence() + { + var plate = MakePlate(); + var real = plate.Parts[0]; + var secondReal = new Part(real.BaseDrawing, new Vector(0, 60)); + plate.Parts.Add(secondReal); + var secondProgram = secondReal.Program; + var secondLocation = secondReal.Location; + var program = real.Program; + var text = program.ToString(); + var location = real.Location; + var nested = real.BaseDrawing.Quantity.Nested; + var settings = new CutOffSettings { Overtravel = 2, PartClearance = 0.75 }; + var manual = new CutOff(new Vector(15, 0), CutOffAxis.Horizontal) + { StartLimit = 5, EndLimit = 15 }; + plate.CutOffs.Add(manual); + plate.RegenerateCutOffs(settings); + var manualPart = plate.Parts[^1]; + plate.Parts.Remove(manualPart); + plate.Parts.Insert(0, manualPart); + var first = Plan(plate, settings: settings); + foreach (var definition in first.Definitions) + plate.CutOffs.Add(definition); + plate.RegenerateCutOffs(settings); + var second = Plan(plate, settings: settings); + Assert.Empty(second.Definitions); + Assert.True(second.HasSeparatedTail); + Assert.Equal(first.UsedSpan, second.UsedSpan); + Assert.Equal(3, second.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff)); + plate.RegenerateCutOffs(settings); + Assert.Equal(4, plate.Parts.Count(p => p.BaseDrawing.IsCutOff)); + Assert.All(plate.CutOffs, c => Assert.Single(plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, c.Drawing)))); + Assert.Same(manual.Drawing, plate.Parts[0].BaseDrawing); + Assert.Same(real, plate.Parts[1]); + Assert.Same(secondReal, plate.Parts[2]); + Assert.Same(secondProgram, secondReal.Program); + Assert.Equal(secondLocation, secondReal.Location); + Assert.Same(program, real.Program); + Assert.Equal(text, real.Program.ToString()); + Assert.Equal(location, real.Location); + Assert.Equal(nested, real.BaseDrawing.Quantity.Nested); + Assert.Equal(5, manual.StartLimit); + Assert.Equal(15, manual.EndLimit); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void EquivalentFullSpanLimits_AndCoordinateTolerance_SuppressDuplicates(bool explicitLimits) + { + var plate = MakePlate(); + var existing = new CutOff(new Vector(35 + Tolerance.Epsilon / 2, 999), CutOffAxis.Vertical); + if (explicitLimits) + { + existing.StartLimit = 0; + existing.EndLimit = 83; + } + plate.CutOffs.Add(existing); + var plan = Plan(plate, settings: new CutOffSettings { Overtravel = 2 }); + Assert.Equal(new[] { 70, 80.5 + Tolerance.Epsilon }, Positions(plan)); + Assert.False(plan.HasBlockingDiagnostics); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff); + } + + [Fact] + public void DifferentAxisOrDifferentCoordinate_DoesNotSuppress() + { + var plate = MakePlate(); + plate.CutOffs.Add(new CutOff(new Vector(35, 35), CutOffAxis.Horizontal)); + plate.CutOffs.Add(new CutOff(new Vector(35.01, 0), CutOffAxis.Vertical)); + Assert.Equal(3, Plan(plate).Definitions.Count); + } + + [Theory] + [InlineData(35)] + [InlineData(80.50001)] + public void SameLineLimitedManualCut_BlocksWholePlan_WithoutMutatingManual(double x) + { + var plate = MakePlate(); + var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical) + { StartLimit = 10, EndLimit = 60 }; + plate.CutOffs.Add(manual); + var program = manual.Drawing.Program; + var plan = Plan(plate); + Assert.True(plan.HasBlockingDiagnostics); + Assert.Empty(plan.Definitions); + Assert.Empty(plan.PreviewParts); + Assert.False(plan.HasSeparatedTail); + Assert.Contains(plan.Diagnostics, d => d.IsBlocking && + d.Code == AutomaticCutOffDiagnosticCode.LimitedCutOffConflict); + Assert.Same(program, manual.Drawing.Program); + Assert.Equal(10, manual.StartLimit); + Assert.Equal(60, manual.EndLimit); + } + + [Fact] + public void ExistingFullSpanAlongsideLimitedConflict_DoesNotHideConflict() + { + var plate = MakePlate(); + plate.CutOffs.Add(new CutOff(new Vector(35, 0), CutOffAxis.Vertical)); + plate.CutOffs.Add(new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 40 }); + Assert.True(Plan(plate).HasBlockingDiagnostics); + } + + [Fact] + public void ExistingSeparator_MetadataUsesItsActualToleranceCloseCoordinate() + { + var plate = MakePlate(); + var x = 80.5 + Tolerance.Epsilon * 1.5; + plate.CutOffs.Add(new CutOff(new Vector(x, 0), CutOffAxis.Vertical)); + var plan = Plan(plate); + Assert.True(plan.HasSeparatedTail); + Assert.Equal(x, plan.TailSeparatorX); + Assert.Equal(x, plan.UsedSpan); + Assert.Equal(120 - x, plan.TailLength); + } + + [Fact] + public void ToleranceCloseExistingSeparator_ThatStillTouchesClearance_DoesNotClaimTail() + { + var plate = MakePlate(); + plate.PartSpacing = 0; + plate.CutOffs.Add(new CutOff(new Vector(80, 0), CutOffAxis.Vertical)); + var plan = Plan(plate, settings: new CutOffSettings { PartClearance = 0 }); + Assert.False(plan.HasSeparatedTail); + Assert.Equal(0, plan.TailLength); + Assert.DoesNotContain(plan.Definitions, c => c.Position.X > 70); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator); + } + + [Fact] + public void FractionalPitch_UsesIntegerMultiplicationWithoutCumulativeDrift() + { + var plate = MakePlate(occupied: 11); + var plan = Plan(plate, spacing: 0.7); + var repeated = plan.Definitions.Take(plan.Definitions.Count - 1).ToArray(); + Assert.NotEmpty(repeated); + for (var i = 0; i < repeated.Length; i++) + Assert.Equal((i + 1) * 0.7, repeated[i].Position.X); + } + + [Fact] + public void ConcaveOrSlopedPart_UsesPerimeterNotJustItsBoundingBox() + { + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(30, 0)); + program.Codes.Add(new LinearMove(0, 30)); + program.Codes.Add(new LinearMove(0, 0)); + var plate = new Plate(81, 120); + plate.Parts.Add(new Part(new Drawing("triangle", program), new Vector(10, 10))); + var plan = Plan(plate, spacing: 20, settings: new CutOffSettings { PartClearance = 0 }); + var segments = Segments(plan.Definitions[0].Drawing.Program); + Assert.Equal(2, segments.Length); + Assert.Equal(30, segments[1].From.Y, 8); // Bounding-box fallback would start at 40. + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.SegmentedCut); + } + + [Fact] + public void ValidButStalePartBounds_AreRejectedRatherThanUsingUnsafeBroadphase() + { + var plate = MakePlate(); + ((Motion)plate.Parts[0].Program.Codes[2]).EndPoint = new Vector(90, 20); + Assert.ThrowsAny(() => Plan(plate)); + } + + private static Plate MakeArcPlate(double centerError) + { + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(10, 0)); + program.Codes.Add(new ArcMove(10, 20, 10, 10 + centerError, RotationType.CCW)); + program.Codes.Add(new LinearMove(0, 20)); + program.Codes.Add(new LinearMove(0, 0)); + var material = ConvertProgram.ToGeometry(program).Where(e => SpecialLayers.IsMaterial(e.Layer)); + Assert.True(Assert.Single(ShapeBuilder.GetShapes(material)).IsClosed()); + var drawing = new Drawing("D shape", program); + Assert.Equal(200 + 50 * System.Math.PI, drawing.Area, 2); // Drawing area tessellates arcs. + var plate = new Plate(81, 120) { PartSpacing = 0 }; + plate.Parts.Add(new Part(drawing, new Vector(10, 10))); + return plate; + } + + [Theory] + [InlineData(0.0001, false)] + [InlineData(0.0000075, true)] + public void ConvertedArcOutsideCachedBounds_IsRejectedBeforeAcceptance(double centerError, bool repeatedCut) + { + var plate = MakeArcPlate(centerError); + var real = plate.Parts[0]; + var cached = real.BoundingBox; + var raw = real.Program.BoundingBox().Translate(real.Location); + Assert.Equal(raw.Right, cached.Right); // Neither cache refresh nor raw-program bounds reveal the error. + Assert.Equal(30 - centerError, cached.Right, 10); + var arc = Assert.Single(ConvertProgram.ToGeometry(real.Program).OfType()); + arc.Offset(real.Location); + Assert.Equal(30, arc.BoundingBox.Right); + var protrusion = arc.BoundingBox.Right - cached.Right; + Assert.True(protrusion > 0); + Assert.Equal(repeatedCut, protrusion < AutomaticCutOffPlanner.GeometryTolerance); + + // A separator crosses the larger protrusion. Even sub-tolerance protrusions are + // unsafe when a repeated line falls between the cached and converted bounds. + var unsafeX = cached.Right + (repeatedCut ? protrusion / 2 : AutomaticCutOffPlanner.GeometryTolerance); + var unsafeLine = new Line(new Vector(unsafeX, 0), new Vector(unsafeX, 81)); + Assert.True(arc.Intersects(unsafeLine, out var intersections)); + Assert.Equal(2, intersections.Count); + if (repeatedCut) + plate.Parts.Add(Rectangle(10, 10, new Vector(60, 50))); + var settings = new CutOffSettings { PartClearance = 0 }; + var manual = new CutOff(new Vector(100, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(manual); + plate.RegenerateCutOffs(settings); + var parts = plate.Parts.ToArray(); + var definitions = plate.CutOffs.ToArray(); + var programs = parts.Select(p => p.Program).ToArray(); + var text = programs.Select(p => p.ToString()).ToArray(); + var locations = parts.Select(p => p.Location).ToArray(); + var bounds = parts.Select(p => p.BoundingBox).ToArray(); + var manualProgram = manual.Drawing.Program; + var originalCenter = Assert.IsType(real.Program.Codes[2]).CenterPoint; + var changes = 0; + plate.PartAdded += (_, _) => changes++; + plate.PartRemoved += (_, _) => changes++; + plate.PartChanged += (_, _) => changes++; + AutomaticCutOffPlan? plan = null; + + Assert.Throws(() => + { + plan = Plan(plate, spacing: repeatedCut ? unsafeX : 35, settings: settings); + foreach (var definition in plan.Definitions) + plate.CutOffs.Add(definition); + plate.RegenerateCutOffs(settings); + }); + + Assert.Null(plan); // No unsafe proposal can escape to the normal acceptance path. + Assert.Equal(parts, plate.Parts.ToArray()); + Assert.Equal(definitions, plate.CutOffs.ToArray()); + Assert.Equal(programs, plate.Parts.Select(p => p.Program)); + Assert.Equal(text, plate.Parts.Select(p => p.Program.ToString())); + Assert.Equal(locations, plate.Parts.Select(p => p.Location)); + Assert.Equal(bounds, plate.Parts.Select(p => p.BoundingBox)); + Assert.Same(manualProgram, manual.Drawing.Program); + Assert.Equal(originalCenter.X, Assert.IsType(real.Program.Codes[2]).CenterPoint.X); + Assert.Equal(originalCenter.Y, Assert.IsType(real.Program.Codes[2]).CenterPoint.Y); + Assert.Equal(0, changes); + } + + [Fact] + public void ExactArcWithinCachedBounds_PlansAndMaterializesSafeCuts() + { + var plate = MakeArcPlate(0); + var real = plate.Parts[0]; + var program = real.Program; + var settings = new CutOffSettings { PartClearance = 0 }; + var plan = Plan(plate, spacing: 25, settings: settings); + Assert.True(plan.HasSeparatedTail); + Assert.False(plan.HasBlockingDiagnostics); + Assert.Equal(30, plan.OccupiedSpan); + Assert.Equal(new[] { 25, 30 + AutomaticCutOffPlanner.GeometryTolerance }, Positions(plan)); + Assert.Same(real, Assert.Single(plate.Parts)); + Assert.Empty(plate.CutOffs); + var previewSegments = plan.PreviewParts.Select(p => Segments(p.Program)).ToArray(); + + foreach (var definition in plan.Definitions) + plate.CutOffs.Add(definition); + plate.RegenerateCutOffs(settings); + + var cuts = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToArray(); + Assert.Equal(plan.Definitions.Count, cuts.Length); + for (var i = 0; i < cuts.Length; i++) + Assert.Equal(previewSegments[i], Segments(cuts[i].Program)); + var repeated = Segments(cuts[0].Program); + Assert.Equal(2, repeated.Length); + Assert.Equal(20 - System.Math.Sqrt(75), repeated[0].To.Y, 8); + Assert.Equal(20 + System.Math.Sqrt(75), repeated[1].From.Y, 8); + var separator = Assert.Single(Segments(cuts[1].Program)); + Assert.Equal(new Vector(plan.TailSeparatorX!.Value, 0), separator.From); + Assert.Equal(new Vector(plan.TailSeparatorX.Value, 81), separator.To); + var arc = Assert.Single(ConvertProgram.ToGeometry(real.Program).OfType()); + arc.Offset(real.Location); + Assert.False(arc.Intersects(new Line(separator.From, separator.To), out _)); + Assert.Same(real, plate.Parts[0]); + Assert.Same(program, real.Program); + var repeatedPlan = Plan(plate, spacing: 25, settings: settings); + Assert.Empty(repeatedPlan.Definitions); + Assert.True(repeatedPlan.HasSeparatedTail); + Assert.Equal(plan.TailSeparatorX, repeatedPlan.TailSeparatorX); + } + + [Fact] + public void InvalidArcCenterAndRecursiveProgram_AreRejectedBeforeGeometryConversion() + { + var plate = MakePlate(); + plate.Parts[0].Program.Codes.Add(new ArcMove(new Vector(), new Vector(double.NaN, 0))); + Assert.ThrowsAny(() => Plan(plate)); + plate = MakePlate(); + var program = plate.Parts[0].Program; + program.Codes.Add(new SubProgramCall { Program = program }); + Assert.ThrowsAny(() => Plan(plate)); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void ApplyAndRepeat_IsIdempotentInEveryQuadrant(int quadrant) + { + var plate = MakePlate(quadrant); + var settings = new CutOffSettings(); + var first = Plan(plate, settings: settings); + foreach (var definition in first.Definitions) + plate.CutOffs.Add(definition); + plate.RegenerateCutOffs(settings); + var second = Plan(plate, settings: settings); + Assert.Empty(second.Definitions); + Assert.True(second.HasSeparatedTail); + Assert.Equal(first.TailSeparatorX, second.TailSeparatorX); + Assert.Equal(first.TailLength, second.TailLength); + Assert.Equal(3, plate.Parts.Count(p => p.BaseDrawing.IsCutOff)); + } + + [Fact] + public void ExistingSeparator_IsVerifiedUnderCurrentSettings_NotStaleProgram() + { + var plate = MakePlate(); + var existing = new CutOff(new Vector(80.5 + Tolerance.Epsilon, 0), CutOffAxis.Vertical); + existing.Regenerate(plate, new CutOffSettings()); + Assert.NotEmpty(existing.Drawing.Program.Codes); + plate.CutOffs.Add(existing); + var program = existing.Drawing.Program; + var plan = Plan(plate, settings: new CutOffSettings { MinSegmentLength = 100 }); + Assert.False(plan.HasSeparatedTail); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator); + Assert.Same(program, existing.Drawing.Program); + } +} diff --git a/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs b/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs new file mode 100644 index 0000000..0b3a10d --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs @@ -0,0 +1,241 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Windows.Forms; +using OpenNest.CNC; +using OpenNest.Controls; +using OpenNest.Forms; +using OpenNest.Geometry; + +namespace OpenNest.WinForms.Tests.Forms; + +public class AutomaticCutOffFormTests +{ + [Theory] + [InlineData(Units.Inches, "35", "in")] + [InlineData(Units.Millimeters, "889", "mm")] + public void InitialSpacingUsesNestUnitsAndActualSheetWidth(Units units, string spacing, string suffix) + { + RunSta(() => + { + using var view = CreateView(); + using var form = new AutomaticCutOffForm(view, units); + Assert.Equal(spacing, Control(form, "spacingBox").Text); + Assert.Contains(suffix, Control