diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index ab82a3f8..d52a5097 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -209,6 +209,12 @@ static class NestConsole ApplyTemplate(plate, options); ApplyOverrides(plate, options); + if (!options.AutoNest && PlateFillService.Refusal(plate) is { } refusal) + { + Console.Error.WriteLine($"Error: {refusal} Use --autonest."); + return 2; + } + var drawing = ResolveDrawing(nest, options); if (drawing == null) diff --git a/OpenNest.Engine.Tests/Jobs/IrregularStockTests.cs b/OpenNest.Engine.Tests/Jobs/IrregularStockTests.cs index 68af7a19..b9f48f91 100644 --- a/OpenNest.Engine.Tests/Jobs/IrregularStockTests.cs +++ b/OpenNest.Engine.Tests/Jobs/IrregularStockTests.cs @@ -229,6 +229,11 @@ public class IrregularStockTests var exception = Assert.Throws(() => PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(), null, default)); Assert.Contains("rectangular", exception.Message); + + // Callers check the refusal first and show it instead of catching the exception. + Assert.Equal(exception.Message, PlateFillService.Refusal(plate)); + Assert.Null(PlateFillService.Refusal(new Plate(48, 96))); + Assert.Null(PlateFillService.Refusal(null)); } [Fact] diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs index 220d3ad0..b3c62cec 100644 --- a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs +++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs @@ -29,6 +29,16 @@ public static class PlateFillService "Horizontal Remnant", ]; + /// Why no strategy fills a plate with a sheet outline (see ). + public const string OutlinedSheetMessage = + "Fill strategies need a rectangular sheet. Nest an outlined sheet with Auto Nest and the Irregular engine."; + + /// + /// Why the fill strategies cannot fill , or null when they can. Callers + /// show it to the user instead of calling an operation that would throw it. + /// + public static string Refusal(Plate plate) => plate?.Outline == null ? null : OutlinedSheetMessage; + public static List FillItem( string strategy, Plate plate, @@ -433,9 +443,8 @@ public static class PlateFillService ArgumentNullException.ThrowIfNull(strategy); ArgumentNullException.ThrowIfNull(plate); // Every filler lays parts out in a rectangle; only Auto Nest's Irregular engine honors an outline. - if (plate.Outline != null) - throw new InvalidOperationException( - "Fill strategies need a rectangular sheet. Nest an outlined sheet with Auto Nest and the Irregular engine."); + if (Refusal(plate) is { } refusal) + throw new InvalidOperationException(refusal); // An explicit empty string is an unknown strategy; only ResolveStrategy(string), // used for caller selections, maps null/empty to Fill. ResolveStrategy(strategy, allowEmpty: false); diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index 39325551..9d99516b 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -66,6 +66,8 @@ namespace OpenNest.Mcp.Tools var strategy = ResolveStrategy(engine); if (strategy == null) return EngineError(engine); + if (PlateFillService.Refusal(plate) is { } refusal) + return $"Error: {refusal} Nothing committed."; var countBefore = plate.Parts.Count; var item = new NestItem { Drawing = drawing, Quantity = quantity }; @@ -121,6 +123,8 @@ namespace OpenNest.Mcp.Tools var strategy = ResolveStrategy(engine); if (strategy == null) return EngineError(engine); + if (PlateFillService.Refusal(plate) is { } refusal) + return $"Error: {refusal} Nothing committed."; var countBefore = plate.Parts.Count; var item = new NestItem { Drawing = drawing, Quantity = quantity }; @@ -173,6 +177,8 @@ namespace OpenNest.Mcp.Tools var strategy = ResolveStrategy(engine); if (strategy == null) return EngineError(engine); + if (PlateFillService.Refusal(plate) is { } refusal) + return $"Error: {refusal} Nothing committed."; var finder = RemnantFinder.FromPlate(plate); var remnants = finder.FindRemnants(); @@ -240,6 +246,8 @@ namespace OpenNest.Mcp.Tools var strategy = ResolveStrategy(engine); if (strategy == null) return EngineError(engine); + if (PlateFillService.Refusal(plate) is { } refusal) + return $"Error: {refusal} Nothing committed."; var parsed = ParseItems(drawingNames, quantities); if (parsed.error != null) diff --git a/OpenNest.WinForms.Tests/Controls/OutlinedPlateFillTests.cs b/OpenNest.WinForms.Tests/Controls/OutlinedPlateFillTests.cs new file mode 100644 index 00000000..f51b9f6d --- /dev/null +++ b/OpenNest.WinForms.Tests/Controls/OutlinedPlateFillTests.cs @@ -0,0 +1,98 @@ +using System.Windows.Forms; +using OpenNest.Actions; +using OpenNest.Controls; +using OpenNest.Engine; +using OpenNest.Engine.Jobs.Placement; +using OpenNest.Forms; +using OpenNest.Geometry; +using OpenNest.WinForms.Tests.Forms; + +namespace OpenNest.WinForms.Tests.Controls; + +/// +/// The interactive fills stop before the fill service on a plate with a sheet outline: nothing +/// throws, no part is added, no progress window opens, and the status bar says why. +/// +[Collection("Fill operation lifetime")] +public class OutlinedPlateFillTests +{ + private static PlateView OutlinedView() + { + var plate = new Plate(60, 120) { PartSpacing = 0.5 }; + plate.Outline = new SheetOutline(new[] + { + new Vector(0, 0), new Vector(120, 0), new Vector(120, 30), new Vector(60, 30), new Vector(60, 60), new Vector(0, 60), + }); + return new PlateView { Plate = plate }; + } + + private static Drawing Square() => new("square", UndoTestEnv.Square(10)); + + [Fact] + public void PlateViewFillIsRefusedWithAStatusMessage() => RunSta(() => + { + using var view = OutlinedView(); + var statuses = new List(); + view.StatusChanged += (_, _) => statuses.Add(view.Status); + + view.FillWithProgress([new Part(Square())], view.Plate.WorkArea()); + + Assert.False(view.IsFillInProgress); + Assert.Empty(Application.OpenForms.OfType()); + Assert.Empty(view.Plate.Parts); + Assert.Equal(new[] { PlateFillService.OutlinedSheetMessage }, statuses); + Assert.Contains("rectangular sheet", view.Status); + }); + + [Fact] + public void FillAreaIsRefusedWithAStatusMessage() => RunSta(() => + { + using var view = OutlinedView(); + view.SetAction(typeof(ActionFillArea), Square()); + var action = (ActionFillArea)view.Actions.CurrentAction; + UndoTestEnv.SetBackingField(action, "SelectedArea", new Box(0, 0, 50, 50)); + + UndoTestEnv.Invoke(action, "FillArea"); + + Assert.Empty(view.Plate.Parts); + Assert.Equal(PlateFillService.OutlinedSheetMessage, view.Status); + }); + + [Fact] + public void FillAreaWithProgressCompletesEmptyWithoutStarting() => RunSta(() => + { + using var view = OutlinedView(); + using var cts = new CancellationTokenSource(); + var reported = new List(); + List? completed = null; + var action = new ActionFillArea(view, Square(), new Progress(reported.Add), cts, + parts => completed = parts); + UndoTestEnv.SetBackingField(action, "SelectedArea", new Box(0, 0, 50, 50)); + + UndoTestEnv.Invoke(action, "FillArea"); + + Assert.NotNull(completed); + Assert.Empty(completed!); + Assert.Empty(reported); + Assert.Empty(view.Plate.Parts); + Assert.Equal(PlateFillService.OutlinedSheetMessage, view.Status); + action.DisconnectEvents(); + }); + + [Fact] + public void ARectangleStillFills() => RunSta(() => + { + using var view = new PlateView { Plate = new Plate(60, 120) { PartSpacing = 0.5 } }; + view.SetAction(typeof(ActionFillArea), Square()); + var action = (ActionFillArea)view.Actions.CurrentAction; + UndoTestEnv.SetBackingField(action, "SelectedArea", new Box(0, 0, 50, 50)); + + UndoTestEnv.Invoke(action, "FillArea"); + + Assert.NotEmpty(view.Plate.Parts); + Assert.Equal("Fill Area", view.Status); // the action's name, as SetAction left it + }); + + private static void RunSta(System.Action action) => + StaTestThread.Run(action, TimeSpan.FromSeconds(60), "Outlined plate fill test timed out."); +} diff --git a/OpenNest/Actions/ActionFillArea.cs b/OpenNest/Actions/ActionFillArea.cs index ac7734c9..050fe587 100644 --- a/OpenNest/Actions/ActionFillArea.cs +++ b/OpenNest/Actions/ActionFillArea.cs @@ -48,6 +48,14 @@ namespace OpenNest.Actions private async void FillArea() { + // Nothing may throw out of this async void: the service's refusal is shown instead. + if (PlateFillService.Refusal(plateView.Plate) is { } refusal) + { + plateView.Status = refusal; + onFillComplete?.Invoke(new List()); + return; + } + var strategy = EngineSelection.FillStrategy; if (progress != null && cts != null) { diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index 3290566f..d50a858c 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -914,6 +914,13 @@ namespace OpenNest.Controls if (IsFillInProgress) return; + // The service would throw this; the status bar tells the user why nothing happened. + if (PlateFillService.Refusal(Plate) is { } refusal) + { + Status = refusal; + return; + } + IsFillInProgress = true; var operationActive = true; try diff --git a/OpenNest/Forms/MainForm.Tools.cs b/OpenNest/Forms/MainForm.Tools.cs index 430d3385..fb688e80 100644 --- a/OpenNest/Forms/MainForm.Tools.cs +++ b/OpenNest/Forms/MainForm.Tools.cs @@ -107,7 +107,7 @@ namespace OpenNest.Forms return; } - using (var form = new PatternTileForm(activeForm.Nest)) + using (var form = new PatternTileForm(activeForm.Nest, activeForm.PlateView.Plate)) { if (form.ShowDialog(this) != DialogResult.OK || form.Result == null) return; diff --git a/OpenNest/Forms/PatternTileForm.cs b/OpenNest/Forms/PatternTileForm.cs index 46bdb35a..8f7c39bd 100644 --- a/OpenNest/Forms/PatternTileForm.cs +++ b/OpenNest/Forms/PatternTileForm.cs @@ -2,22 +2,30 @@ using System; using System.Collections.Generic; using System.Linq; using System.Windows.Forms; +using OpenNest.Engine; using OpenNest.Engine.Fill; +using OpenNest.Engine.Jobs.Placement; using OpenNest.Geometry; using GeoSize = OpenNest.Geometry.Size; -using OpenNest.Engine; namespace OpenNest.Forms { public partial class PatternTileForm : Form { private readonly Nest nest; + private readonly Plate currentPlate; public PatternTileResult Result { get; private set; } public PatternTileForm(Nest nest) + : this(nest, null) { } + + /// The plate "Current plate" would replace; a sheet with an + /// outline cannot take the rectangular tiling, so only a new plate is offered. + public PatternTileForm(Nest nest, Plate currentPlate) { this.nest = nest; + this.currentPlate = currentPlate; InitializeComponent(); // Hide plate outline via zero-size plate @@ -323,13 +331,31 @@ namespace OpenNest.Forms else applyDirection = NestDirection.Horizontal; // tie-break - var choice = MessageBox.Show( - $"Apply {applyDirection} pattern ({(applyDirection == NestDirection.Horizontal ? hCount : vCount)} parts) to current plate?" - + "\n\nYes = Current plate (clears existing parts)\nNo = New plate", - "Apply Pattern", - MessageBoxButtons.YesNoCancel, - MessageBoxIcon.Question - ); + var count = applyDirection == NestDirection.Horizontal ? hCount : vCount; + var refusal = PlateFillService.Refusal(currentPlate); + DialogResult choice; + if (refusal == null) + { + choice = MessageBox.Show( + $"Apply {applyDirection} pattern ({count} parts) to current plate?" + + "\n\nYes = Current plate (clears existing parts)\nNo = New plate", + "Apply Pattern", + MessageBoxButtons.YesNoCancel, + MessageBoxIcon.Question + ); + } + else + { + // The tiling is rectangular, so an outlined current plate cannot take it. + var newPlate = MessageBox.Show( + $"Apply {applyDirection} pattern ({count} parts) to a new plate?" + + $"\n\nThe current plate has a sheet outline. {refusal}", + "Apply Pattern", + MessageBoxButtons.OKCancel, + MessageBoxIcon.Question + ); + choice = newPlate == DialogResult.OK ? DialogResult.No : DialogResult.Cancel; + } if (choice == DialogResult.Cancel) return; diff --git a/docs/irregular-stock.md b/docs/irregular-stock.md index f62b8082..e948fab2 100644 --- a/docs/irregular-stock.md +++ b/docs/irregular-stock.md @@ -29,7 +29,7 @@ Drawing an outline in the plate view and taking the leftover of a cut plate are | Area | Behavior | |---|---| | Irregular engine (and Default through it) | Fills the outline: the stock's usable material (outline less obstacles, eroded by the outline inset below) is the `FrontierPacker`'s `PackBoundary`, the same boundary the cutout packer uses, so every orientation's free region starts inside it | -| Rectangles, Circles, FlatBar, the fill strategies (Fill, Strip, remnants) | Place nothing on an outlined sheet; Default then keeps Irregular's layout. Interactive fills (`PlateFillService`) refuse an outlined plate with a message | +| Rectangles, Circles, FlatBar, the fill strategies (Fill, Strip, remnants) | Place nothing on an outlined sheet; Default then keeps Irregular's layout. `PlateFillService` throws `PlateFillService.Refusal(plate)` for an outlined plate; every caller checks it first: the desktop's Fill (Ctrl+F on a picked-up part) and Fill Area put the message in the status bar and add nothing, Pattern Tile offers a new plate only, the MCP fill/pack tools return it as an `Error:` result, and the console exits 2 with it unless `--autonest` is given | | Layout check and the runner's candidate validation | A part's circumscribed material must lie inside the usable area, within the shared work-area slack; a part in a missing corner or over an obstacle is a violation ("falls outside the outline ... or over one of its obstacles"). The area budget is the usable area | | Edge spacing | The outline is eroded by the **largest** of the four edge spacings (`NestPlateStock.OutlineInset`), so no outline edge is closer to a part than the rectangle's own sides would be. The rectangular work area still applies and is never larger than that erosion | | Clamps | Unchanged: the rail is resolved for the bounding box, zones are keep-outs as before |