mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-10 08:41:10 -05:00
fix(desktop): refuse fills on an outlined plate before the service
PlateFillService throws for a plate with a sheet outline, but the desktop swallowed it: Fill (Ctrl+F) caught every exception and showed nothing, and Fill Area's async void either completed silently or let it escape. PlateFillService.Refusal(plate) names the reason, and every caller checks it first: Fill and Fill Area put it 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. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
a0d063bb94
commit
101d7b5a50
10 files changed
+180
-13
No files matched your search
@@ -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)
|
||||
|
||||
@@ -229,6 +229,11 @@ public class IrregularStockTests
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
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]
|
||||
|
||||
@@ -29,6 +29,16 @@ public static class PlateFillService
|
||||
"Horizontal Remnant",
|
||||
];
|
||||
|
||||
/// <summary>Why no strategy fills a plate with a sheet outline (see <see cref="Refusal"/>).</summary>
|
||||
public const string OutlinedSheetMessage =
|
||||
"Fill strategies need a rectangular sheet. Nest an outlined sheet with Auto Nest and the Irregular engine.";
|
||||
|
||||
/// <summary>
|
||||
/// Why the fill strategies cannot fill <paramref name="plate"/>, or null when they can. Callers
|
||||
/// show it to the user instead of calling an operation that would throw it.
|
||||
/// </summary>
|
||||
public static string Refusal(Plate plate) => plate?.Outline == null ? null : OutlinedSheetMessage;
|
||||
|
||||
public static List<Part> 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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<string>();
|
||||
view.StatusChanged += (_, _) => statuses.Add(view.Status);
|
||||
|
||||
view.FillWithProgress([new Part(Square())], view.Plate.WorkArea());
|
||||
|
||||
Assert.False(view.IsFillInProgress);
|
||||
Assert.Empty(Application.OpenForms.OfType<NestProgressForm>());
|
||||
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<NestProgress>();
|
||||
List<Part>? completed = null;
|
||||
var action = new ActionFillArea(view, Square(), new Progress<NestProgress>(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.");
|
||||
}
|
||||
@@ -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<Part>());
|
||||
return;
|
||||
}
|
||||
|
||||
var strategy = EngineSelection.FillStrategy;
|
||||
if (progress != null && cts != null)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) { }
|
||||
|
||||
/// <param name="currentPlate">The plate "Current plate" would replace; a sheet with an
|
||||
/// outline cannot take the rectangular tiling, so only a new plate is offered.</param>
|
||||
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;
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user