Merge remote-tracking branch 'github/master'

This commit is contained in:
aj committed 2026-10-12 13:33:13 -04:00
commit b36db41769
26 files changed
+1821 -76

No files matched your search

+4 -2
View File
@@ -16,7 +16,8 @@ public sealed record NestPartFulfillment(string PartId, int Requested, int Place
/// <summary>Physical-sheet usage for one stock identity.</summary>
public sealed record NestStockUsage(string StockId, int Used, int? Remaining);
/// <summary>Maps each materialized physical sheet to its source stock identity.</summary>
/// <summary>Maps each materialized layout (Nest.Plates index) to its stock identity.
/// Its Plate.Quantity gives the physical copies; schema 3 and older used one sheet per entry.</summary>
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
@@ -24,10 +25,11 @@ public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
public class NestResponse
{
public const int CurrentSchemaVersion = 3;
public const int CurrentSchemaVersion = 4;
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
/// <summary>Physical sheets: sum of Plate.Quantity, not materialized layout entries.</summary>
public int SheetCount { get; init; }
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
+7 -7
View File
@@ -103,7 +103,7 @@ public static class NestRunner
PartSpacing = proposed.Stock.PartSpacing,
EdgeSpacing = proposed.Stock.EdgeSpacing,
Quadrant = proposed.Stock.Quadrant,
Quantity = 1,
Quantity = proposed.Quantity,
};
plate.Parts.AddRange(proposed.Parts);
nest.Plates.Add(plate);
@@ -111,8 +111,8 @@ public static class NestRunner
// Pipeline IDs are internal part-i values. Exposed counts/IDs come from the
// returned, bound placements and the original request, never plug-in summaries.
var counts = result.Plates.SelectMany(p => p.Parts)
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Count());
var counts = result.Plates.SelectMany(p => p.Parts.Select(part => (part.BaseDrawing, p.Quantity)))
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Sum(p => p.Quantity));
var fulfillment = parts.Select((part, i) =>
{
var placed = counts.GetValueOrDefault(items[i].Drawing);
@@ -121,7 +121,7 @@ public static class NestRunner
}).ToArray();
var usage = stock.Select(s =>
{
var used = result.Plates.Count(p => p.Stock.Id == s.Id);
var used = result.Plates.Where(p => p.Stock.Id == s.Id).Sum(p => p.Quantity);
return new NestStockUsage(s.Id, used, s.Quantity.HasValue ? System.Math.Max(0, s.Quantity.Value - used) : null);
}).ToArray();
var complete = fulfillment.All(f => f.Placed == f.Requested);
@@ -136,7 +136,7 @@ public static class NestRunner
return Task.FromResult(
new NestResponse
{
SheetCount = nest.Plates.Count,
SheetCount = nest.Plates.Sum(p => p.Quantity),
Utilization = CalculateUtilization(nest),
CutTime = cutTime,
Elapsed = sw.Elapsed,
@@ -248,11 +248,11 @@ public static class NestRunner
private static double CalculateUtilization(Nest nest)
{
var sheetArea = nest.Plates.Sum(plate => plate.Area());
var sheetArea = nest.Plates.Sum(plate => plate.Area() * plate.Quantity);
if (sheetArea == 0)
return 0;
var placedArea = nest.Plates.Sum(plate =>
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area)
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) * plate.Quantity
);
return placedArea / sheetArea;
}
+2 -2
View File
@@ -514,9 +514,9 @@ static class NestConsole
foreach (var violation in result.Violations)
Console.Error.WriteLine($"Violation: {violation}");
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
if (!result.CanKeep || result.Raw.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
{
Console.Error.WriteLine(result.Plates.Count > 1
Console.Error.WriteLine(result.Raw.Plates.Count > 1
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
: !result.CanKeep
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.Tests.Jobs;
public class CompletedLayoutRepeatTests
{
private static Dictionary<string, int> Counts(int a, int b) => new(StringComparer.Ordinal) { ["a"] = a, ["b"] = b };
[Theory]
[InlineData(null, null, 5)]
[InlineData(3, null, 3)]
[InlineData(null, 2, 2)]
[InlineData(4, 3, 3)]
[InlineData(0, null, 0)]
[InlineData(null, 0, 0)]
public void ScarcestRequirementAndEachPhysicalBoundClampTotalCopies(int? stock, int? cap, int expected)
{
Assert.Equal(expected, CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 1, stock, cap));
}
[Theory]
[InlineData(false, 1)]
[InlineData(true, 2)]
public void UnknownSearchAndCompetingEntriesUseOne(bool completed, int entries)
{
Assert.Equal(1, CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), completed, entries, null, null));
}
[Fact]
public void EmptyLayoutAndNoStockCannotProduceASheet()
{
Assert.Equal(0, CompletedLayoutRepeat.Limit(Counts(20, 12), new Dictionary<string, int>(), true, 1, null, null));
Assert.Equal(0, CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 0, null, null));
}
[Fact]
public void ExactCaseSensitiveIdsAndOnlyPresentRequirementsMatter()
{
var remaining = new Dictionary<string, int>(StringComparer.Ordinal) { ["a"] = 20, ["A"] = 3, ["absent"] = 0 };
var layout = new Dictionary<string, int>(StringComparer.Ordinal) { ["a"] = 4, ["A"] = 1 };
Assert.Equal(3, CompletedLayoutRepeat.Limit(remaining, layout, true, 1, null, null));
}
[Fact]
public void IntegerEdgeHasNoMultiplicationOverflowOrFirstCopyOffByOne()
{
var remaining = new Dictionary<string, int> { ["a"] = int.MaxValue };
Assert.Equal(int.MaxValue, CompletedLayoutRepeat.Limit(remaining, new Dictionary<string, int> { ["a"] = 1 }, true, 1, null, null));
var q = CompletedLayoutRepeat.Limit(remaining, new Dictionary<string, int> { ["a"] = 2 }, true, 1, null, null);
Assert.Equal(1, checked(int.MaxValue - q * 2));
Assert.Equal(0, CompletedLayoutRepeat.Limit(Counts(3, 1), Counts(4, 2), true, 1, null, null));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void InvalidCountsAndUnknownIdsAreRefused(int count)
{
Assert.Throws<ArgumentException>(() => CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(count, 2), true, 1, null, null));
Assert.Throws<ArgumentException>(() => CompletedLayoutRepeat.Limit(Counts(20, 12), new Dictionary<string, int> { ["unknown"] = 1 }, true, 1, null, null));
}
[Theory]
[InlineData(-1, null)]
[InlineData(null, -1)]
public void NegativeBoundsAreRefused(int? stock, int? cap)
{
Assert.Throws<ArgumentOutOfRangeException>(() => CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 1, stock, cap));
}
}
@@ -0,0 +1,318 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class RepeatedPlateGroupingTests
{
private static NestItem Item(string name = "same-name", int quantity = 10) => new()
{
Drawing = new Drawing(name, TestDrawingFactory.Rectangle(2, 2)),
Quantity = quantity,
};
private static NestPlateStock Stock(string id = "sheet") => new(id, new Size(20, 30), 10, 0.25);
private sealed class Stub(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => solve(job);
}
private static NestJobResult Raw(IEnumerable<NestJobPlateResult> sheets) =>
new(NestJobStatus.Complete, NestJobStopReason.Completed, sheets, [], []);
private static NestJobPlateResult Sheet(NestPlateStock stock, int index, params NestJobPlacement[] poses) =>
new(index, stock, poses);
private static NestJobPlacement Pose(string id = "part-0", int instance = 0,
double x = 1, double y = 1, double rotation = 0) => new(id, instance, x, y, rotation);
[Fact]
public void NonAdjacentOrderedDuplicatesKeepFirstOccurrenceAndIgnoreInstanceIndices()
{
var item = Item();
var input = new NestPipelineRequest("stub", [item], [Stock()]);
NestJobResult? raw = null;
var result = NestPipeline.Run(new Stub(job => raw = Raw([
Sheet(job.Plates[0], 7, Pose(instance: 0), Pose(instance: 1, x: 6)),
Sheet(job.Plates[0], 8, Pose(instance: 2, x: 3)),
Sheet(job.Plates[0], 9, Pose(instance: 3), Pose(instance: 4, x: 6))])), "stub", input);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Same(raw, result.Raw);
Assert.Equal(new[] { 7, 8 }, result.Plates.Select(p => p.PlateIndex));
Assert.Equal(new[] { 2, 1 }, result.Plates.Select(p => p.Quantity));
Assert.Equal(new[] { 1d, 6d }, result.Plates[0].Parts.Select(p => p.Location.X));
Assert.All(result.Plates.SelectMany(p => p.Parts), p => Assert.Same(item.Drawing, p.BaseDrawing));
Assert.Equal(5, result.Plates.Sum(p => p.Parts.Count * p.Quantity));
Assert.Equal(0, item.Drawing.Quantity.Nested);
Assert.Empty(NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
result.Plates, result.DrawingsByPartId, consolidate: true));
}
[Theory]
[InlineData("x")]
[InlineData("y")]
[InlineData("rotation")]
[InlineData("negative-zero")]
public void ScalarBitDifferenceDoesNotGroup(string field)
{
var second = field switch
{
"x" => Pose(instance: 1, x: double.BitIncrement(1)),
"y" => Pose(instance: 1, y: double.BitIncrement(1)),
"rotation" => Pose(instance: 1, rotation: double.BitIncrement(0)),
_ => Pose(instance: 1, rotation: -0.0),
};
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, second)])),
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Count);
Assert.All(result.Plates, p => Assert.Equal(1, p.Quantity));
}
[Fact]
public void ReorderedPlacementsAndDistinctSameNameRequirementsStaySeparate()
{
var a = Item();
var b = Item();
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose(), Pose("part-1", x: 6)),
Sheet(job.Plates[0], 1, Pose("part-1", 1, x: 6), Pose(instance: 1)),
Sheet(job.Plates[0], 2, Pose(instance: 2)),
Sheet(job.Plates[0], 3, Pose("part-1", 2))])),
"stub", new NestPipelineRequest("stub", [a, b], [Stock()]));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(4, result.Plates.Count);
Assert.Same(a.Drawing, result.Plates[2].Parts[0].BaseDrawing);
Assert.Same(b.Drawing, result.Plates[3].Parts[0].BaseDrawing);
Assert.All(result.Plates, p => Assert.Equal(1, p.Quantity));
}
[Fact]
public void SameSizeDifferentStockIdentityDoesNotGroup()
{
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[1], 1, Pose(instance: 1))])),
"stub", new NestPipelineRequest("stub", [Item()], [Stock("a"), Stock("b")]));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Count);
}
[Theory]
[InlineData("width")]
[InlineData("length")]
[InlineData("spacing")]
[InlineData("left")]
[InlineData("right")]
[InlineData("top")]
[InlineData("bottom")]
[InlineData("quadrant")]
[InlineData("cost")]
[InlineData("quantity")]
public void EveryStockSettingIsPartOfExactIdentity(string field)
{
var size = new Size(field == "width" ? double.BitIncrement(20) : 20,
field == "length" ? double.BitIncrement(30) : 30);
var spacing = new Spacing
{
Left = field == "left" ? double.Epsilon : 0,
Right = field == "right" ? double.Epsilon : 0,
Top = field == "top" ? double.Epsilon : 0,
Bottom = field == "bottom" ? double.Epsilon : 0,
};
var second = new NestPlateStock("sheet", size, field == "quantity" ? 11 : 10,
field == "spacing" ? double.BitIncrement(0.25) : 0.25,
spacing, field == "quadrant" ? 2 : 1, field == "cost" ? 1 : null);
var groups = RepeatedPlateGrouping.Create([
Sheet(Stock(), 0, Pose()), Sheet(second, 1, Pose(instance: 1))], true);
Assert.Equal(2, groups.Count);
}
[Fact]
public void ToleranceEquivalentValidStockSettingsStillDoNotGroup()
{
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()),
Sheet(new NestPlateStock("sheet", job.Plates[0].Size, 10,
double.BitIncrement(0.25)), 1, Pose(instance: 1))])),
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Count);
}
[Fact]
public void EmptySheetsNeverGroup()
{
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0), Sheet(job.Plates[0], 1)])),
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
Assert.True(result.IsValid);
Assert.Equal(2, result.Plates.Count);
Assert.All(result.Plates, p => Assert.Equal(1, p.Quantity));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void QuantityWeightedCostAndSalvageMatchExpandedPhysicalSheets(bool priced)
{
var item = Item(quantity: 3);
var stock = new NestPlateStock("sheet", new Size(20, 30), 4, 0.25,
new Spacing(), 1, cost: priced ? 7 : null);
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1)),
Sheet(job.Plates[0], 2, Pose(instance: 2, x: 15))])), "stub",
new NestPipelineRequest("stub", [item], [stock],
new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { 2, 1 }, result.Plates.Select(p => p.Quantity));
var summary = NestCostSummary.FromAccepted(result);
var gross = result.Raw.Plates.Sum(p => NestJobCost.GrossSheetCost(p.Stock));
var net = NestJobCost.Evaluate(result.Job, result.Raw);
Assert.Equal(gross, summary.GrossTotal, 10);
Assert.Equal(net, summary.NetScore, 10);
Assert.Equal(gross - net, summary.SalvageCredit, 10);
Assert.True(summary.SalvageCredit > 0);
Assert.Equal(3, Assert.Single(summary.Stock).Used);
Assert.Equal(gross, Assert.Single(summary.Stock).GrossSubtotal, 10);
}
[Fact]
public void InvalidDuplicatesRemainPhysicalEvenWithExplicitCommitConsent()
{
var item = Item();
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose(), Pose(instance: 1)),
Sheet(job.Plates[0], 1, Pose(instance: 2), Pose(instance: 3))])),
"stub", new NestPipelineRequest("stub", [item], [Stock()]));
Assert.False(result.IsValid);
Assert.True(result.CanKeep);
Assert.Equal(2, result.Plates.Count);
Assert.All(result.Plates, p => Assert.Equal(1, p.Quantity));
var nest = new Nest();
nest.Drawings.Add(item.Drawing);
using var manager = new PlateManager(nest);
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true);
Assert.Equal(2, applied.Count);
Assert.Equal(4, item.Drawing.Quantity.Nested);
}
[Theory]
[InlineData("ghost", 1)]
[InlineData(null, 1)]
[InlineData("part-0", double.NaN)]
public void MalformedDuplicatesRemainUnkeepable(string? id, double x)
{
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose(id!, x: x)),
Sheet(job.Plates[0], 1, Pose(id!, 1, x: x))])),
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Equal(2, result.Raw.Plates.Count);
}
[Fact]
public void ProposalKeepsThreeFieldConstructorAndDeconstructionAndRequiresPositiveQuantity()
{
var stock = Stock();
var proposed = new ProposedPlate(4, stock, []);
var (index, actualStock, parts) = proposed;
Assert.Equal(4, index);
Assert.Same(stock, actualStock);
Assert.Empty(parts);
Assert.Equal(1, proposed.Quantity);
Assert.Equal(2, (proposed with { Quantity = 2 }).Quantity);
Assert.Throws<ArgumentOutOfRangeException>(() => proposed with { Quantity = 0 });
Assert.Throws<ArgumentOutOfRangeException>(() => proposed with { Quantity = -1 });
}
[Theory]
[InlineData("program")]
[InlineData("pose")]
[InlineData("drawing")]
public void GroupedProposalFreshnessStillRefusesMutationBeforeAttachment(string field)
{
var item = Item(quantity: 2);
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1))])),
"stub", new NestPipelineRequest("stub", [item], [Stock()]));
var proposed = Assert.Single(result.Plates);
Assert.Equal(2, proposed.Quantity);
if (field == "program") ((Motion)proposed.Parts[0].Program.Codes[1]).EndPoint = new Vector(10, 10);
else if (field == "pose") proposed.Parts[0].Location = new Vector(double.BitIncrement(1), 1);
else item.Drawing.Program = TestDrawingFactory.Rectangle(10, 10);
var nest = new Nest();
nest.Drawings.Add(item.Drawing);
using var manager = new PlateManager(nest);
Assert.Throws<InvalidOperationException>(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager, true));
Assert.Empty(nest.Plates);
Assert.Equal(0, item.Drawing.Quantity.Nested);
}
[Theory]
[InlineData("quantity")]
[InlineData("stock")]
[InlineData("index")]
public void RawToGroupedMatchChecksQuantityStockAndFirstPhysicalIndex(string field)
{
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1))])),
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
var proposed = Assert.Single(result.Plates);
var changed = field switch
{
"quantity" => proposed with { Quantity = 1 },
"stock" => proposed with { Stock = Stock("other") },
_ => proposed with { PlateIndex = 1 },
};
Assert.NotEmpty(NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
[changed], result.DrawingsByPartId, true));
}
private sealed class TransientMove(double x, double y) : LinearMove(x, y)
{
public override ICode Clone()
{
var before = EndPoint;
EndPoint = new Vector(100, 100);
try { return base.Clone(); }
finally { EndPoint = before; }
}
}
[Fact]
public void GroupedBindingTransientCloneEditRemainsUnkeepable()
{
var item = Item();
item.Drawing.Program.Codes[^1] = new TransientMove(0, 0);
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1))])),
"stub", new NestPipelineRequest("stub", [item], [Stock()]));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("Bound part"));
}
[Fact]
public void CancelledGroupingAndCommitLeaveInputsUntouched()
{
using var source = new CancellationTokenSource();
source.Cancel();
Assert.Throws<OperationCanceledException>(() => RepeatedPlateGrouping.Create([
Sheet(Stock(), 0, Pose()), Sheet(Stock(), 1, Pose(instance: 1))], true, source.Token));
var item = Item();
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1))])),
"stub", new NestPipelineRequest("stub", [item], [Stock()]));
var nest = new Nest();
using var manager = new PlateManager(nest);
Assert.Throws<OperationCanceledException>(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager,
token: source.Token));
Assert.Empty(nest.Plates);
Assert.Equal(0, item.Drawing.Quantity.Nested);
}
}
@@ -0,0 +1,92 @@
using System.Linq;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularRepeatTests : RepeatEngineSafetyTests
{
private protected override INestingEngine Engine(Action? packed = null, Action<RepeatBatch>? batches = null) =>
new IrregularNestingEngine { WorkBudget = 0, SheetPacked = packed, BatchSelected = batches };
[Fact]
public void CompletedFullLayoutRemovesActualFrontierFillCallsIncludingTailTrials()
{
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
var calls = 0;
var result = new IrregularNestingEngine { SheetPacked = () => calls++ }.Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(10, result.Plates.Count);
Assert.Equal(24, calls); // Six main variants and six trials for each physical tail of 1..3 sheets.
var first = result.Plates[0];
foreach (var plate in result.Plates.Skip(1))
{
Assert.NotSame(first.Placements, plate.Placements);
Assert.Equal(first.Placements.Select(Pose), plate.Placements.Select(Pose));
Assert.NotSame(first.Placements[0], plate.Placements[0]);
}
}
[Fact]
public void SeededPhysicalTailReturnsFiniteStockAndReconsumesItWithoutDroppingRepeatedPrefix()
{
// A lifecycle test, not a claim that public Solve chooses this deliberately wasteful
// incumbent. Four identical full copies precede two half-full physical tail copies.
var job = Job([Rectangle("a", 9, 9, 20)], [Stock("s", 20, 20, spacing: 0.2, quantity: 6)]);
var orientation = Assert.Single(Assert.Single(PartCatalog.Build(job)).Orientations);
var full = new[] { new Placed(orientation, 0, 0), new Placed(orientation, 9.2, 0),
new Placed(orientation, 0, 9.2), new Placed(orientation, 9.2, 9.2) };
var seed = Enumerable.Range(0, 4).Select(_ => new SheetFill(job.Plates[0], full.ToArray(), 324))
.Concat(Enumerable.Range(0, 2).Select(_ => new SheetFill(job.Plates[0], full.Take(2).ToArray(), 162))).ToArray();
var incumbent = new NestJobResultBuilder(job);
foreach (var sheet in seed)
incumbent.AddSheet(sheet.Stock, sheet.Parts.Select(p => ("a", p.X, p.Y, p.Orientation.Rotation)));
var before = incumbent.Build(NestJobStopReason.Completed);
LayoutAssert.Valid(job, before);
var replaced = new List<int>();
var batches = new List<RepeatBatch>();
var result = new IrregularNestingEngine { TailReplaced = replaced.Add, BatchSelected = batches.Add }
.ImproveTailForTesting(job, seed);
LayoutAssert.Valid(job, result);
Assert.Equal(new[] { 2 }, replaced);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(5, result.Plates.Count);
Assert.Equal(20, Assert.Single(result.Fulfillment).Placed);
Assert.Equal((5, 1), (Assert.Single(result.StockUsage).Used, Assert.Single(result.StockUsage).Remaining));
Assert.Equal(2000, NestJobCost.Evaluate(job, result), precision: 8);
Assert.True(NestJobCost.Evaluate(job, result) < NestJobCost.Evaluate(job, before));
for (var i = 0; i < 4; i++)
Assert.Equal(before.Plates[i].Placements.Select(Pose), result.Plates[i].Placements.Select(Pose));
Assert.All(result.Plates, p => Assert.Equal(4, p.Placements.Count));
Assert.Equal(Enumerable.Range(0, 20), result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex));
Assert.All(seed.Take(4), s => Assert.Equal(full, s.Parts)); // The incumbent was not mutated.
Assert.Contains(batches, b => b.ForcedFirst && b.Copies > 1); // The retained physical tail can still repeat.
}
[Fact]
public void ForcedFirstStockCountsAllAvailableEntriesAndResumesAlternatives()
{
var job = Job([Rectangle("a", 9, 9, 16)],
[Stock("one", 20, 20, spacing: 0.2, quantity: 10), Stock("two", 20, 20, spacing: 0.2, quantity: 10)]);
var batches = new List<RepeatBatch>();
var result = new IrregularNestingEngine { BatchSelected = batches.Add }.Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
var forced = batches.Where(b => b.ForcedFirst).ToArray();
Assert.NotEmpty(forced);
Assert.All(forced, b =>
{
Assert.Equal(2, b.AvailableStockEntries);
Assert.Equal(1, b.Copies);
});
// Within a multi-sheet Decode forced to start on "two", the next decision can
// choose "one". A trial-subset count of one would incorrectly batch that first sheet.
Assert.Contains(Enumerable.Range(0, batches.Count - 1), i =>
batches[i] is { ForcedFirst: true, StockId: "two" }
&& batches[i + 1] is { ForcedFirst: false, StockId: "one", SheetsBefore: 1 });
}
private static (string, double, double, double) Pose(NestJobPlacement p) => (p.PartId, p.X, p.Y, p.Rotation);
}
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Engine.RectanglePacking;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
namespace OpenNest.Engine.Tests.NestingEngines;
public class RectanglesRepeatTests : RepeatEngineSafetyTests
{
private protected override INestingEngine Engine(Action? packed = null, Action<RepeatBatch>? batches = null) =>
new RectanglesNestingEngine { SheetPacked = _ => packed?.Invoke(), BatchSelected = batches };
[Fact]
public void CompletedFullLayoutRemovesActualPackCallsInBothModes()
{
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
var calls = new List<PickMode>();
var result = new RectanglesNestingEngine { SheetPacked = calls.Add }.Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(10, result.Plates.Count);
Assert.Equal(6, calls.Count(m => m == PickMode.Global));
Assert.Equal(6, calls.Count(m => m == PickMode.Ordered));
var first = result.Plates[0];
foreach (var plate in result.Plates.Skip(1))
{
Assert.NotSame(first.Placements, plate.Placements);
Assert.Equal(first.Placements.Select(Pose), plate.Placements.Select(Pose));
Assert.NotSame(first.Placements[0], plate.Placements[0]);
}
}
[Fact]
public void CandidateOutsideTheStricterCertificationBoundsRetainsOrdinaryAllocation()
{
// MaxRects/layout-check slack accepts this overhang; the candidate validator does not.
// This real Solve must pack five times rather than certifying a repeat from that layout.
var job = Job([Rectangle("a", 10.000006, 9, 5)], [Stock("s", 10, 10)]);
var batches = new List<RepeatBatch>();
var calls = 0;
var result = new RectanglesNestingEngine { SheetPacked = _ => calls++, BatchSelected = batches.Add }.Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(5, result.Plates.Count);
Assert.Equal(60, calls);
Assert.Equal(5, batches.Count);
Assert.All(batches, b => Assert.Equal(1, b.Copies));
var candidate = new PlateCandidate(result.Plates[0].Placements);
Assert.Throws<InvalidOperationException>(() => NestJobValidator.ValidateCandidate(candidate, job.Plates[0],
new Dictionary<string, int> { ["a"] = 5 }, job.Parts.ToDictionary(p => p.Id)));
}
private static (string, double, double, double) Pose(NestJobPlacement p) => (p.PartId, p.X, p.Y, p.Rotation);
}
@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
namespace OpenNest.Engine.Tests.NestingEngines;
/// <summary>Physical accounting/continuation contract, run independently for each repeating engine.</summary>
public abstract class RepeatEngineSafetyTests
{
private protected abstract INestingEngine Engine(Action? packed = null, Action<RepeatBatch>? batches = null);
[Theory]
[InlineData(5)]
[InlineData(1)]
public void MixedScarcityContinuesToDifferentResidualLayoutInOneSolve(int urgentCount)
{
var job = Mixed(urgentCount, 8);
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(urgentCount, batches[0].Copies);
Assert.True(batches.Count > 1);
Assert.Equal(urgentCount, result.Fulfillment.Single(f => f.PartId == "a").Placed);
Assert.Equal(8, result.Fulfillment.Single(f => f.PartId == "b").Placed);
Assert.All(result.Plates.Take(urgentCount), p => Assert.Equal(new[] { "a", "b" }, p.Placements.Select(v => v.PartId)));
Assert.All(result.Plates.Skip(urgentCount), p => Assert.All(p.Placements, v => Assert.Equal("b", v.PartId)));
}
[Fact]
public void SmallDemandThatFitsTheRemnantIsPlacedBeforeRepeating()
{
var job = Mixed(10, 10);
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(10, Assert.Single(batches).Copies);
Assert.All(result.Plates, p => Assert.Equal(new[] { "a", "b" }, p.Placements.Select(v => v.PartId)));
}
[Theory]
[InlineData(2, null, NestJobStopReason.StockExhausted)]
[InlineData(null, 2, NestJobStopReason.PlateLimitReached)]
[InlineData(3, 2, NestJobStopReason.PlateLimitReached)]
public void FiniteStockAndMaxPlatesCountPhysicalCopies(int? stockCount, int? cap, NestJobStopReason reason)
{
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2, quantity: stockCount)], new NestJobOptions(maxPlates: cap));
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(reason, result.StopReason);
Assert.Equal(2, result.Plates.Count);
Assert.Equal(8, Assert.Single(result.Fulfillment).Placed);
Assert.Equal(2, Assert.Single(batches).Copies);
Assert.Equal(stockCount - 2, Assert.Single(result.StockUsage).Remaining);
}
[Fact]
public void CompetingSameSizeStockEntriesCommitSinglyUntilOneRemains()
{
var job = Job([Rectangle("a", 9, 9, 16)], [Stock("one", 20, 20, spacing: 0.2, quantity: 2), Stock("two", 20, 20, spacing: 0.2, quantity: 2)]);
var batches = new List<RepeatBatch>();
var calls = 0;
var result = Engine(() => calls++, batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(new[] { 1, 1, 2 }, batches.Select(b => b.Copies));
Assert.Equal(new[] { 2, 2, 1 }, batches.Select(b => b.AvailableStockEntries));
Assert.All(result.StockUsage, s => Assert.Equal(2, s.Used));
Assert.True(calls >= 5); // Two competing trials twice, then the genuinely sole remaining stock.
}
[Fact]
public void SameNamedDrawingsRetainExactDistinctRequirementIds()
{
var a = new Drawing("same", Shapes.Rectangle(6, 9));
var b = new Drawing("same", Shapes.Rectangle(3, 9)) { Priority = 1 };
var job = Job([DrawingJobMapper.FromDrawing("a", a, 5), DrawingJobMapper.FromDrawing("A", b, 8)], [Stock("s", 10, 10, spacing: 0.2)]);
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "a").Placed);
Assert.Equal(8, result.Fulfillment.Single(f => f.PartId == "A").Placed);
Assert.Equal(0, a.Quantity.Nested);
Assert.Equal(0, b.Quantity.Nested);
}
[Fact]
public void SalvageOffcutDoesNotPreventRepeatingCompletedJobSearch()
{
var stock = new NestPlateStock("s", new OpenNest.Geometry.Size(10, 10), null, 0.2, default, 1, cost: 20);
var job = Job([Rectangle("a", 6, 9, 10)], [stock], new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1));
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(10, Assert.Single(batches).Copies);
Assert.Equal(10, result.Plates.Count);
Assert.Equal(162, NestJobCost.Evaluate(job, result), precision: 8);
Assert.True(NestJobCost.NetSheetCost(job, result.Plates[0]) < stock.Cost);
}
[Fact]
public void OversizedRequirementRemainsUnplacedWhileUsefulDemandRepeats()
{
var job = Job([Rectangle("huge", 100, 100, 4), Rectangle("small", 6, 9, 5)], [Stock("s", 10, 10, spacing: 0.2)]);
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Equal(4, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "small").Placed);
Assert.Contains(batches, b => b.Copies == 5);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void NoFitOrUnavailableStockProducesNoEmptyRepeat(bool unavailable)
{
var job = Job([Rectangle("a", 100, 100, 2)], [Stock("s", 10, 10, quantity: unavailable ? 0 : null)]);
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Empty(result.Plates);
Assert.Empty(batches);
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
}
[Fact]
public void CancellationAtRepeatDecisionCannotReturnAProposal()
{
using var cancellation = new CancellationTokenSource();
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
var observed = 0;
var engine = Engine(batches: b => { Assert.Equal(10, b.Copies); observed++; cancellation.Cancel(); });
Assert.Throws<OperationCanceledException>(() => engine.Solve(job, token: cancellation.Token));
Assert.Equal(1, observed);
}
[Theory]
[InlineData(1)]
[InlineData(10)]
public void CancellationDuringPhysicalExpansionIncludingLastCopyIsObserved(int cancelAfter)
{
using var cancellation = new CancellationTokenSource();
var committed = new List<NestJobProgress>();
var progress = new InlineProgress(p =>
{
if (p.Stage != NestJobStage.PlateCommitted) return;
committed.Add(p);
if (p.CommittedPlates == cancelAfter) cancellation.Cancel();
});
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
Assert.Throws<OperationCanceledException>(() => Engine().Solve(job, progress, cancellation.Token));
Assert.Equal(cancelAfter, committed.Count);
Assert.Equal(cancelAfter * 4, committed.Last().CommittedParts);
}
[Fact]
public void SuccessfulProgressRemainsPhysicalAndOrdered()
{
var committed = new List<NestJobProgress>();
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
var result = Engine().Solve(job, new InlineProgress(p => { if (p.Stage == NestJobStage.PlateCommitted) committed.Add(p); }));
LayoutAssert.Valid(job, result);
Assert.Equal(Enumerable.Range(1, 10), committed.Select(p => p.CommittedPlates));
Assert.Equal(Enumerable.Range(1, 10).Select(i => i * 4), committed.Select(p => p.CommittedParts));
}
[Fact]
public void UnreadableGeometryIsNotTurnedIntoAnEmptyRepeat()
{
var job = Job([Part("unsupported", new OpenNest.CNC.Program(), 12)], [Stock("s", 10, 10)]);
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Equal(12, Assert.Single(result.Fulfillment).Unplaced);
Assert.Empty(result.Plates);
Assert.Empty(batches);
Assert.Equal(0, Assert.Single(result.StockUsage).Used);
}
[Fact]
public void CancellationInsideTheRealSheetSearchNeverCertifiesARepeat()
{
using var cancellation = new CancellationTokenSource();
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
var batches = new List<RepeatBatch>();
var packed = 0;
var progress = new InlineProgress(p =>
{
if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel();
});
Assert.ThrowsAny<OperationCanceledException>(() => Engine(() => packed++, batches.Add).Solve(job, progress, cancellation.Token));
Assert.Equal(0, packed);
Assert.Empty(batches);
}
private static NestJob Mixed(int a, int b) => Job(
[Part("a", Shapes.Rectangle(6, 9), a, RotationPolicy.Fixed(0)), Part("b", Shapes.Rectangle(3, 9), b, RotationPolicy.Fixed(0), priority: 1)],
[Stock("s", 10, 10, spacing: 0.2)]);
private sealed class InlineProgress(Action<NestJobProgress> report) : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value) => report(value);
}
}
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
namespace OpenNest.Engine.Jobs;
/// <summary>Pure limits for total physical copies, including the first, using pre-commit demand.
/// Callers must validate the selected layout before allowing more than one copy.</summary>
internal static class CompletedLayoutRepeat
{
public static int Limit(
IReadOnlyDictionary<string, int> remaining,
IReadOnlyDictionary<string, int> layoutCounts,
bool searchCompleted,
int availableStockEntries,
int? stockRemaining,
int? physicalRemaining)
{
ArgumentNullException.ThrowIfNull(remaining);
ArgumentNullException.ThrowIfNull(layoutCounts);
if (availableStockEntries < 0 || stockRemaining < 0 || physicalRemaining < 0)
throw new ArgumentOutOfRangeException(nameof(availableStockEntries), "Repeat bounds cannot be negative.");
if (layoutCounts.Count == 0 || availableStockEntries == 0)
return 0;
var copies = int.MaxValue;
foreach (var (id, count) in layoutCounts)
{
if (count <= 0 || !remaining.TryGetValue(id, out var demand) || demand < 0)
throw new ArgumentException("Layout counts must be positive and reference exact remaining requirement IDs.", nameof(layoutCounts));
copies = System.Math.Min(copies, demand / count);
}
if (stockRemaining is int stock)
copies = System.Math.Min(copies, stock);
if (physicalRemaining is int cap)
copies = System.Math.Min(copies, cap);
if (!searchCompleted || availableStockEntries != 1)
copies = System.Math.Min(copies, 1);
return copies;
}
}
/// <summary>Immutable observation of a decode decision, not production accounting.</summary>
internal sealed record RepeatBatch(string StockId, int Copies, int AvailableStockEntries, int SheetsBefore, bool ForcedFirst);
+4 -4
View File
@@ -17,7 +17,7 @@ public sealed record NestCostSummary(string Basis, IReadOnlyList<NestStockCost>
throw new ArgumentException("Cost reporting requires a validated proposal.", nameof(result));
var stock = result.Job.Plates.Select(s =>
{
var used = result.Plates.Count(p => p.Stock.Id == s.Id);
var used = result.Plates.Where(p => p.Stock.Id == s.Id).Sum(p => p.Quantity);
return new NestStockCost(s.Id, used, s.Cost,
used == 0 ? 0 : NestJobCost.RequireFinite(used * NestJobCost.GrossSheetCost(s)));
}).ToArray();
@@ -26,12 +26,12 @@ public sealed record NestCostSummary(string Basis, IReadOnlyList<NestStockCost>
{
if (sheet.Parts.Count == 0 || result.Job.Options.SalvageRate == 0
|| result.Job.Options.MinimumSalvageDimension <= 0)
return NestJobCost.GrossSheetCost(sheet.Stock);
return NestJobCost.RequireFinite(sheet.Quantity * NestJobCost.GrossSheetCost(sheet.Stock));
var bounds = sheet.Parts.Select(NestLayoutCheck.MaterialBounds).ToArray();
var left = bounds.Min(b => b.Left);
var bottom = bounds.Min(b => b.Bottom);
return NestJobCost.NetSheetCost(result.Job.Options, sheet.Stock,
new Box(left, bottom, bounds.Max(b => b.Right) - left, bounds.Max(b => b.Top) - bottom));
return NestJobCost.RequireFinite(sheet.Quantity * NestJobCost.NetSheetCost(result.Job.Options, sheet.Stock,
new Box(left, bottom, bounds.Max(b => b.Right) - left, bounds.Max(b => b.Top) - bottom)));
}));
return new NestCostSummary(NestJobCost.UsesExplicitCosts(result.Job)
? "supplied-cost" : "area", stock, gross, NestJobCost.RequireFinite(gross - net), net);
+24 -12
View File
@@ -20,8 +20,17 @@ public sealed record NestPipelineRequest(
NestJobOptions Options = null
);
/// <summary>A proposed sheet: new parts bound by reference to the caller's drawings.</summary>
public sealed record ProposedPlate(int PlateIndex, NestPlateStock Stock, IReadOnlyList<Part> Parts);
/// <summary>A proposed layout: new parts bound to caller drawings. PlateIndex is the first
/// raw physical occurrence; Quantity is the total physical copies, including the first.</summary>
public sealed record ProposedPlate(int PlateIndex, NestPlateStock Stock, IReadOnlyList<Part> Parts)
{
private int quantity = 1;
public int Quantity
{
get => quantity;
init => quantity = value > 0 ? value : throw new ArgumentOutOfRangeException(nameof(Quantity));
}
}
/// <summary>
/// The engine's raw result, its independent validation, and the proposed plates. Nothing
@@ -204,21 +213,24 @@ public static class NestPipeline
if (canKeep && violations.Count == 0)
_ = NestJobCost.Evaluate(job, raw);
var plates = canKeep
? raw.Plates.Select(sheet => new ProposedPlate(
sheet.PlateIndex,
sheet.Stock,
NestResultBinder.Bind(sheet, drawingsByPartId)
))
.ToList()
: new List<ProposedPlate>();
// Invalid-but-representable output retains its physical review correspondence.
var consolidate = violations.Count == 0;
var plates = new List<ProposedPlate>();
if (canKeep)
foreach (var group in RepeatedPlateGrouping.Create(raw.Plates, consolidate, token))
{
token.ThrowIfCancellationRequested();
plates.Add(new ProposedPlate(group.Sheet.PlateIndex, group.Sheet.Stock,
NestResultBinder.Bind(group.Sheet, drawingsByPartId))
{ Quantity = group.Quantity });
}
// Binding clones the caller's current Program; a concurrent edit during binding
// must not return parts whose bytes differ from the already validated snapshot.
if (canKeep)
{
var changes = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId).ToList();
changes.AddRange(NestPipelineDrawingFreshness.BoundChanges(job, raw, plates, drawingsByPartId));
changes.AddRange(NestPipelineDrawingFreshness.BoundChanges(job, raw, plates, drawingsByPartId, consolidate, token));
if (changes.Count > 0)
{
violations.AddRange(changes);
@@ -243,7 +255,7 @@ public static class NestPipeline
engineName,
job,
raw,
plates,
plates.AsReadOnly(),
violations,
canKeep,
solveTime,
+3 -3
View File
@@ -4,7 +4,7 @@ using System.Threading;
namespace OpenNest.Engine.Jobs;
/// <summary>Applies an explicitly accepted whole-job proposal to empty physical sheets only.
/// <summary>Applies accepted layouts and total copy quantities to empty/new plates only.
/// Caller must keep the nest and its drawings stable from request construction through commit.</summary>
public static class NestPipelineCommit
{
@@ -22,7 +22,7 @@ public static class NestPipelineCommit
throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations.");
if (NestPipelineDrawingFreshness.Changes(result.Job, result.DrawingsByPartId).Count > 0
|| NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
result.Plates, result.DrawingsByPartId).Count > 0)
result.Plates, result.DrawingsByPartId, result.IsValid, token).Count > 0)
throw new InvalidOperationException("A drawing or proposed part changed after the nesting proposal was validated; run Auto Nest again.");
token.ThrowIfCancellationRequested();
@@ -41,7 +41,7 @@ public static class NestPipelineCommit
plate.PartSpacing = proposed.Stock.PartSpacing;
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
plate.Quadrant = proposed.Stock.Quadrant;
plate.Quantity = 1;
plate.Quantity = proposed.Quantity;
plate.Parts.AddRange(proposed.Parts);
applied.Add(plate);
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
@@ -40,20 +41,29 @@ internal static class NestPipelineDrawingFreshness
/// <summary>Check the actual cloned programs, not only the drawing before/after
/// binding. A transient edit during Clone may leave the drawing unchanged.</summary>
internal static IReadOnlyList<string> BoundChanges(NestJob job, NestJobResult raw,
IReadOnlyList<ProposedPlate> plates, IReadOnlyDictionary<string, Drawing> drawings)
IReadOnlyList<ProposedPlate> plates, IReadOnlyDictionary<string, Drawing> drawings,
bool consolidate = false, CancellationToken token = default)
{
var changed = new List<string>();
var requirements = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
if (plates.Count != raw.Plates.Count)
var groups = RepeatedPlateGrouping.Create(raw.Plates, consolidate, token);
if (plates.Count != groups.Count)
return new[] { "Bound plate count changed after validation" };
for (var sheetIndex = 0; sheetIndex < plates.Count; sheetIndex++)
{
var poses = raw.Plates[sheetIndex].Placements;
var bound = plates[sheetIndex].Parts;
token.ThrowIfCancellationRequested();
var group = groups[sheetIndex];
var proposed = plates[sheetIndex];
if (proposed.Quantity != group.Quantity || proposed.PlateIndex != group.Sheet.PlateIndex
|| !RepeatedPlateGrouping.SameStock(proposed.Stock, group.Sheet.Stock))
return new[] { "Bound plate identity or quantity changed after validation" };
var poses = group.Sheet.Placements;
var bound = proposed.Parts;
if (bound.Count != poses.Count)
return new[] { "Bound part count changed after validation" };
for (var index = 0; index < poses.Count; index++)
{
token.ThrowIfCancellationRequested();
var pose = poses[index];
var actual = bound[index];
if (pose.PartId == null || !requirements.TryGetValue(pose.PartId, out var requirement)
@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest.Engine.Jobs;
/// <summary>Alternate representation of checked physical sheets; never changes raw output.
/// Equality includes ordered requirement IDs and scalar bits, not instance indices.</summary>
internal static class RepeatedPlateGrouping
{
internal sealed record Group(NestJobPlateResult Sheet, int Quantity);
internal static IReadOnlyList<Group> Create(IReadOnlyList<NestJobPlateResult> sheets,
bool consolidate, CancellationToken token = default)
{
var groups = new List<Group>();
var firstByLayout = new Dictionary<NestJobPlateResult, int>(LayoutComparer.Instance);
foreach (var sheet in sheets)
{
token.ThrowIfCancellationRequested();
if (consolidate && sheet.Placements.Count > 0
&& firstByLayout.TryGetValue(sheet, out var first))
groups[first] = groups[first] with { Quantity = checked(groups[first].Quantity + 1) };
else
{
if (consolidate && sheet.Placements.Count > 0)
firstByLayout.Add(sheet, groups.Count);
groups.Add(new Group(sheet, 1));
}
}
return groups.AsReadOnly();
}
internal static bool SameStock(NestPlateStock a, NestPlateStock b) =>
string.Equals(a.Id, b.Id, StringComparison.Ordinal)
&& Bits(a.Size.Width) == Bits(b.Size.Width) && Bits(a.Size.Length) == Bits(b.Size.Length)
&& a.Quadrant == b.Quadrant && Bits(a.PartSpacing) == Bits(b.PartSpacing)
&& Bits(a.EdgeSpacing.Left) == Bits(b.EdgeSpacing.Left)
&& Bits(a.EdgeSpacing.Right) == Bits(b.EdgeSpacing.Right)
&& Bits(a.EdgeSpacing.Top) == Bits(b.EdgeSpacing.Top)
&& Bits(a.EdgeSpacing.Bottom) == Bits(b.EdgeSpacing.Bottom)
&& a.Quantity == b.Quantity
&& a.Cost.HasValue == b.Cost.HasValue
&& (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value));
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
private sealed class LayoutComparer : IEqualityComparer<NestJobPlateResult>
{
internal static readonly LayoutComparer Instance = new();
public bool Equals(NestJobPlateResult a, NestJobPlateResult b)
{
if (ReferenceEquals(a, b))
return true;
if (a == null || b == null || !SameStock(a.Stock, b.Stock)
|| a.Placements.Count != b.Placements.Count)
return false;
for (var i = 0; i < a.Placements.Count; i++)
{
var x = a.Placements[i];
var y = b.Placements[i];
if (!string.Equals(x.PartId, y.PartId, StringComparison.Ordinal)
|| Bits(x.X) != Bits(y.X) || Bits(x.Y) != Bits(y.Y)
|| Bits(x.Rotation) != Bits(y.Rotation))
return false;
}
return true;
}
public int GetHashCode(NestJobPlateResult sheet)
{
// Hash only accelerates lookup; Equals checks every stock field and ordered pose.
var hash = new HashCode();
hash.Add(sheet.StockId, StringComparer.Ordinal);
foreach (var pose in sheet.Placements)
{
hash.Add(pose.PartId, StringComparer.Ordinal);
hash.Add(Bits(pose.X));
hash.Add(Bits(pose.Y));
hash.Add(Bits(pose.Rotation));
}
return hash.ToHashCode();
}
}
}
@@ -39,6 +39,11 @@ public sealed class IrregularNestingEngine : INestingEngine
/// </summary>
internal long WorkBudget { get; init; } = 1_500_000;
// Instance-owned diagnostics; no process-global counters or mutable solver state.
internal Action? SheetPacked { get; init; }
internal Action<RepeatBatch>? BatchSelected { get; init; }
internal Action<int>? TailReplaced { get; init; }
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress>? progress = null,
@@ -48,7 +53,7 @@ public sealed class IrregularNestingEngine : INestingEngine
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
var types = PartCatalog.Build(job);
using var solver = new Solver(job, types, progress, token, WorkBudget);
using var solver = new Solver(job, types, progress, token, WorkBudget, SheetPacked, BatchSelected, TailReplaced);
// Pair-only orientations may fit stock even when the sampled single poses do not.
// Demand that neither a single nor a pair can fit is reported unplaced.
@@ -78,7 +83,19 @@ public sealed class IrregularNestingEngine : INestingEngine
// The last sheets hold the leftovers, which is where waste concentrates; re-plan them.
best = solver.ImproveTail(best!, WorkBudget * 2);
return BuildResult(job, types, best, progress);
return BuildResult(job, types, best, progress, token);
}
/// <summary>Exercises the production physical-tail lifecycle from a test-owned incumbent.
/// This does not alter normal Solve or expose a public packing API.</summary>
internal NestJobResult ImproveTailForTesting(NestJob job, IReadOnlyList<SheetFill> physicalPlan)
{
var types = PartCatalog.Build(job);
using var solver = new Solver(job, types, null, default, WorkBudget, SheetPacked, BatchSelected, TailReplaced);
var unplaced = job.Parts.Sum(p => p.Quantity) - physicalPlan.Sum(s => s.Parts.Count);
var plan = new Plan(physicalPlan, physicalPlan.Sum(s => NetCost(job.Options, s)), unplaced,
unplaced == 0 ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound, NestJobCost.UsesExplicitCosts(job));
return BuildResult(job, types, solver.ImproveTail(plan, WorkBudget * 2), null, default);
}
/// <summary>Shared state for one solve: job, catalog, NFP caches, effort meter.</summary>
@@ -87,7 +104,10 @@ public sealed class IrregularNestingEngine : INestingEngine
IReadOnlyList<PartType> types,
IProgress<NestJobProgress>? progress,
CancellationToken token,
long workBudget
long workBudget,
Action? sheetPacked,
Action<RepeatBatch>? batchSelected,
Action<int>? tailReplaced
) : IDisposable
{
public void Dispose()
@@ -100,6 +120,7 @@ public sealed class IrregularNestingEngine : INestingEngine
private readonly Dictionary<double, NoFitCache> caches = new();
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
private readonly Dictionary<double, BlockCatalog> blocks = new();
private readonly IReadOnlyDictionary<string, NestJobPart> parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
private BlockCatalog BlocksFor(NestPlateStock stock)
{
@@ -164,6 +185,7 @@ public sealed class IrregularNestingEngine : INestingEngine
continue;
sheets = prefix.Concat(bestRun.Sheets).ToList();
plan = plan with { Sheets = sheets.ToList(), Cost = plan.Cost - (tailNet - bestRun.Net) };
tailReplaced?.Invoke(k);
}
return plan;
}
@@ -222,6 +244,7 @@ public sealed class IrregularNestingEngine : INestingEngine
while (true)
{
token.ThrowIfCancellationRequested();
if (remaining.All(r => r == 0))
{
reason = NestJobStopReason.Completed;
@@ -244,6 +267,7 @@ public sealed class IrregularNestingEngine : INestingEngine
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
var fill = packer.Fill(remaining, token);
sheetPacked?.Invoke();
if (fill.Parts.Count > 0)
trials.Add((fill, NetCost(job.Options, fill)));
}
@@ -266,11 +290,42 @@ public sealed class IrregularNestingEngine : INestingEngine
.ThenBy(t => t.order)
.First();
sheets.Add(chosen.Fill);
net = NestJobCost.RequireFinite(net + chosen.Net);
used[chosen.Fill.Stock.Id]++;
foreach (var part in chosen.Fill.Parts)
remaining[part.Orientation.TypeIndex]--;
var outstanding = types.ToDictionary(t => t.Part.Id, t => remaining[t.Index], StringComparer.Ordinal);
var counts = chosen.Fill.Parts.GroupBy(p => types[p.Orientation.TypeIndex].Part.Id, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
// Count all stock still available, not just the forced-first trial subset.
var availableEntries = job.Plates.Count(s => s.Quantity == null || used[s.Id] < s.Quantity);
// Fill has no partial/budget exit: it returns after states empty or Choose finds
// no supported placement. WorkBudget only controls later variants/block preparation.
var copies = CompletedLayoutRepeat.Limit(outstanding, counts, searchCompleted: true, availableEntries,
chosen.Fill.Stock.Quantity - used[chosen.Fill.Stock.Id], sheetCap - sheets.Count);
if (copies > 1)
{
try
{
NestJobValidator.ValidateCandidate(new PlateCandidate(chosen.Fill.Parts.Select(p =>
new NestJobPlacement(types[p.Orientation.TypeIndex].Part.Id, 0, p.X, p.Y, p.Orientation.Rotation))),
chosen.Fill.Stock, outstanding, parts);
}
catch (InvalidOperationException)
{
copies = 1; // Uncertified candidates retain ordinary allocation/final validation.
}
}
batchSelected?.Invoke(new RepeatBatch(chosen.Fill.Stock.Id, copies, availableEntries, sheets.Count,
sheets.Count == 0 && first != null));
for (var copy = 0; copy < copies; copy++)
{
token.ThrowIfCancellationRequested();
// Poses are immutable records over the read-only catalog; each physical sheet
// owns a separate list so tail replanning can remove/replace individual copies.
sheets.Add(chosen.Fill with { Parts = chosen.Fill.Parts.ToList() });
net = NestJobCost.RequireFinite(net + chosen.Net);
}
used[chosen.Fill.Stock.Id] = checked(used[chosen.Fill.Stock.Id] + copies);
foreach (var type in types)
if (counts.TryGetValue(type.Part.Id, out var count))
remaining[type.Index] = checked(remaining[type.Index] - checked(count * copies));
}
return new Run(sheets, net, reason);
@@ -280,12 +335,16 @@ public sealed class IrregularNestingEngine : INestingEngine
private sealed record Run(IReadOnlyList<SheetFill> Sheets, double Net, NestJobStopReason Reason);
private static NestJobResult BuildResult(NestJob job, IReadOnlyList<PartType> types,
Plan plan, IProgress<NestJobProgress>? progress)
Plan plan, IProgress<NestJobProgress>? progress, CancellationToken token)
{
var builder = new NestJobResultBuilder(job, progress);
foreach (var sheet in plan.Sheets)
{
token.ThrowIfCancellationRequested();
builder.AddSheet(sheet.Stock, sheet.Parts.Select(p =>
(types[p.Orientation.TypeIndex].Part.Id, p.X, p.Y, p.Orientation.Rotation)));
}
token.ThrowIfCancellationRequested();
return builder.Build(plan.Reason);
}
@@ -32,6 +32,10 @@ public sealed class RectanglesNestingEngine : INestingEngine
private static readonly PickMode[] Modes = { PickMode.Global, PickMode.Ordered };
// Instance-owned diagnostics; no process-global counters or mutable solver state.
internal Action<PickMode>? SheetPacked { get; init; }
internal Action<RepeatBatch>? BatchSelected { get; init; }
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress>? progress = null,
@@ -49,11 +53,13 @@ public sealed class RectanglesNestingEngine : INestingEngine
remaining[t.Index] = 0;
var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
var parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
var result = new NestJobResultBuilder(job, progress);
NestJobStopReason reason;
while (true)
{
token.ThrowIfCancellationRequested();
if (remaining.All(r => r == 0))
{
reason = NestJobStopReason.NoPlacementFound; // Builder reports Completed when demand is met.
@@ -77,6 +83,7 @@ public sealed class RectanglesNestingEngine : INestingEngine
foreach (var rule in Rules)
{
var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token);
SheetPacked?.Invoke(mode);
if (plan.Parts.Count > 0)
trials.Add((plan, NetCost(job, plan)));
}
@@ -90,10 +97,39 @@ public sealed class RectanglesNestingEngine : INestingEngine
}
var chosen = Choose(types, remaining, trials);
result.AddSheet(chosen.Stock, chosen.Poses());
used[chosen.Stock.Id]++;
foreach (var p in chosen.Parts)
remaining[p.Type.Index]--;
var poses = chosen.Poses().ToArray();
var demand = types.ToDictionary(t => t.Id, t => remaining[t.Index], StringComparer.Ordinal);
var counts = chosen.Parts.GroupBy(p => p.Type.Id, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
var availableEntries = job.Plates.Count(s => s.Quantity == null || used[s.Id] < s.Quantity);
// Both private MaxRects modes return only after their normal demand/no-fit search;
// neither returns partial/budget-limited output. This is not a geometric impossibility proof.
var copies = CompletedLayoutRepeat.Limit(demand, counts, searchCompleted: true, availableEntries,
chosen.Stock.Quantity - used[chosen.Stock.Id], job.Options.MaxPlates - result.SheetsUsed(job));
if (copies > 1)
{
try
{
NestJobValidator.ValidateCandidate(new PlateCandidate(poses.Select(p =>
new NestJobPlacement(p.PartId, 0, p.X, p.Y, p.Rotation))), chosen.Stock, demand, parts);
}
catch (InvalidOperationException)
{
// Keep the original one-sheet path if the candidate validator cannot certify
// reuse (including its stricter work-area tolerance). Final job validation still applies.
copies = 1;
}
}
BatchSelected?.Invoke(new RepeatBatch(chosen.Stock.Id, copies, availableEntries, result.SheetsUsed(job), false));
for (var copy = 0; copy < copies; copy++)
{
token.ThrowIfCancellationRequested();
result.AddSheet(chosen.Stock, poses); // Builder owns fresh placements/instance indices.
}
used[chosen.Stock.Id] = checked(used[chosen.Stock.Id] + copies);
foreach (var type in types)
if (counts.TryGetValue(type.Id, out var count))
remaining[type.Index] = checked(remaining[type.Index] - checked(count * copies));
}
return result.Build(reason);
@@ -0,0 +1,284 @@
using System.Runtime.CompilerServices;
using OpenNest.Mcp;
using OpenNest.Mcp.Tools;
namespace OpenNest.FrontEnd.Tests;
[Collection("FrontEndRegistry")]
public class McpRepeatReservationLifecycleTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-mcp-reservations-" + Guid.NewGuid());
public McpRepeatReservationLifecycleTests() => Directory.CreateDirectory(directory);
public void Dispose() => Directory.Delete(directory, true);
private static NestSession Session(int plateCount = 2)
{
var session = new NestSession { Nest = new Nest("reservations") };
var input = new InputTools(session);
input.CreateDrawing("A", "rectangle", 2, 2);
input.CreateDrawing("B", "rectangle", 2, 2);
var setup = new SetupTools(session);
for (var i = 0; i < plateCount; i++)
setup.CreatePlate(3, 3, partSpacing: 0.25);
return session;
}
private static string Repeat(NestSession session, int index = 0, string name = "A", int quantity = 2) =>
new NestingTools(session).AutoNestJob(index, name, quantity.ToString(), "Rectangles", no_new_plates: true);
private static int Index(NestSession session, Plate plate) =>
session.AllPlates().FindIndex(p => ReferenceEquals(p, plate));
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ClearingOwnerAllowsCompleteRenestWithoutCreatingOrDeletingSheets(bool noNew)
{
var session = Session();
var original = session.AllPlates().ToArray();
var tools = new NestingTools(session);
var first = tools.AutoNestJob(0, "A", "2", "Rectangles", no_new_plates: noNew,
sheets: noNew ? null : [new SheetStockInput { Width = 3, Length = 3, Quantity = 2 }]);
Assert.Contains("2 sheet(s) placed in 1 layout(s)", first);
Assert.Equal(2, original[0].Quantity);
Assert.Empty(original[1].Parts);
Assert.Contains("removed 1 parts", new SetupTools(session).ClearPlate(0));
Assert.Equal(0, session.GetDrawing("A").Quantity.Nested);
Assert.Contains("2 sheet(s) placed in 1 layout(s)", Repeat(session));
Assert.Equal(original, session.AllPlates());
Assert.Single(original[0].Parts);
Assert.Equal(2, original[0].Quantity);
Assert.Empty(original[1].Parts);
Assert.Equal(2, session.GetDrawing("A").Quantity.Nested);
}
[Fact]
public void ClearingPlaceholderDoesNotReleasePopulatedOwnersPhysicalStock()
{
var session = Session(3);
Assert.Contains("2 sheet(s) placed in 1 layout(s)", Repeat(session));
var owner = session.GetPlate(0);
var part = Assert.Single(owner.Parts);
var setup = new SetupTools(session);
Assert.Contains("removed 0 parts", setup.ClearPlate(1));
var tools = new NestingTools(session);
Assert.Contains("consumed", Repeat(session, 1, "B", 1));
Assert.Contains("consumed", tools.FillPlate(1, "B", 1));
Assert.Contains("consumed", tools.FillArea(1, "B", 0, 0, 3, 3, 1));
Assert.Contains("consumed", tools.FillRemnants(1, "B", 1));
Assert.Contains("consumed", tools.PackPlate(1, "B", "1"));
Assert.Contains("consumed", tools.AutoNestPlate(1, "B", "1", allow_invalid: true));
Assert.Contains("Nothing committed", Repeat(session, 2, "B"));
Assert.Same(part, Assert.Single(owner.Parts));
Assert.Equal(2, session.GetDrawing("A").Quantity.Nested);
Assert.Equal(0, session.GetDrawing("B").Quantity.Nested);
Assert.All(session.AllPlates().Skip(1), plate => Assert.Empty(plate.Parts));
}
[Fact]
public void ClearingOneOwnerDoesNotFreeAnotherPopulatedLayoutsReservation()
{
var session = Session(5);
Assert.Contains("Whole job complete", Repeat(session));
Assert.Contains("Whole job complete", Repeat(session, 2, "B"));
var otherOwner = session.GetPlate(2);
var otherPart = Assert.Single(otherOwner.Parts);
var otherPlaceholder = session.GetPlate(3);
new SetupTools(session).ClearPlate(0);
Assert.Contains("consumed", Repeat(session, 3, "B", 3));
Assert.Contains("3 sheet(s) placed in 1 layout(s)", Repeat(session, quantity: 3));
Assert.Same(otherPart, Assert.Single(otherOwner.Parts));
Assert.Equal(2, otherOwner.Quantity);
Assert.Empty(otherPlaceholder.Parts);
Assert.Contains("consumed", new NestingTools(session).FillPlate(Index(session, otherPlaceholder), "A", 1));
Assert.Equal(5, session.AllPlates().Count);
Assert.Equal(3, session.GetDrawing("A").Quantity.Nested);
Assert.Equal(2, session.GetDrawing("B").Quantity.Nested);
}
[Fact]
public void DeletingPlaceholderDoesNotReleaseItsOwnersOtherCopiesOrAnotherOwner()
{
var session = Session(5);
Assert.Contains("Whole job complete", Repeat(session, quantity: 3));
Assert.Contains("Whole job complete", Repeat(session, 3, "B"));
var original = session.AllPlates().ToArray();
var setup = new SetupTools(session);
Assert.Contains("occupied", setup.DeletePlate(0));
Assert.Contains("Deleted plate 1", setup.DeletePlate(1));
Assert.Equal(new[] { original[0], original[2], original[3], original[4] }, session.AllPlates());
Assert.Contains("consumed", Repeat(session, 1, "A", 4));
Assert.Contains("consumed", Repeat(session, 3, "B", 3));
Assert.Equal(3, original[0].Quantity);
Assert.Equal(2, original[3].Quantity);
Assert.Single(original[0].Parts);
Assert.Single(original[3].Parts);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void MutableCollectionsReleaseOnlyRemovedOwnersReservation(bool loadedOwner)
{
var session = Session(4);
if (loadedOwner)
{
session.Nest.Plates.Add(session.Plates[0]);
session.Plates.RemoveAt(0);
}
Assert.Contains("Whole job complete", Repeat(session));
Assert.Contains("Whole job complete", Repeat(session, 2, "B"));
var removed = session.GetPlate(0);
var freed = session.GetPlate(1);
var otherOwner = session.GetPlate(2);
var otherPlaceholder = session.GetPlate(3);
if (loadedOwner)
session.Nest.Plates.Remove(removed);
else
session.Plates.Remove(removed); // List removal leaves the detached owner's parts populated.
Assert.Contains("consumed", Repeat(session, Index(session, otherPlaceholder), "B", 3));
Assert.Contains("Parts added: 1", new NestingTools(session).FillPlate(Index(session, freed), "A", 1));
Assert.Single(freed.Parts);
Assert.Single(otherOwner.Parts);
Assert.Equal(2, otherOwner.Quantity);
Assert.Empty(otherPlaceholder.Parts);
Assert.Equal(3, session.AllPlates().Count);
}
[Fact]
public void DirectlyClearedThenDeletedOwnerReleasesCopiesWithoutCorruptingOtherOwner()
{
var session = Session(4);
Assert.Contains("Whole job complete", Repeat(session));
Assert.Contains("Whole job complete", Repeat(session, 2, "B"));
var freed = session.GetPlate(1);
var otherPlaceholder = session.GetPlate(3);
session.GetPlate(0).Parts.Clear();
Assert.Contains("Deleted plate 0", new SetupTools(session).DeletePlate(0));
Assert.Contains("consumed", Repeat(session, Index(session, otherPlaceholder), "B", 3));
Assert.Contains("1 sheet(s) placed in 1 layout(s)", Repeat(session, Index(session, freed), quantity: 1));
Assert.Single(freed.Parts);
Assert.Empty(otherPlaceholder.Parts);
}
[Fact]
public void LoadingReplacementReleasesRemovedOwnerButPreservesAppendedOwnersReservation()
{
var session = Session(4);
session.Nest.Plates.Add(session.Plates[0]);
session.Plates.RemoveAt(0);
Assert.Contains("Whole job complete", Repeat(session));
Assert.Contains("Whole job complete", Repeat(session, 2, "B"));
var freed = session.GetPlate(1);
var otherOwner = session.GetPlate(2);
var otherPlaceholder = session.GetPlate(3);
var path = ReplacementFile();
Assert.Contains("Loaded nest", new InputTools(session).LoadNest(path));
Assert.Contains("consumed", Repeat(session, Index(session, otherPlaceholder), "B", 3));
Assert.Contains("1 sheet(s) placed in 1 layout(s)", Repeat(session, Index(session, freed), quantity: 1));
Assert.Single(freed.Parts);
Assert.Single(otherOwner.Parts);
Assert.Equal(2, otherOwner.Quantity);
Assert.Empty(otherPlaceholder.Parts);
Assert.Equal(4, session.AllPlates().Count);
}
private string ReplacementFile()
{
var replacement = Session(1);
replacement.Drawings.Clear();
new InputTools(replacement).CreateDrawing("LoadedOnly", "rectangle", 2, 2);
Assert.Contains("Whole job complete", Repeat(replacement, name: "LoadedOnly", quantity: 1));
var path = Path.Combine(directory, "replacement.nest");
Assert.Contains("Saved nest", new InputTools(replacement).SaveNest(path));
return path;
}
[Fact]
public void LoadingAwayPlaceholderDoesNotReleaseAppendedOwnersRemainingReservation()
{
var session = Session(3);
Assert.Contains("Whole job complete", Repeat(session, quantity: 3));
var owner = session.GetPlate(0);
var remaining = session.GetPlate(2);
var replaced = ReplaceLoadedPlaceholder(session, ReplacementFile());
Assert.Contains("consumed", Repeat(session, Index(session, remaining), "B", 1));
Assert.Single(owner.Parts);
Assert.Equal(3, owner.Quantity);
Assert.Empty(remaining.Parts);
AssertCollected(replaced);
GC.KeepAlive(session);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference ReplaceLoadedPlaceholder(NestSession session, string path)
{
var placeholder = session.GetPlate(1);
session.Plates.Remove(placeholder);
session.Nest.Plates.Add(placeholder);
Assert.Contains("Loaded nest", new InputTools(session).LoadNest(path));
return new WeakReference(placeholder);
}
[Fact]
public void DeletedPlaceholderReferencesAreNotRetainedWhileSiblingReservationStaysBlocked()
{
var session = Session(3);
Assert.Contains("Whole job complete", Repeat(session, quantity: 3));
var deleted = DeletePlaceholder(session);
Assert.Contains("consumed", Repeat(session, 1, "B", 1));
AssertCollected(deleted);
GC.KeepAlive(session);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference DeletePlaceholder(NestSession session)
{
var placeholder = session.GetPlate(1);
Assert.Contains("Deleted plate 1", new SetupTools(session).DeletePlate(1));
return new WeakReference(placeholder);
}
[Fact]
public void RepeatedLoadedNestReplacementDoesNotRetainOldOwnersOrPlaceholders()
{
var session = new NestSession();
var path = ReplacementFile();
var oldPlates = ReplaceLoadedReservations(session, path);
Assert.All(oldPlates, AssertCollected);
GC.KeepAlive(session);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static List<WeakReference> ReplaceLoadedReservations(NestSession session, string path)
{
var oldPlates = new List<WeakReference>();
for (var i = 0; i < 8; i++)
{
var fixture = Session();
foreach (var drawing in fixture.AllDrawings())
fixture.Nest.Drawings.Add(drawing);
foreach (var plate in fixture.AllPlates())
fixture.Nest.Plates.Add(plate);
session.Nest = fixture.Nest;
Assert.Contains("Whole job complete", Repeat(session));
oldPlates.AddRange(session.AllPlates().Select(p => new WeakReference(p)));
Assert.Contains("Loaded nest", new InputTools(session).LoadNest(path));
}
return oldPlates;
}
private static void AssertCollected(WeakReference reference)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Assert.False(reference.IsAlive, "Session retained a plate outside its live reservation ownership.");
}
}
+143 -13
View File
@@ -262,21 +262,19 @@ public class McpWholeJobTests : IDisposable
NestingEngineRegistry.Register(engine, "two safe sheets", () => new TwoSheetStub());
var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine);
Assert.Contains("complete", text, StringComparison.OrdinalIgnoreCase);
Assert.Equal(2, session.AllPlates().Count);
Assert.Single(session.AllPlates());
Assert.Same(original, session.GetPlate(0));
Assert.Single(session.Plates);
Assert.All(session.AllPlates(), plate =>
{
Assert.Equal(2, plate.Parts.Count);
Assert.Equal(0.25, plate.PartSpacing);
Assert.Equal(1, plate.Quantity);
});
Assert.Empty(session.Plates);
Assert.Equal(2, original.Parts.Count);
Assert.Equal(0.25, original.PartSpacing);
Assert.Equal(2, original.Quantity);
Assert.Contains("2 sheet(s) placed in 1 layout(s)", text);
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
var path = Path.Combine(directory, "complete.nest");
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
var loaded = new NestReader(path).Read();
Assert.Equal(2, loaded.Plates.Count);
Assert.Equal(2, Assert.Single(loaded.Plates).Quantity);
Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "B").Quantity.Nested);
}
@@ -310,7 +308,7 @@ public class McpWholeJobTests : IDisposable
session.Plates.Add(second);
var text = Call(new NestingTools(session), 1, "A,B", "3,1", "Rectangles", noNew: true);
Assert.Contains("Whole job complete", text);
Assert.Contains("0 new sheet(s)", text);
Assert.Contains("0 new layout(s)", text);
Assert.Same(first, session.GetPlate(1));
Assert.Same(second, session.GetPlate(2));
Assert.Equal(3, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
@@ -331,8 +329,9 @@ public class McpWholeJobTests : IDisposable
session.GetPlate(0).Size = new Size(3, 3);
var text = Call(new NestingTools(session), 0, "A,B", "2,2", "Rectangles");
Assert.Contains("Whole job complete", text);
Assert.Equal(4, session.AllPlates().Count);
Assert.All(session.AllPlates(), p => Assert.Single(p.Parts));
Assert.Equal(4, session.AllPlates().Sum(p => p.Quantity));
Assert.Equal(2, session.AllPlates().Count);
Assert.All(session.AllPlates(), p => { Assert.Single(p.Parts); Assert.Equal(2, p.Quantity); });
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
}
@@ -404,7 +403,9 @@ public class McpWholeJobTests : IDisposable
Assert.Contains("Whole job complete", text);
Assert.Equal(2, session.AllPlates().Count);
Assert.Single(first.Parts);
Assert.Single(second.Parts);
Assert.Equal(2, first.Quantity);
Assert.Empty(second.Parts); // redundant empty layout remains untouched, not expanded
Assert.Equal(1, second.Quantity);
}
[Fact]
@@ -487,6 +488,135 @@ public class McpWholeJobTests : IDisposable
CancellationToken token = default) => throw new NotSupportedException("unsupported fixture");
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RealRepeatedJobPreservesQuantitiesAndExistingOnlyPhysicalCap(bool noNew)
{
var session = Session();
var template = session.GetPlate(0);
template.Size = new Size(4.5, 4.5);
var drawing = session.Nest.Drawings.Single(d => d.Name == "A");
drawing.Constraints = new NestConstraints { StepAngle = OpenNest.Math.Angle.TwoPI, StartAngle = 0, EndAngle = 0 };
if (noNew)
{
session.Plates.Add(new Plate(4.5, 4.5) { PartSpacing = 0.25 });
session.Plates.Add(new Plate(4.5, 4.5) { PartSpacing = 0.25 });
}
var output = new NestingTools(session).AutoNestJob(0, "A", "9", "Rectangles", no_new_plates: noNew,
sheets: noNew ? null : [new SheetStockInput { Width = 4.5, Length = 4.5, Quantity = 4, Cost = 7 }]);
Assert.Contains("3 sheet(s) placed in 2 layout(s)", output);
Assert.Equal(new[] { 2, 1 }, session.AllPlates().Where(p => p.Parts.Count > 0).Select(p => p.Quantity));
Assert.Equal(9, drawing.Quantity.Nested);
Assert.Equal(9, drawing.Quantity.Required);
if (noNew)
{
Assert.Contains("0 new layout(s)", output);
Assert.Equal(3, session.AllPlates().Count);
Assert.Empty(session.GetPlate(2).Parts);
}
else
{
Assert.Contains("gross total=21", output);
Assert.Contains("stock-0 (4.5x4.5): used=3, remaining=1, reused=1, created=1 (layout counts)", output);
Assert.Contains("plate 0 -> stock-0", output);
Assert.Contains("plate 1 -> stock-0", output);
}
var path = Path.Combine(directory, "repeated.nest");
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
var loaded = new NestReader(path).Read();
Assert.Equal(new[] { 2, 1 }, loaded.Plates.Select(p => p.Quantity));
Assert.Equal(9, loaded.Drawings.Single(d => d.Name == "A").Quantity.Nested);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RepeatedLayoutCannotHideInsufficientPhysicalStock(bool noNew)
{
var session = Session();
session.GetPlate(0).Size = new Size(3, 3);
if (noNew) session.Plates.Add(new Plate(3, 3) { PartSpacing = 0.25 });
var output = new NestingTools(session).AutoNestJob(0, "A", "3", "Rectangles", no_new_plates: noNew,
sheets: noNew ? null : [new SheetStockInput { Width = 3, Length = 3, Quantity = 2 }]);
Assert.Contains("incomplete", output, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Nothing committed", output);
Assert.All(session.AllPlates(), p => Assert.Empty(p.Parts));
Assert.Equal(0, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
}
[Fact]
public void MixedStockGroupedMappingReportsWeightedUsageAndCostWithoutFlattening()
{
var session = Session();
var template = session.GetPlate(0);
template.Size = new Size(3, 3);
var engine = "GroupedStock-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "expanded exact layouts", () => new InspectStub(job =>
{
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[1], [(job.Parts[1].Id, 1d, 1d, 0d)]);
builder.AddSheet(job.Plates[0], [(job.Parts[0].Id, 0.5, 0.5, 0d)]);
builder.AddSheet(job.Plates[1], [(job.Parts[1].Id, 1d, 1d, 0d)]);
builder.AddSheet(job.Plates[0], [(job.Parts[0].Id, 0.5, 0.5, 0d)]);
return builder.Build(NestJobStopReason.Completed);
}));
var text = new NestingTools(session).AutoNestJob(0, "A,B", "2,2", engine,
sheets: [new SheetStockInput { Width = 3, Length = 3, Quantity = 3, Cost = 5 },
new SheetStockInput { Width = 6, Length = 6, Quantity = 2, Cost = 7 }]);
Assert.Contains("4 sheet(s) placed in 2 layout(s)", text);
Assert.Contains("gross total=24", text);
Assert.Contains("stock-0 (3x3): used=2, remaining=1, reused=1, created=0 (layout counts)", text);
Assert.Contains("stock-1 (6x6): used=2, remaining=0, reused=0, created=1 (layout counts)", text);
Assert.Contains("plate 0 -> stock-0", text);
Assert.Contains("plate 1 -> stock-1", text);
Assert.Same(template, session.GetPlate(0));
Assert.Equal(2, session.AllPlates().Count);
Assert.All(session.AllPlates(), p => { Assert.Equal(2, p.Quantity); Assert.Single(p.Parts); });
Assert.All(session.AllDrawings(), d => Assert.Equal(2, d.Quantity.Nested));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ExistingOnlyGroupedCopiesCannotOfferConsumedEmptyPlaceholdersAsStockAgain(bool noNew)
{
var session = Session();
session.GetPlate(0).Size = new Size(3, 3);
var consumed = new Plate(3, 3) { PartSpacing = 0.25 };
var spare = new Plate(3, 3) { PartSpacing = 0.25 };
session.Plates.Add(consumed);
session.Plates.Add(spare);
var tools = new NestingTools(session);
var first = tools.AutoNestJob(0, "A", "2", "Rectangles", no_new_plates: noNew,
sheets: noNew ? null : [new SheetStockInput { Width = 3, Length = 3, Quantity = 2 }]);
Assert.Contains("2 sheet(s) placed in 1 layout(s)", first);
Assert.Equal(2, session.GetPlate(0).Quantity);
Assert.Empty(consumed.Parts);
Assert.Empty(spare.Parts);
// Two physical sheets were consumed, so only one of the two empty entries is free.
var output = Call(new NestingTools(session), 2, "A,B", "2,2", "Rectangles", noNew: true);
Assert.Contains("Nothing committed", output);
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(0, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
Assert.Equal(3, session.AllPlates().Count);
Assert.Empty(consumed.Parts);
Assert.Empty(spare.Parts);
// Explicitly selecting the consumed placeholder must not resurrect it either.
Assert.Contains("Nothing committed", Call(new NestingTools(session), 1, "B", "1", "Rectangles", noNew: true));
Assert.Contains("consumed", tools.FillPlate(1, "B", 1));
Assert.Contains("consumed", tools.FillArea(1, "B", 0, 0, 3, 3, 1));
Assert.Contains("consumed", tools.FillRemnants(1, "B", 1));
Assert.Contains("consumed", tools.PackPlate(1, "B", "1"));
Assert.Contains("consumed", tools.AutoNestPlate(1, "B", "1", allow_invalid: true));
Assert.Empty(consumed.Parts);
// Rejection must not consume the genuinely unused last physical sheet.
Assert.Contains("1 sheet(s) placed in 1 layout(s)", Call(new NestingTools(session), 2, "A,B", "2,1", "Rectangles", noNew: true));
Assert.Single(spare.Parts);
Assert.Equal(1, spare.Quantity);
Assert.Equal(1, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
}
private sealed class TwoSheetStub(bool overlap = false) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
+57 -1
View File
@@ -1,13 +1,67 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Mcp
{
public class NestSession
{
public Nest Nest { get; set; }
private Nest nest;
public Nest Nest
{
get => nest;
set
{
nest = value;
ReconcileStockReservations();
}
}
public List<Plate> Plates { get; } = new();
public List<Drawing> Drawings { get; } = new();
// A repeated layout owns its otherwise-empty physical sheet placeholders.
// Clearing/removing that owner releases only its copies, never another layout's.
// User-authored plates and pre-existing quantities imply no reservations.
private readonly Dictionary<Plate, HashSet<Plate>> stockReservations = new(ReferenceEqualityComparer.Instance);
internal bool IsConsumedStockPlate(Plate plate)
{
ReconcileStockReservations();
return stockReservations.Values.Any(copies => copies.Contains(plate));
}
internal void ReserveStockCopies(Plate owner, IEnumerable<Plate> copies)
{
var reserved = new HashSet<Plate>(copies, ReferenceEqualityComparer.Instance);
if (reserved.Count > 0)
stockReservations[owner] = reserved;
}
internal void ReleaseStockCopies(Plate owner) => stockReservations.Remove(owner);
private void ReconcileStockReservations()
{
if (stockReservations.Count == 0)
return;
// Both collections are publicly mutable. Reconcile by reference, not index,
// so removals/replacements cannot retain stale plates or free unrelated stock.
var live = new HashSet<Plate>(Plates, ReferenceEqualityComparer.Instance);
if (Nest != null)
live.UnionWith(Nest.Plates);
foreach (var (owner, copies) in stockReservations.ToArray())
{
if (!live.Contains(owner) || owner.Parts.Count == 0)
{
stockReservations.Remove(owner);
continue;
}
copies.RemoveWhere(copy => !live.Contains(copy));
if (copies.Count == 0)
stockReservations.Remove(owner);
}
}
/// <summary>
/// Whole-job engine <c>autonest_plate</c> uses when a call does not pass an explicit
/// <c>engine</c> argument; fill tools default to the Fill strategy instead. Instance state
@@ -58,6 +112,7 @@ namespace OpenNest.Mcp
else
Plates.RemoveAt(index - loadedCount);
ReconcileStockReservations();
reason = null;
return true;
}
@@ -84,6 +139,7 @@ namespace OpenNest.Mcp
public List<Plate> AllPlates()
{
ReconcileStockReservations();
var all = new List<Plate>();
if (Nest != null)
all.AddRange(Nest.Plates);
+42 -8
View File
@@ -55,6 +55,8 @@ namespace OpenNest.Mcp.Tools
var plate = _session.GetPlate(plateIndex);
if (plate == null)
return $"Error: plate {plateIndex} not found";
if (_session.IsConsumedStockPlate(plate))
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
var drawing = _session.GetDrawing(drawingName);
if (drawing == null)
@@ -108,6 +110,8 @@ namespace OpenNest.Mcp.Tools
var plate = _session.GetPlate(plateIndex);
if (plate == null)
return $"Error: plate {plateIndex} not found";
if (_session.IsConsumedStockPlate(plate))
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
var drawing = _session.GetDrawing(drawingName);
if (drawing == null)
@@ -158,6 +162,8 @@ namespace OpenNest.Mcp.Tools
var plate = _session.GetPlate(plateIndex);
if (plate == null)
return $"Error: plate {plateIndex} not found";
if (_session.IsConsumedStockPlate(plate))
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
var drawing = _session.GetDrawing(drawingName);
if (drawing == null)
@@ -221,6 +227,8 @@ namespace OpenNest.Mcp.Tools
var plate = _session.GetPlate(plateIndex);
if (plate == null)
return $"Error: plate {plateIndex} not found";
if (_session.IsConsumedStockPlate(plate))
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
if (string.IsNullOrWhiteSpace(drawingNames))
return "Error: drawingNames is required";
@@ -277,6 +285,8 @@ namespace OpenNest.Mcp.Tools
var plate = _session.GetPlate(plateIndex);
if (plate == null)
return $"Error: plate {plateIndex} not found";
if (_session.IsConsumedStockPlate(plate))
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
if (plate.Parts.Count > 0)
return "Error: autonest cannot use an occupied plate. Use fill_area or fill_remnants for existing obstacles.";
if (string.IsNullOrWhiteSpace(drawingNames))
@@ -305,9 +315,9 @@ namespace OpenNest.Mcp.Tools
var sb = new StringBuilder();
foreach (var violation in result.Violations)
sb.AppendLine($"Violation: {violation}");
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allow_invalid))
if (!result.CanKeep || result.Raw.Plates.Count > 1 || (!result.IsValid && !allow_invalid))
{
sb.AppendLine(result.Plates.Count > 1
sb.AppendLine(result.Raw.Plates.Count > 1
? "Error: multiple result sheets cannot be merged onto one target, even with allow_invalid. Nothing committed."
: !result.CanKeep
? "Error: result cannot be represented faithfully, even with allow_invalid. Nothing committed."
@@ -350,6 +360,8 @@ namespace OpenNest.Mcp.Tools
var template = _session.GetPlate(plateIndex);
if (template == null)
return $"Error: plate {plateIndex} not found";
if (_session.IsConsumedStockPlate(template))
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
if (template.Parts.Count != 0 || template.CutOffs.Count != 0)
return "Error: stock template must be empty and have no cutoff definitions. Nothing committed.";
if (sheets != null && no_new_plates)
@@ -392,6 +404,7 @@ namespace OpenNest.Mcp.Tools
var available = new List<Plate> { template };
available.AddRange(existing.Where(p => !ReferenceEquals(p, template)
&& !_session.IsConsumedStockPlate(p)
&& p.Parts.Count == 0 && p.CutOffs.Count == 0 && SameStock(p, template)));
var demand = remaining.Sum(item => (long)item.Quantity);
if (demand > int.MaxValue)
@@ -419,14 +432,14 @@ namespace OpenNest.Mcp.Tools
var placed = new Dictionary<Drawing, long>(ReferenceEqualityComparer.Instance);
foreach (var sheet in result.Plates)
foreach (var part in sheet.Parts)
placed[part.BaseDrawing] = placed.GetValueOrDefault(part.BaseDrawing) + 1;
placed[part.BaseDrawing] = placed.GetValueOrDefault(part.BaseDrawing) + sheet.Quantity;
if (remaining.Any(item => placed.GetValueOrDefault(item.Drawing) != item.Quantity))
{
var summary = string.Join(", ", remaining.Select(item =>
$"{item.Drawing.Name}: {placed.GetValueOrDefault(item.Drawing)}/{item.Quantity} newly placed"));
return $"Error: incomplete whole-job proposal ({result.StopReason}); {summary}. Nothing committed. A solver's no-placement result does not prove geometric impossibility.";
}
if (result.Plates.Count > limit)
if (result.Raw.Plates.Count > limit)
return "Error: proposal exceeds the allowed sheet count. Nothing committed.";
var costSummary = NestCostSummary.FromAccepted(result);
@@ -439,9 +452,12 @@ namespace OpenNest.Mcp.Tools
{
var target = new[] { template }.Concat(existing.Where(p => !ReferenceEquals(p, template)))
.FirstOrDefault(p => !usedTargets.Contains(p)
&& !_session.IsConsumedStockPlate(p)
&& p.Parts.Count == 0 && p.CutOffs.Count == 0
&& SameStock(p, template, proposed.Stock.Size));
var created = target == null;
if (created && no_new_plates)
return "Error: no compatible empty target for the proposed layout. Nothing committed.";
target ??= new Plate(proposed.Stock.Size)
{
GrainAngle = template.GrainAngle,
@@ -450,6 +466,22 @@ namespace OpenNest.Mcp.Tools
usedTargets.Add(target);
targets.Add((target, created, proposed.Stock.Id));
}
// Reserve physical copies separately from display-layout targets. Do this
// only after mapping every layout, so a repeat cannot steal a residual's
// target. A rejected/cancelled proposal reserves nothing and deletes nothing.
var reservations = new List<List<Plate>>();
foreach (var proposed in result.Plates)
{
var copies = existing.Where(p => !usedTargets.Contains(p)
&& !_session.IsConsumedStockPlate(p)
&& p.Parts.Count == 0 && p.CutOffs.Count == 0
&& SameStock(p, template, proposed.Stock.Size))
.Take(proposed.Quantity - 1).ToList();
if (no_new_plates && copies.Count != proposed.Quantity - 1)
return "Error: insufficient unused physical stock for the repeated layout. Nothing committed.";
usedTargets.UnionWith(copies);
reservations.Add(copies);
}
cancellationToken.ThrowIfCancellationRequested();
for (var i = 0; i < result.Plates.Count; i++)
{
@@ -459,11 +491,13 @@ namespace OpenNest.Mcp.Tools
target.PartSpacing = proposed.Stock.PartSpacing;
target.EdgeSpacing = proposed.Stock.EdgeSpacing;
target.Quadrant = proposed.Stock.Quadrant;
target.Quantity = 1;
target.Quantity = proposed.Quantity;
if (created)
_session.Plates.Add(target);
target.Parts.AddRange(proposed.Parts);
}
for (var i = 0; i < targets.Count; i++)
_session.ReserveStockCopies(targets[i].plate, reservations[i]);
foreach (var item in parsed.items)
item.Drawing.Quantity.Required = item.Quantity;
if (sheets != null)
@@ -476,13 +510,13 @@ namespace OpenNest.Mcp.Tools
lines.AppendLine($"Cost basis={costSummary.Basis}, gross total={costSummary.GrossTotal}, estimated salvage credit={costSummary.SalvageCredit}, net score={costSummary.NetScore}");
foreach (var usage in costSummary.Stock)
lines.AppendLine($" {usage.StockId}: used={usage.Used}, supplied cost={usage.Cost?.ToString() ?? "unspecified"}, gross subtotal={usage.GrossSubtotal}");
lines.AppendLine($"Whole job complete: {result.Plates.Count} sheet(s) placed, {targets.Count(t => t.created)} new sheet(s) created.");
lines.AppendLine($"Whole job complete: {result.Raw.Plates.Count} sheet(s) placed in {result.Plates.Count} layout(s), {targets.Count(t => t.created)} new layout(s) created.");
if (sheets != null)
{
foreach (var stock in offeredStock.stock)
{
var used = targets.Count(t => t.stockId == stock.Id);
lines.AppendLine($" {stock.Id} ({stock.Size.Width}x{stock.Size.Length}): used={used}, remaining={stock.Quantity - used}, reused={targets.Count(t => !t.created && t.stockId == stock.Id)}, created={targets.Count(t => t.created && t.stockId == stock.Id)}");
var used = targets.Where(t => t.stockId == stock.Id).Sum(t => t.plate.Quantity);
lines.AppendLine($" {stock.Id} ({stock.Size.Width}x{stock.Size.Length}): used={used}, remaining={stock.Quantity - used}, reused={targets.Count(t => !t.created && t.stockId == stock.Id)}, created={targets.Count(t => t.created && t.stockId == stock.Id)} (layout counts)");
}
foreach (var (target, _, stockId) in targets)
lines.AppendLine($" plate {_session.AllPlates().FindIndex(p => ReferenceEquals(p, target))} -> {stockId}");
+1
View File
@@ -85,6 +85,7 @@ namespace OpenNest.Mcp.Tools
var count = plate.Parts.Count;
plate.Parts.Clear();
_session.ReleaseStockCopies(plate);
return $"Cleared plate {plateIndex}: removed {count} parts";
}
@@ -0,0 +1,120 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading.Tasks;
using OpenNest.Api;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.Api;
public class RepeatedPlateResponseTests
{
private static Drawing Square()
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(2, 0)));
shape.Entities.Add(new Line(new Vector(2, 0), new Vector(2, 2)));
shape.Entities.Add(new Line(new Vector(2, 2), new Vector(0, 2)));
shape.Entities.Add(new Line(new Vector(0, 2), new Vector(0, 0)));
return new Drawing("neutral-square", OpenNest.Converters.ConvertGeometry.ToProgram(shape));
}
[Fact]
public async Task RealEngineArchiveCountsPhysicalSheetsAndWeightedPartsAreaTimeAndCost()
{
var path = Path.Combine(Path.GetTempPath(), $"repeated-{Guid.NewGuid()}.dxf");
var archive = Path.ChangeExtension(path, ".nestquote");
Dxf.ExportProgram(Square().Program, path);
try
{
var request = new NestRequest
{
Engine = "Rectangles",
Parts = [new NestRequestPart { Id = "external-square", DxfPath = path, Quantity = 9, AllowRotation = false }],
Plates = [new NestRequestPlate { Id = "finite-stock", Size = new Size(4.5, 4.5), Quantity = 4, PartSpacing = 0.25, Cost = 7 }],
};
var response = await NestRunner.RunAsync(request);
AssertResponse(response);
await response.SaveAsync(archive);
File.Delete(path); // Stored layouts and metadata must be sufficient without the DXF.
var loaded = await NestResponse.LoadAsync(archive);
AssertResponse(loaded);
Assert.Equal(response.CutTime, loaded.CutTime);
Assert.Equal(response.Elapsed, loaded.Elapsed);
Assert.Equal(response.Nest.Plates.SelectMany(p => p.Parts).Select(p => (p.Location, p.Rotation)),
loaded.Nest.Plates.SelectMany(p => p.Parts).Select(p => (p.Location, p.Rotation)));
Assert.All(loaded.Nest.Plates, p => Assert.Equal(0.25, p.PartSpacing));
}
finally { File.Delete(path); File.Delete(archive); }
}
private static void AssertResponse(NestResponse response)
{
Assert.Equal(4, response.SchemaVersion);
Assert.Equal(NestValidationStatus.Valid, response.ValidationStatus);
Assert.Equal(NestJobStatus.Complete, response.Status);
Assert.Empty(response.Violations);
Assert.Equal(3, response.SheetCount);
Assert.Equal(new[] { 2, 1 }, response.Nest.Plates.Select(p => p.Quantity));
Assert.Equal(new[] { 4, 1 }, response.Nest.Plates.Select(p => p.Parts.Count));
Assert.Equal(new NestPartFulfillment("external-square", 9, 9, 0), Assert.Single(response.Fulfillment));
Assert.Equal(9, Assert.Single(response.Nest.Drawings).Quantity.Nested);
Assert.Equal(new NestStockUsage("finite-stock", 3, 1), Assert.Single(response.StockUsage));
Assert.Equal(new[] { new NestPlateStockMapping(0, "finite-stock"), new NestPlateStockMapping(1, "finite-stock") }, response.PlateStockMappings);
Assert.Equal(36.0 / (3 * 4.5 * 4.5), response.Utilization, 10);
var timing = Timing.GetTimingInfo(response.Nest);
Assert.Equal(72, timing.CutDistance, 8);
Assert.Equal(9, timing.PierceCount);
Assert.Equal(Timing.CalculateTime(timing, response.Request.Cutting), response.CutTime);
Assert.True(response.CutTime > TimeSpan.Zero);
Assert.Equal("supplied-cost", response.Costs.Basis);
Assert.Equal(21, response.Costs.GrossTotal);
Assert.Equal(21, response.Costs.NetScore);
Assert.Equal(3, Assert.Single(response.Costs.Stock).Used);
}
[Fact]
public async Task SchemaThreePhysicalEntriesLoadWithoutGroupingOrMetadataReinterpretation()
{
var path = Path.Combine(Path.GetTempPath(), $"schema3-{Guid.NewGuid()}.nestquote");
var nest = new Nest();
var drawing = Square();
nest.Drawings.Add(drawing);
for (var i = 0; i < 2; i++)
{
var plate = new Plate(4.5, 4.5) { Quantity = 1 };
plate.Parts.Add(new Part(drawing));
nest.Plates.Add(plate);
}
try
{
using (var file = File.Create(path))
using (var zip = new ZipArchive(file, ZipArchiveMode.Create))
{
using (var writer = new StreamWriter(zip.CreateEntry("request.json").Open()))
await writer.WriteAsync("{\"parts\":[]}");
using (var writer = new StreamWriter(zip.CreateEntry("response.json").Open()))
await writer.WriteAsync("""
{"schemaVersion":3,"sheetCount":2,"utilization":0.25,"status":"Complete","stopReason":"Completed","validationStatus":"Valid","fulfillment":[{"partId":"old-id","requested":2,"placed":2,"unplaced":0}],"stockUsage":[{"stockId":"old-stock","used":2,"remaining":1}],"plateStockMappings":[{"plateIndex":0,"stockId":"old-stock"},{"plateIndex":1,"stockId":"old-stock"}]}
""");
using var stream = zip.CreateEntry("nest.nest").Open();
Assert.True(new NestWriter(nest).Write(stream));
}
var response = await NestResponse.LoadAsync(path);
Assert.Equal(3, response.SchemaVersion);
Assert.Equal(2, response.SheetCount);
Assert.Equal(2, response.Nest.Plates.Count);
Assert.All(response.Nest.Plates, p => Assert.Equal(1, p.Quantity));
Assert.Equal(0.25, response.Utilization);
Assert.Equal(new NestPartFulfillment("old-id", 2, 2, 0), Assert.Single(response.Fulfillment));
Assert.Equal(new NestStockUsage("old-stock", 2, 1), Assert.Single(response.StockUsage));
Assert.Equal(new[] { 0, 1 }, response.PlateStockMappings.Select(p => p.PlateIndex));
Assert.Equal(2, Assert.Single(response.Nest.Drawings).Quantity.Nested);
}
finally { File.Delete(path); }
}
}
@@ -40,7 +40,7 @@ public class CutoutPipelinePersistenceTests
using (var manager = new PlateManager(nest))
{
var committed = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
Assert.Equal(2, committed.Count);
Assert.Equal(2, committed.Sum(p => p.Quantity));
Assert.All(committed, plate => Assert.Equal(new[] { frame, insert },
plate.Parts.Select(p => p.BaseDrawing)));
}
@@ -49,11 +49,12 @@ public class CutoutPipelinePersistenceTests
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
var loaded = new NestReader(new MemoryStream(stream.ToArray())).Read();
Assert.Equal(2, loaded.Plates.Count);
Assert.Equal(2, loaded.Plates.Sum(p => p.Quantity));
for (var i = 0; i < loaded.Plates.Count; i++)
{
var expected = nest.Plates[i];
var actual = loaded.Plates[i];
Assert.Equal(expected.Quantity, actual.Quantity);
Assert.Equal(expected.Size, actual.Size);
Assert.Equal(expected.PartSpacing, actual.PartSpacing);
Assert.Equal(expected.Quadrant, actual.Quadrant);
@@ -0,0 +1,83 @@
using OpenNest.CNC;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.Engine;
public class RepeatedPlatePersistenceTests
{
[Theory]
[InlineData("Rectangles", 9)]
[InlineData("Irregular", 9)]
[InlineData("Default", 9)]
[InlineData("Rectangles", 5)]
[InlineData("Irregular", 5)]
[InlineData("Default", 5)]
public void RealEngineRepeatedAndPartialLayoutsCommitAndReloadWeightedQuantities(string engine, int quantity)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(2, 0);
program.LineTo(2, 2);
program.LineTo(0, 2);
program.LineTo(0, 0);
var drawing = new Drawing("neutral-square", program);
var before = PartGeometrySnapshot.FromProgram(drawing.Program).Motions;
var item = new NestItem
{
Drawing = drawing,
Quantity = quantity,
StepAngle = OpenNest.Math.Angle.TwoPI,
RotationStart = 0,
RotationEnd = 0
};
var result = NestPipeline.Run(new NestPipelineRequest(engine, [item],
[new NestPlateStock("sheet", new Size(4.5, 4.5), 3, 0.25)]));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(quantity / 4 + 1, result.Raw.Plates.Count);
Assert.Equal(quantity, result.Raw.Plates.Sum(p => p.Placements.Count));
Assert.Equal(Enumerable.Range(0, quantity), result.Raw.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex));
Assert.Equal(Enumerable.Range(0, result.Raw.Plates.Count), result.Raw.Plates.Select(p => p.PlateIndex));
Assert.Equal(result.Raw.Plates.Count, Assert.Single(result.Raw.StockUsage).Used);
Assert.Equal(quantity, Assert.Single(result.Raw.Fulfillment).Placed);
Assert.Equal(0, drawing.Quantity.Nested);
Assert.Equal(before, PartGeometrySnapshot.FromProgram(drawing.Program).Motions);
Assert.Equal(quantity, item.Quantity);
var nest = new Nest();
nest.Drawings.Add(drawing);
var occupied = nest.CreatePlate();
occupied.Quantity = 2;
var existing = new Part(drawing);
occupied.Parts.Add(existing);
var empty = nest.CreatePlate();
empty.Quantity = 7;
using (var manager = new PlateManager(nest))
{
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
Assert.Equal(new[] { quantity / 4, 1 }, applied.Select(p => p.Quantity));
Assert.Equal(new[] { 4, 1 }, applied.Select(p => p.Parts.Count));
Assert.Same(empty, applied[0]);
Assert.Same(existing, Assert.Single(occupied.Parts));
Assert.Equal(2, occupied.Quantity);
}
Assert.Equal(quantity + 2, drawing.Quantity.Nested);
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
var loaded = new NestReader(new MemoryStream(stream.ToArray())).Read();
Assert.Equal(new[] { 2, quantity / 4, 1 }, loaded.Plates.Select(p => p.Quantity));
Assert.Equal(quantity + 2, Assert.Single(loaded.Drawings).Quantity.Nested);
Assert.Equal(result.Raw.Plates.Count + 2, loaded.Plates.Sum(p => p.Quantity));
for (var i = 0; i < loaded.Plates.Count; i++)
{
Assert.Equal(nest.Plates[i].Size, loaded.Plates[i].Size);
Assert.Equal(nest.Plates[i].EdgeSpacing, loaded.Plates[i].EdgeSpacing);
Assert.Equal(nest.Plates[i].PartSpacing, loaded.Plates[i].PartSpacing);
Assert.Equal(nest.Plates[i].Quadrant, loaded.Plates[i].Quadrant);
Assert.Equal(nest.Plates[i].Parts.Select(p => (p.Location, p.Rotation)),
loaded.Plates[i].Parts.Select(p => (p.Location, p.Rotation)));
}
}
}
+13 -5
View File
@@ -6,9 +6,17 @@
2. Resolve the selected name through `NestingEngineRegistry` (built-ins and plug-ins use the same path).
3. Run the engine without mutating caller drawings or plates.
4. Check returned placements with the independent `NestLayoutCheck` used by the benchmark.
5. Bind a representable result back to the caller's drawing references; the caller decides whether to commit it.
5. Consolidate exact ordered nonempty layouts only when the entire result is valid, then bind each layout to the caller's drawing references; the caller decides whether to commit it.
`NestStockBuilder.FromTemplate` snapshots sheet options and spacing settings for multiple-sheet jobs. `SinglePlate` offers exactly one physical sheet, regardless of the legacy plate repeat quantity. `NestPipelineCommit.ApplyToEmptyPlates` applies accepted desktop proposals to empty/new plates with the checked size, quadrant, spacing and quantity-one semantics. It never fills occupied plates.
`NestStockBuilder.FromTemplate` snapshots sheet options and spacing settings for multiple-sheet jobs. `SinglePlate` offers exactly one physical sheet, regardless of the legacy plate repeat quantity. `NestPipelineCommit.ApplyToEmptyPlates` applies accepted desktop proposals to empty/new plates with the checked size, quadrant, spacing and total-copy quantity semantics. Quantity is set before parts are attached, so drawing counts multiply exactly once. It never fills occupied plates.
## Repeated layouts and physical sheets
Raw `NestJobResult` remains expanded: one entry per physical sheet, with physical stock usage and original instance indices. `NestPipelineResult.Raw` is not rewritten. Valid exact duplicates in the newly generated proposal become one `ProposedPlate` with positive `Quantity` (total copies, including the first; default one). Its three-field constructor/deconstruction remains supported. `PlateIndex` retains the first raw occurrence, and proposed layouts retain first-occurrence order.
Identity includes stock ID and exact stock settings plus the **ordered** requirement IDs and IEEE bits of X, Y and rotation. Instance indices do not affect grouping. Different same-name drawings, reordered parts, near-equal poses, signed zero differences, or different stock entries stay separate. No rounding, geometric equivalence or merging with existing occupied plates/cutting programs is performed. Empty sheets and all invalid-but-representable proposals stay ungrouped, preserving physical review indices; unknown requirements and nonfinite poses remain unkeepable. Drawing and bound-program freshness checks still apply to every grouped layout and its correspondence to the raw physical sheets.
Layout-entry count is not physical-sheet count: sum layout quantities for sheets, and multiply each layout's part counts, area, cutting time and costs by its quantity. `.nest` already stores `Plate.Quantity`; no file-format change is required. Exact output grouping does not itself remove engine searches.
## Desktop Auto Nest
@@ -50,9 +58,9 @@ MCP `autonest_plate` requires an empty target. The stdio server serializes all t
MCP `pack_plate` and `autonest_plate` report each requested drawing's newly placed count and remaining quantity, indexed by request; partial fulfillment is explicitly warned about. Per-sheet ratios are not enforced or required: an uneven sheet is acceptable if the remaining quantities are placed elsewhere in the nest. These two plate tools operate on a single selected plate and do not automatically finish a multi-sheet job; callers can use `autonest_job` for that. Duplicate request tokens and ambiguous drawing names are refused before placement. A zero-quantity pack request places zero parts for that drawing; negative quantities are rejected.
`autonest_job(plateIndex, drawingNames, quantities, engine?, no_new_plates=false, sheets?)` is the whole-session completion path. Quantities are total targets across existing placed parts (physical plate quantity included) plus newly proposed sheets. Without `sheets`, the selected empty cutoff-free plate supplies stock size, spacing, quadrant, grain and cutting settings; other empty plates with equivalent persisted settings may be reused first, then additional matching plates are created as needed. `no_new_plates=true` limits this legacy solve to existing matching empty plates. The request uses the independently validated `NestPipeline` and commits only when every requested quantity is met; invalid, incomplete, cancelled or unknown-engine proposals leave existing plates and counts unchanged. A no-placement solver result is not proof that geometry cannot fit. `pack_plate` and `autonest_plate` remain single-sheet operations.
`autonest_job(plateIndex, drawingNames, quantities, engine?, no_new_plates=false, sheets?)` is the whole-session completion path. Quantities are total targets across existing placed parts (physical plate quantity included) plus newly proposed sheets. Without `sheets`, the selected empty cutoff-free plate supplies stock size, spacing, quadrant, grain and cutting settings; other empty plates with equivalent persisted settings may be reused first, then additional matching plates are created as needed. `no_new_plates=true` caps physical stock by the number of existing matching empty plates, not the number of grouped output layouts or their old repeat quantities. Exact repeats still attach once with their total quantity; redundant empty entries remain unchanged and are omitted on save. Empty entries consumed by those physical copies are reserved by their populated layout in the live MCP session and cannot be offered again or filled by another nesting tool while that owner remains populated. Clearing or removing the owner (including replacement by `load_nest`) releases only its surviving placeholders; clearing or deleting a placeholder does not release the owner's other copies or another layout's reservations. Genuinely unused entries remain available. No automatic deletion of user plates occurs. No new layout entries are created in this mode. The request uses the independently validated `NestPipeline` and commits only when every requested quantity is met; invalid, incomplete, cancelled or unknown-engine proposals leave existing plates and counts unchanged. A no-placement solver result is not proof that geometry cannot fit. `pack_plate` and `autonest_plate` remain single-sheet operations.
For finite mixed-size inventory, supply `sheets` as an array, for example `{"plateIndex":0,"drawingNames":"large,small","quantities":"1,1","engine":"Rectangles","sheets":[{"width":60,"length":120,"quantity":1},{"width":48,"length":96,"quantity":2}]}`. Width and length have the same units and orientation as `create_plate`. Every row's positive integer `quantity` is the total physical sheets available for this solve, including any compatible pre-created empty plates; it is never a `Plate.Quantity` repeat multiplier or additional stock. The selected plate supplies common spacing, edge spacing, quadrant, grain and cutting settings, but is reused only if its size matches a chosen sheet. Other compatible empty sheets can be reused; occupied or cutoff-bearing sheets cannot. Only needed different-sized sheets are created. Duplicate size pairs, nonpositive or nonfinite dimensions, unusable work areas, nonpositive quantities, an empty list and excessive total inventory are refused before solving. `sheets` cannot be combined with `no_new_plates=true`. Omit `sheets` to retain the previous single-size behavior. Successful output reports `stock-0`, `stock-1`, etc. in input order with used/remaining/reused/created physical counts, plus committed session plate index to stock ID and per-drawing totals. Stock is request-local, not a persistent purchasing catalog. Unused empty templates may be omitted by the `.nest` writer on save.
For finite mixed-size inventory, supply `sheets` as an array, for example `{"plateIndex":0,"drawingNames":"large,small","quantities":"1,1","engine":"Rectangles","sheets":[{"width":60,"length":120,"quantity":1},{"width":48,"length":96,"quantity":2}]}`. Width and length have the same units and orientation as `create_plate`. Every row's positive integer `quantity` is the total physical sheets available for this solve, including any compatible pre-created empty plates; it is never a `Plate.Quantity` repeat multiplier or additional stock. The selected plate supplies common spacing, edge spacing, quadrant, grain and cutting settings, but is reused only if its size matches a chosen sheet. Other compatible empty sheets can be reused; occupied or cutoff-bearing sheets cannot. Only needed different-sized sheets are created. Duplicate size pairs, nonpositive or nonfinite dimensions, unusable work areas, nonpositive quantities, an empty list and excessive total inventory are refused before solving. `sheets` cannot be combined with `no_new_plates=true`. Omit `sheets` to retain the previous single-size behavior. Successful output reports `stock-0`, `stock-1`, etc. in input order with physical used/remaining counts, reused/created layout counts, plus committed session layout index to stock ID and per-drawing totals. Stock is request-local, not a persistent purchasing catalog. Unused empty templates may be omitted by the `.nest` writer on save.
MCP `delete_plate(plateIndex)` removes only a sheet with no parts and no cutoff definitions, from either loaded or newly created session plates. Occupied, cutoff-bearing and invalid indices are refused without changing the session. After deletion every later index shifts down by one; inspect `get_plate_info` before further edits. This is session cleanup, not a data purge or a save-time option. `save_nest` lists all session plates in its response, but the existing `.nest` writer omits empty cutoff-free plates from the serialized file; reloading may therefore have fewer plates than the response says. There is no opt-in switch to preserve empty plates in the file.
@@ -93,7 +101,7 @@ Set `NestRequest.Engine` to a registered engine name; null retains `PlacementStr
The API returns a detached proposal. `ValidationStatus` is `Valid`, `Invalid`, or `Unrepresentable`, and `Violations` lists the problems. Representable invalid proposals retain their parts for caller review; unrepresentable proposals contain no sheets. Consumers must inspect validation before applying, quoting or posting the proposal. `Status` describes fulfillment, not acceptance: counts, stock usage and completeness are derived from returned placements rather than trusting plug-in summary metadata.
Response archive schema 3 persists validation status and violations. Older archives with no validation metadata load with null status; null must not be interpreted as a successful check. Saving a proposal archive records it and does not authorize CNC posting.
Response archive schema 4 records grouped-layout semantics. `SheetCount` is the physical sum of `Nest.Plates[*].Quantity`; fulfillment, stock usage, utilization (part area / physical sheet area), cutting time and costs are quantity-weighted. `PlateStockMappings[*].PlateIndex` indexes the actual materialized `Nest.Plates` layout entries, so mappings may be fewer than `SheetCount`. Schema 3 and unversioned archives still load unchanged: no regrouping or metadata reinterpretation occurs. Validation status and violations remain persisted; older archives without validation metadata load with null status, which is not a successful check. Saving a proposal archive does not authorize CNC posting.
## Verification
+30
View File
@@ -67,6 +67,36 @@ or invalid Fill proposal leaves singles and pairs available. Large enclosed-pock
pending the hole-geometry integration; containment cutting order and shop-use safety acceptance
remain separate sequencer/verification work.
## Repeating completed layouts
Rectangles and Irregular can skip repeated sheet searches after selecting one nonempty layout
whose normal sheet search has completed. The repeat count includes the first physical copy and
is limited by the scarcest requirement on that layout, remaining finite stock and the remaining
physical `MaxPlates` allowance. Parts absent from the layout keep their demand. A salvage-worthy
offcut does not prevent repetition: completion means the engine found no further supported
requested placement, not that no geometric packing exists.
Fast-forwarding applies only while exactly one stock entry remains available. Different stock
IDs count as alternatives even at identical dimensions. Irregular's forced-first-stock tail trial
still counts **all** remaining stock entries, not just that trial's restricted shortlist; it commits
the forced first sheet once when alternatives remain and resumes ordinary stock selection.
Candidates that cannot pass the existing candidate validator retain ordinary one-sheet allocation.
Cancellation never certifies a layout and is checked during physical-copy expansion.
Both engines continue automatically with residual demand in the same solve, including when a
layout's repeat count is one. There is no per-layout stop, confirmation or opt-in mode. Rectangles
retains both Global/Ordered packing modes and its priority guard. Irregular retains demand-dependent
singles, pairs and blocks, variant ordering and its existing work-budget semantics; saved work can
allow additional variants to run, so this is not a promise of historical pose equality.
Raw `NestJobResult` output remains one entry per physical sheet, with fresh placements and unique
instance indices, physical stock usage and physical progress counts. Irregular also retains each
physical copy in its working plan: its last-one-to-three-sheet improvement can return the removed
tail's finite stock, repack that demand, and replace just those copies while keeping the prefix.
Default inherits these fast paths by running the two engines; other engines and external plug-ins
keep their existing search behavior. Quantity-bearing presentation is a separate validated-pipeline
representation; repeating does not bypass whole-job validation or change cutting programs.
## Filling cutouts (not yet in production)
Placing parts inside another part's enclosed cutout is being built as a step that runs before any