From c3dd346b7a0a346951ded7b63c64899f2ecd86af Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 28 Sep 2026 23:43:06 -0400 Subject: [PATCH] feat(cutoffs): apply nest-wide with minimum retained tail --- AGENTS.md | 2 +- OpenNest.Core/AutomaticCutOffBatch.cs | 119 ++++++++++ OpenNest.Core/AutomaticCutOffPlanner.cs | 20 +- .../CutOffs/AutomaticCutOffBatchTests.cs | 216 ++++++++++++++++++ .../CutOffs/AutomaticCutOffPlannerTests.cs | 87 +++++++ .../Forms/AutomaticCutOffFormTests.cs | 142 +++++++++++- .../Forms/AutomaticCutOffForm.Designer.cs | 28 ++- OpenNest/Forms/AutomaticCutOffForm.cs | 159 +++++++------ OpenNest/Forms/MainForm.Designer.cs | 11 +- OpenNest/Forms/MainForm.cs | 26 ++- README.md | 2 +- docs/automatic-scrap-cutoffs.md | 15 +- 12 files changed, 735 insertions(+), 92 deletions(-) create mode 100644 OpenNest.Core/AutomaticCutOffBatch.cs create mode 100644 OpenNest.Tests/CutOffs/AutomaticCutOffBatchTests.cs diff --git a/AGENTS.md b/AGENTS.md index 49c97fb..716b992 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,7 +156,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). +- **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. Both Plate and Nest menus use the modal `AutomaticCutOffForm`; Nest applies one setting set to every plate through Core's `AutomaticCutOffBatch`, which replans all plates before any changes, refuses the entire batch on blocking diagnostics, and rolls back cutoff programs/sequence on failure. Minimum tail-to-keep defaults to 12 in / 304.8 mm in the dialog; `AutomaticCutOffOptions.MinimumTailLength` is in model units (zero disables the minimum). A shorter tail suppresses only the new final separator, never other grid lines or existing definitions. 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/AutomaticCutOffBatch.cs b/OpenNest.Core/AutomaticCutOffBatch.cs new file mode 100644 index 0000000..1c04ae3 --- /dev/null +++ b/OpenNest.Core/AutomaticCutOffBatch.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest; + +/// +/// Plans and applies the same automatic-cutoff settings to a stable, ordered set of plates. +/// Callers must prevent concurrent edits for the duration of either operation. +/// +public static class AutomaticCutOffBatch +{ + /// Detached plans in plate order. Invalid input identifies the one-based plate number. + public static IReadOnlyList Create(IReadOnlyList plates, + AutomaticCutOffOptions options, CutOffSettings settings) + { + ArgumentNullException.ThrowIfNull(plates); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(settings); + if (plates.Distinct(ReferenceEqualityComparer.Instance).Count() != plates.Count) + throw new ArgumentException("Each plate must occur only once.", nameof(plates)); + + var plans = new List(plates.Count); + for (var index = 0; index < plates.Count; index++) + { + try + { + plans.Add(AutomaticCutOffPlanner.Create(plates[index], options, settings)); + } + catch (ArgumentException error) + { + throw new ArgumentException($"Plate {index + 1}: {error.Message}", nameof(plates), error); + } + } + return plans.AsReadOnly(); + } + + /// + /// Replans all plates before changing any. A blocking plan returns without applying any + /// definitions; empty/unchanged plates are untouched. On failure, restores cutoff state + /// on every touched plate, reporting explicitly if any restoration also fails. + /// Returned plans describe the fresh proposal, not preview parts to accept into a plate. + /// + public static IReadOnlyList Apply(IReadOnlyList plates, + AutomaticCutOffOptions options, CutOffSettings settings) + { + var plans = Create(plates, options, settings); + if (plans.Any(plan => plan.HasBlockingDiagnostics)) + return plans; + + var restoreActions = new List<(int PlateNumber, Action Restore)>(); + try + { + for (var index = 0; index < plates.Count; index++) + { + var plan = plans[index]; + if (plan.Definitions.Count == 0) + continue; + + var plate = plates[index]; + // Register recovery before the first observable mutation, including AddRange. + restoreActions.Add((index + 1, CaptureRestore(plate, plan))); + plate.CutOffs.AddRange(plan.Definitions); + plate.RegenerateCutOffs(settings); + } + } + catch (Exception applyError) + { + var rollbackErrors = new List(); + for (var index = restoreActions.Count - 1; index >= 0; index--) + { + var saved = restoreActions[index]; + try + { + saved.Restore(); + } + catch (Exception rollbackError) + { + // A broken observer on one plate must not prevent recovery of the others. + rollbackErrors.Add(new InvalidOperationException( + $"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError)); + } + } + if (rollbackErrors.Count > 0) + throw new InvalidOperationException( + $"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: " + + string.Join("; ", rollbackErrors.Select(e => e.Message)) + + "\nThe nest may be incomplete; review it before saving or cutting.", + new AggregateException(new[] { applyError }.Concat(rollbackErrors))); + + throw new InvalidOperationException( + $"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError); + } + return plans; + } + + private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan) + { + // Regeneration replaces drawing programs and removes/reinserts cutoff parts. + // Save only that state; real parts, poses, programs and quantities stay untouched. + var programs = plate.CutOffs.Select(c => (CutOff: c, Program: c.Drawing.Program)).ToArray(); + var parts = plate.Parts.Select((part, index) => (Part: part, Index: index)) + .Where(p => p.Part.BaseDrawing.IsCutOff).ToArray(); + return () => + { + foreach (var definition in plan.Definitions) + plate.CutOffs.Remove(definition); + for (var index = plate.Parts.Count - 1; index >= 0; index--) + { + if (plate.Parts[index].BaseDrawing.IsCutOff) + plate.Parts.RemoveAt(index); + } + foreach (var saved in programs) + saved.CutOff.Drawing.Program = saved.Program; + foreach (var saved in parts) + plate.Parts.Insert(saved.Index, saved.Part); + }; + } +} diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs index 876dd68..2f86631 100644 --- a/OpenNest.Core/AutomaticCutOffPlanner.cs +++ b/OpenNest.Core/AutomaticCutOffPlanner.cs @@ -15,6 +15,12 @@ public sealed class AutomaticCutOffOptions /// greater than AutomaticCutOffPlanner.MinimumSpacing; Create also bounds candidate count. /// public double Spacing { get; set; } + + /// + /// Minimum retained-tail length along X in model units. Zero permits any positive tail. + /// The desktop default is 12 inches (304.8 mm). + /// + public double MinimumTailLength { get; set; } } public enum AutomaticCutOffDiagnosticCode @@ -24,6 +30,7 @@ public enum AutomaticCutOffDiagnosticCode EmptyCut, SegmentedCut, NoSafeTailSeparator, + TailBelowMinimum, } public sealed record AutomaticCutOffDiagnostic( @@ -133,7 +140,16 @@ public static class AutomaticCutOffPlanner AddCandidate(sign * distance, false); } if (hasTailCandidate) - AddCandidate(sign * separator, true); + { + if (length - separator >= options.MinimumTailLength) + AddCandidate(sign * separator, true); + else + diagnostics.Add(new AutomaticCutOffDiagnostic( + AutomaticCutOffDiagnosticCode.TailBelowMinimum, + "The proposed tail is shorter than the minimum tail length; the final separator was skipped. " + + "Other automatic lines and existing cut-offs are unchanged. No retained tail is claimed.", + X: sign * separator)); + } if (diagnostics.Any(d => d.IsBlocking)) { @@ -219,6 +235,8 @@ public static class AutomaticCutOffPlanner { Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing, $"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options)); + Require(Nonnegative(options.MinimumTailLength), + "Minimum tail length must be finite and nonnegative.", 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)); diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffBatchTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffBatchTests.cs new file mode 100644 index 0000000..222a59b --- /dev/null +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffBatchTests.cs @@ -0,0 +1,216 @@ +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Tests.CutOffs; + +public class AutomaticCutOffBatchTests +{ + private static readonly AutomaticCutOffOptions Options = new() { Spacing = 35 }; + + [Fact] + public void Apply_AllPlates_UsesEachSheetAndPreservesRealPartsAndManualSequence() + { + var settings = new CutOffSettings(); + var plates = new[] { CreatePlate(1, 36, 120), CreatePlate(3, 50, 100), new Plate(60, 120) }; + var realParts = plates.SelectMany(p => p.Parts).ToArray(); + var realPrograms = realParts.Select(p => p.Program).ToArray(); + var quantities = realParts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray(); + var manual = new CutOff(new Vector(5, 0), CutOffAxis.Vertical); + plates[0].CutOffs.Add(manual); + plates[0].RegenerateCutOffs(settings); + var manualPart = plates[0].Parts[^1]; + plates[0].Parts.Remove(manualPart); + plates[0].Parts.Insert(0, manualPart); + + var preview = AutomaticCutOffBatch.Create(plates, Options, settings); + Assert.Same(manual, Assert.Single(plates[0].CutOffs)); + Assert.Empty(plates[1].CutOffs); + Assert.Empty(preview[2].Definitions); + + var plans = AutomaticCutOffBatch.Apply(plates, Options, settings); + + Assert.All(plans, p => Assert.False(p.HasBlockingDiagnostics)); + Assert.Equal(3, plans[0].Definitions.Count); + Assert.Equal(3, plans[1].Definitions.Count); + Assert.Same(manual, plates[0].CutOffs[0]); + Assert.Same(manual.Drawing, plates[0].Parts[0].BaseDrawing); + Assert.Same(realParts[0], plates[0].Parts[1]); + Assert.Same(realParts[1], plates[1].Parts[0]); + Assert.All(plates.Take(2), p => Assert.Equal(p.CutOffs.Count, p.Parts.Count(x => x.BaseDrawing.IsCutOff))); + Assert.All(plates[1].CutOffs, c => Assert.True(c.Position.X < 0)); + var tailProgram = plates[1].Parts[^1].Program; + Assert.Equal(50, tailProgram.BoundingBox().Width, 6); + Assert.Empty(plates[2].Parts); + Assert.Empty(plates[2].CutOffs); + Assert.Equal(realPrograms, realParts.Select(p => p.Program)); + Assert.Equal(quantities, realParts.Select(p => p.BaseDrawing.Quantity.Nested)); + Assert.All(preview.SelectMany(p => p.PreviewParts), p => + Assert.DoesNotContain(p, plates.SelectMany(plate => plate.Parts))); + + var originalParts = plates.Select(p => p.Parts.ToArray()).ToArray(); + var repeated = AutomaticCutOffBatch.Apply(plates, Options, settings); + Assert.All(repeated, p => Assert.Empty(p.Definitions)); + for (var i = 0; i < plates.Length; i++) + Assert.Equal(originalParts[i], plates[i].Parts.ToArray()); + + var nest = new Nest("batch-cutoffs"); + foreach (var part in realParts) + nest.Drawings.Add(part.BaseDrawing); + nest.Plates.AddRange(plates); + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + stream.Position = 0; + var loaded = new NestReader(stream).Read(); + // The existing writer omits empty plates. + Assert.Equal(2, loaded.Plates.Count); + for (var i = 0; i < loaded.Plates.Count; i++) + { + Assert.Equal(plates[i].CutOffs.Select(c => (c.Position.X, c.Axis, c.StartLimit, c.EndLimit)), + loaded.Plates[i].CutOffs.Select(c => (c.Position.X, c.Axis, c.StartLimit, c.EndLimit))); + Assert.Equal(plates[i].Parts.Select(p => p.BaseDrawing.IsCutOff), + loaded.Plates[i].Parts.Select(p => p.BaseDrawing.IsCutOff)); + } + } + + [Fact] + public void Apply_ConflictOnLaterPlate_ChangesNothingOnAnyPlate() + { + var plates = new[] { CreatePlate(), CreatePlate() }; + var settings = new CutOffSettings(); + var preview = AutomaticCutOffBatch.Create(plates, Options, settings); + Assert.All(preview, p => Assert.NotEmpty(p.Definitions)); + var limited = new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 2 }; + plates[1].CutOffs.Add(limited); + var beforeParts = plates.Select(p => p.Parts.ToArray()).ToArray(); + + var result = AutomaticCutOffBatch.Apply(plates, Options, settings); + + Assert.True(result[1].HasBlockingDiagnostics); + Assert.Empty(plates[0].CutOffs); + Assert.Same(limited, Assert.Single(plates[1].CutOffs)); + for (var i = 0; i < plates.Length; i++) + Assert.Equal(beforeParts[i], plates[i].Parts.ToArray()); + } + + [Fact] + public void Apply_InvalidLaterPlate_ReportsPlateNumberBeforeChangingAnything() + { + var plates = new[] { CreatePlate(), CreatePlate() }; + plates[1].Parts[0].Offset(500, 0); + + var error = Assert.Throws(() => + AutomaticCutOffBatch.Apply(plates, Options, new CutOffSettings())); + + Assert.Contains("Plate 2:", error.Message); + Assert.All(plates, p => Assert.Empty(p.CutOffs)); + Assert.All(plates, p => Assert.Single(p.Parts)); + } + + [Fact] + public void Apply_ReplansCurrentPartsAndSettingsInsteadOfAcceptingPreview() + { + var plates = new[] { CreatePlate(), CreatePlate() }; + var settings = new CutOffSettings(); + var preview = AutomaticCutOffBatch.Create(plates, Options, settings); + plates[1].Parts[0].Offset(5, 0); + settings.PartClearance = 2; + var expected = AutomaticCutOffPlanner.Create(plates[1], Options, settings); + + var result = AutomaticCutOffBatch.Apply(plates, Options, settings); + + Assert.NotEqual(preview[1].TailSeparatorX, result[1].TailSeparatorX); + Assert.Equal(expected.Definitions.Select(c => c.Position.X), plates[1].CutOffs.Select(c => c.Position.X)); + } + + [Fact] + public void Apply_RegenerationFailureOnLaterPlate_RestoresEveryTouchedPlate() + { + var plates = new[] { CreatePlate(), CreatePlate(), CreatePlate() }; + var settings = new CutOffSettings(); + foreach (var plate in plates) + { + plate.CutOffs.Add(new CutOff(new Vector(5, 0), CutOffAxis.Vertical)); + plate.RegenerateCutOffs(settings); + var cutoffPart = plate.Parts[^1]; + plate.Parts.Remove(cutoffPart); + plate.Parts.Insert(0, cutoffPart); + } + var beforeParts = plates.Select(p => p.Parts.ToArray()).ToArray(); + var beforeDefinitions = plates.Select(p => p.CutOffs.ToArray()).ToArray(); + var beforePrograms = plates.Select(p => p.CutOffs[0].Drawing.Program).ToArray(); + var quantities = plates.Select(p => p.Parts[1].BaseDrawing.Quantity.Nested).ToArray(); + var failOnce = true; + plates[1].PartAdded += (_, e) => + { + if (failOnce && e.Item.BaseDrawing.IsCutOff) + { + failOnce = false; + throw new InvalidOperationException("Injected regeneration failure"); + } + }; + + var error = Assert.Throws(() => + AutomaticCutOffBatch.Apply(plates, Options, settings)); + + Assert.False(failOnce); + Assert.Contains("original cut-offs were restored", error.Message); + for (var i = 0; i < plates.Length; i++) + { + Assert.Equal(beforeParts[i], plates[i].Parts.ToArray()); + Assert.Equal(beforeDefinitions[i], plates[i].CutOffs.ToArray()); + Assert.Same(beforePrograms[i], plates[i].CutOffs[0].Drawing.Program); + Assert.Equal(quantities[i], plates[i].Parts[1].BaseDrawing.Quantity.Nested); + } + } + + [Fact] + public void Apply_MinimumTailIsEvaluatedIndependentlyForEachPlate() + { + var plates = new[] { CreatePlate(length: 90), CreatePlate(), new Plate(36, 90) }; + var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 }; + + var result = AutomaticCutOffBatch.Apply(plates, options, new CutOffSettings()); + + Assert.False(result[0].HasSeparatedTail); + Assert.Equal(new[] { 35.0, 70.0 }, plates[0].CutOffs.Select(c => c.Position.X)); + Assert.True(result[1].HasSeparatedTail); + Assert.Equal(3, plates[1].CutOffs.Count); + Assert.Empty(plates[2].CutOffs); + Assert.Empty(AutomaticCutOffBatch.Apply(plates, options, new CutOffSettings()).SelectMany(p => p.Definitions)); + } + + [Fact] + public void Apply_RollbackFailureIsExplicitAndDoesNotStopRecoveryOfOtherPlates() + { + var plates = new[] { CreatePlate(), CreatePlate() }; + var originals = plates.Select(p => p.Parts.ToArray()).ToArray(); + plates[1].PartAdded += (_, _) => throw new InvalidOperationException("Apply observer failed"); + plates[1].CutOffs.ItemRemoved += (_, _) => throw new InvalidOperationException("Rollback observer failed"); + + var error = Assert.Throws(() => + AutomaticCutOffBatch.Apply(plates, Options, new CutOffSettings())); + + Assert.Contains("Restoring cut-offs also failed", error.Message); + Assert.Contains("Plate 2", error.Message); + Assert.Contains("may be incomplete", error.Message); + Assert.IsType(error.InnerException); + Assert.Empty(plates[0].CutOffs); + Assert.Equal(originals[0], plates[0].Parts.ToArray()); + } + + private static Plate CreatePlate(int quadrant = 1, double width = 36, double length = 120) + { + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(70, 0)); + program.Codes.Add(new LinearMove(70, 20)); + program.Codes.Add(new LinearMove(0, 20)); + program.Codes.Add(new LinearMove(0, 0)); + var plate = new Plate(width, length) { Quadrant = quadrant, PartSpacing = 0.5, Quantity = 2 }; + var drawing = new Drawing($"rectangle-q{quadrant}", program); + var location = new Vector(quadrant is 2 or 3 ? -80 : 10, quadrant is 3 or 4 ? -28 : 8); + plate.Parts.Add(new Part(drawing, location)); + return plate; + } +} diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs index 0407491..8e879b2 100644 --- a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs @@ -68,6 +68,93 @@ public class AutomaticCutOffPlannerTests Assert.Equal(new Vector(separator, 81), segment.To); } + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void MinimumTail_SkipsOnlyFinalSeparatorWithoutExtendingSkeletonGrid(int quadrant) + { + // The proposed separator is just before X=105, with a tail shorter than 16. + // Skipping it must not add the otherwise-next nominal line at X=105. + var plate = MakePlate(quadrant, occupied: 104); + var before = Plan(plate); + var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 16 }; + + var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings()); + + Assert.Equal(before.Definitions.Take(before.Definitions.Count - 1).Select(c => c.Position.X), Positions(plan)); + Assert.False(plan.HasSeparatedTail); + Assert.Null(plan.TailSeparatorX); + Assert.Equal(0, plan.TailLength); + Assert.Equal(120, plan.UsedSpan); + Assert.Contains(plan.Diagnostics, d => d.Message.Contains("minimum tail length")); + Assert.Empty(plate.CutOffs); + Assert.Single(plate.Parts); + } + + [Theory] + [InlineData(1, -0.01, false)] + [InlineData(1, 0, true)] + [InlineData(1, 0.01, true)] + [InlineData(2, -0.01, false)] + [InlineData(2, 0, true)] + [InlineData(2, 0.01, true)] + [InlineData(3, -0.01, false)] + [InlineData(3, 0, true)] + [InlineData(3, 0.01, true)] + [InlineData(4, -0.01, false)] + [InlineData(4, 0, true)] + [InlineData(4, 0.01, true)] + public void MinimumTail_TwelveInchesIsInclusiveAfterSeparatorClearance(int quadrant, double extra, bool keep) + { + var occupied = 120 - 12 - 0.5 - Tolerance.Epsilon - extra; + var plate = MakePlate(quadrant, occupied); + var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 }; + + var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings()); + + Assert.Equal(keep, plan.HasSeparatedTail); + Assert.Equal(keep ? 4 : 3, plan.Definitions.Count); + if (keep) + Assert.Equal(12 + extra, plan.TailLength, 6); + } + + [Fact] + public void MinimumTail_DoesNotRemoveExistingSeparatorWhenRerunWithHigherMinimum() + { + var plate = MakePlate(occupied: 110); + var settings = new CutOffSettings(); + var original = Plan(plate); + plate.CutOffs.AddRange(original.Definitions); + plate.RegenerateCutOffs(settings); + var definitions = plate.CutOffs.ToArray(); + var parts = plate.Parts.ToArray(); + var programs = definitions.Select(c => c.Drawing.Program).ToArray(); + + var plan = AutomaticCutOffPlanner.Create(plate, + new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 }, settings); + + Assert.Empty(plan.Definitions); + Assert.False(plan.HasSeparatedTail); + Assert.Equal(definitions, plate.CutOffs.ToArray()); + Assert.Equal(parts, plate.Parts.ToArray()); + Assert.Equal(programs, plate.CutOffs.Select(c => c.Drawing.Program)); + } + + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void MinimumTail_InvalidValueIsRejected(double minimum) + { + var plate = MakePlate(); + Assert.Throws(() => AutomaticCutOffPlanner.Create(plate, + new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = minimum }, new CutOffSettings())); + Assert.Empty(plate.CutOffs); + } + [Theory] [InlineData(2, 0.5)] [InlineData(0.5, 2)] diff --git a/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs b/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs index 0b3a10d..5311d93 100644 --- a/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs @@ -11,9 +11,9 @@ 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) + [InlineData(Units.Inches, "35", "in", 12)] + [InlineData(Units.Millimeters, "889", "mm", 304.8)] + public void InitialSpacingUsesNestUnitsAndActualSheetWidth(Units units, string spacing, string suffix, double minimumTail) { RunSta(() => { @@ -21,6 +21,8 @@ public class AutomaticCutOffFormTests using var form = new AutomaticCutOffForm(view, units); Assert.Equal(spacing, Control(form, "spacingBox").Text); Assert.Contains(suffix, Control