Files
OpenNest/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs
T
ajandClaude Opus 5.5 ccd361dfa9 test(engine): pin Fill-stack layouts on irregular parts
Characterization digests for the interactive PlateFillService surface and
the fixed-strategy whole-job engines on L-shaped, notched, arc, bar and
chevron parts: single-item fills, whole-plate nests (including one where
learned angles change the layout, guarded by a full-sweep comparison),
fills beside clamp zones, and one job per Fill/Strip/remnant engine.
They let the planned filler/nester consolidation be shown layout-neutral.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:07:20 -04:00

414 lines
15 KiB
C#

using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.Jobs.Placement.Fillers;
using OpenNest.Engine.Tests.NestingEngines;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Characterization pins for the Fill/fixed-strategy placement stack on irregular parts: the
/// interactive <see cref="PlateFillService"/> surface (single-item fill, whole-plate nest, clamp
/// refills) and one whole job per <see cref="FixedStrategyNestingEngine"/>. Each case pins the
/// sorted poses (part, X, Y, rotation rounded to 1e-6) as a count plus SHA-256 digest; a failure
/// prints the actual layout. They exist to prove filler/nester refactors layout-neutral, so a
/// changed digest is a layout change, never a value to re-capture without explaining it.
/// </summary>
/// <remarks>
/// Captured on Windows/.NET 8 without the optional ML angle model, so irregular parts take the
/// 5-degree sweep; each case gave one layout over 5 test processes of 2 solves each, and every
/// case solves twice in-process and must agree. The whole-plate Fill nests fill several
/// irregular drawings with one filler, so later items use the angles
/// <c>AngleCandidateBuilder.RecordProductive</c> learned from earlier ones.
/// Not pinned: richer mixes that pair parallelogram, chevron or tee parts at oblique angles
/// with other irregular items. Their Fill and Vertical Remnant nests vary between runs, most
/// likely because best-fit pair candidates are gathered in a <c>ConcurrentBag</c> and equal
/// scores keep that arbitrary order.
/// </remarks>
public class FillStackCharacterizationTests
{
private static Program LShape()
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(6, 0);
program.LineTo(6, 4);
program.LineTo(3, 4);
program.LineTo(3, 2);
program.LineTo(0, 2);
program.LineTo(0, 0);
return program;
}
/// <summary>6x4 block with a 2x2 notch cut into the middle of its top edge.</summary>
private static Program Notched()
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(6, 0);
program.LineTo(6, 4);
program.LineTo(4, 4);
program.LineTo(4, 2);
program.LineTo(2, 2);
program.LineTo(2, 4);
program.LineTo(0, 4);
program.LineTo(0, 0);
return program;
}
/// <summary>D-shape: 3x5 block closed by a semicircular arc.</summary>
private static Program ArcPart()
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(3, 0);
program.ArcTo(3, 5, 3, 2.5, RotationType.CCW);
program.LineTo(0, 5);
program.LineTo(0, 0);
return program;
}
/// <summary>
/// 46 x 3 bar with a stepped end: irregular, and too long for the 48 x 28 work area at all
/// but the sweep angles near 0 and 180 degrees, so only those prove productive and get learned.
/// </summary>
private static Program LongBar()
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(46, 0);
program.LineTo(46, 3);
program.LineTo(42, 3);
program.LineTo(42, 1.5);
program.LineTo(0, 1.5);
program.LineTo(0, 0);
return program;
}
/// <summary>
/// 6 x 4 chevron. Filled after <see cref="LongBar"/>, its layout differs between the learned
/// (pruned) angle list and a full sweep.
/// </summary>
private static Program Chevron()
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(3, 2);
program.LineTo(6, 0);
program.LineTo(6, 2);
program.LineTo(3, 4);
program.LineTo(0, 2);
program.LineTo(0, 0);
return program;
}
private static Program Shape(string name) =>
name switch
{
"lshape" => LShape(),
"notched" => Notched(),
"arc" => ArcPart(),
"bar" => LongBar(),
"chevron" => Chevron(),
_ => TestDrawingFactory.Rectangle(4, 3),
};
private static Plate MakePlate()
{
var plate = new Plate(new Size(30, 50))
{
EdgeSpacing = new Spacing(1, 1, 1, 1),
PartSpacing = 0.5,
};
return plate;
}
/// <summary>Two front-edge clamps (all on one edge, so the trimmed-strip layout competes).</summary>
private static Plate MakeClampedPlate()
{
var plate = MakePlate();
plate.ClampZones.Add(new ClampZone("front-1", new Box(12, 0, 2, 1.5)));
plate.ClampZones.Add(new ClampZone("front-2", new Box(32, 0, 2, 1.5)));
return plate;
}
// --- pose digests ---
private static string FormatPose(string id, double x, double y, double rotation)
{
var twoPi = System.Math.PI * 2;
var angle = rotation % twoPi;
if (angle < 0)
angle += twoPi;
if (twoPi - angle < 1e-9)
angle = 0;
return string.Format(
CultureInfo.InvariantCulture,
"{0}@({1},{2},{3})",
id,
Round(x),
Round(y),
Round(angle)
);
}
// Normalizes negative zero so rounding never splits one pose into two spellings.
private static string Round(double value) =>
(System.Math.Round(value, 6) + 0.0).ToString("F6", CultureInfo.InvariantCulture);
private static string Layout(IEnumerable<Part> parts) =>
string.Join(
"\n",
parts
.Select(p => FormatPose(p.BaseDrawing.Name, p.Location.X, p.Location.Y, p.Rotation))
.Order(StringComparer.Ordinal)
);
private static string Layout(NestJobResult result) =>
string.Join(
"\n",
result
.Plates.SelectMany(plate =>
plate.Placements.Select(p =>
$"{plate.PlateIndex}:{p.InstanceIndex}:"
+ FormatPose(p.PartId, p.X, p.Y, p.Rotation)
)
)
.Order(StringComparer.Ordinal)
);
private static string Digest(string layout) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(layout)))[..16];
/// <summary>Runs <paramref name="solve"/> twice and requires identical layouts.</summary>
private static string Stable(Func<string> solve)
{
var first = solve();
var second = solve();
Assert.True(
first == second,
$"layout must be deterministic across repeat runs:\n{first}\n---\n{second}"
);
return first;
}
private static void AssertPinned(string layout, int expectedCount, string expectedDigest)
{
var count = layout.Split('\n').Count(line => line.Contains('@'));
var digest = Digest(layout);
Assert.True(
count == expectedCount && digest == expectedDigest,
$"layout changed: expected {expectedCount} parts digest {expectedDigest}, "
+ $"actual {count} parts digest {digest}\n{layout}"
);
}
// --- interactive single-item fill ---
[Theory]
[InlineData("Fill", "lshape", 52, "F1FB09254D935531")]
[InlineData("Fill", "notched", 46, "32FB33C3C19FDCC8")]
[InlineData("Fill", "arc", 40, "4E2F91AA3151E9E7")]
[InlineData("Strip", "lshape", 52, "F1FB09254D935531")]
[InlineData("Strip", "notched", 46, "32FB33C3C19FDCC8")]
[InlineData("Strip", "arc", 40, "4E2F91AA3151E9E7")]
[InlineData("Vertical Remnant", "lshape", 52, "F1FB09254D935531")]
[InlineData("Vertical Remnant", "notched", 46, "32FB33C3C19FDCC8")]
[InlineData("Vertical Remnant", "arc", 40, "BBC5C583C3C7A9A7")]
[InlineData("Horizontal Remnant", "lshape", 52, "F1FB09254D935531")]
[InlineData("Horizontal Remnant", "notched", 46, "32FB33C3C19FDCC8")]
[InlineData("Horizontal Remnant", "arc", 40, "4E2F91AA3151E9E7")]
public void FillItem_IrregularPart_PinnedLayout(
string strategy,
string shape,
int expectedCount,
string expectedDigest
)
{
var layout = Stable(() =>
{
var plate = MakePlate();
var item = new NestItem { Drawing = new Drawing(shape, Shape(shape)), Quantity = 0 };
return Layout(
PlateFillService.FillItem(
strategy,
plate,
item,
plate.WorkArea(),
null,
CancellationToken.None
)
);
});
AssertPinned(layout, expectedCount, expectedDigest);
}
// --- interactive whole-plate nest ---
/// <summary>
/// Three irregular fill items (each over 10% of the plate) plus a single rectangle that
/// takes the pack path. Strip runs the iterative shrink fill on the multi-quantity items.
/// </summary>
private static List<NestItem> MixedItems() =>
[
new() { Drawing = new Drawing("lshape", LShape()), Quantity = 14 },
new() { Drawing = new Drawing("notched", Notched()), Quantity = 9 },
new() { Drawing = new Drawing("arc", ArcPart()), Quantity = 11 },
new() { Drawing = new Drawing("rect", TestDrawingFactory.Rectangle(4, 3)), Quantity = 1 },
];
/// <summary>
/// The bar fills first and is productive only near 0 and 180 degrees, so the Fill filler
/// prunes the chevron and notched fills to the angles it learned (see
/// <see cref="Nest_LearnedAngleItems_DependsOnLearnedAngles"/>).
/// </summary>
private static List<NestItem> LearnedAngleItems() =>
[
new() { Drawing = new Drawing("bar", LongBar()), Quantity = 3 },
new() { Drawing = new Drawing("chevron", Chevron()), Quantity = 20 },
new() { Drawing = new Drawing("notched", Notched()), Quantity = 6 },
new() { Drawing = new Drawing("rect", TestDrawingFactory.Rectangle(4, 3)), Quantity = 1 },
];
private static string NestLayout(string strategy, Func<List<NestItem>> createItems)
{
var plate = MakePlate();
var items = createItems();
var parts = PlateFillService.Nest(strategy, plate, items, null, CancellationToken.None);
// Remaining demand is part of the contract callers read back.
var remaining = string.Join(",", items.Select(i => $"{i.Drawing.Name}={i.Quantity}"));
return Layout(parts) + "\nremaining:" + remaining;
}
[Theory]
[InlineData("Fill", 33, "5F41788D261A5085")]
[InlineData("Strip", 35, "865B16D4365A2492")]
[InlineData("Vertical Remnant", 35, "8CC17C7708C8049F")]
[InlineData("Horizontal Remnant", 30, "A0700052AE771E06")]
public void Nest_MixedIrregularItems_PinnedLayout(
string strategy,
int expectedCount,
string expectedDigest
)
{
var layout = Stable(() => NestLayout(strategy, MixedItems));
AssertPinned(layout, expectedCount, expectedDigest);
}
[Theory]
[InlineData("Fill", 30, "5732030365D13B9A")]
[InlineData("Strip", 30, "A573BA6919FCD300")]
[InlineData("Vertical Remnant", 30, "5732030365D13B9A")]
[InlineData("Horizontal Remnant", 30, "C05F6A30A648B929")]
public void Nest_LearnedAngleItems_PinnedLayout(
string strategy,
int expectedCount,
string expectedDigest
)
{
var layout = Stable(() => NestLayout(strategy, LearnedAngleItems));
AssertPinned(layout, expectedCount, expectedDigest);
}
/// <summary>
/// Keeps <see cref="Nest_LearnedAngleItems_PinnedLayout"/> sensitive to learned angles: the
/// Fill layout must differ from the same nest with a full sweep for every item. If this
/// fails, the fixture no longer catches a filler that forgets learned angles between items.
/// </summary>
[Fact]
public void Nest_LearnedAngleItems_DependsOnLearnedAngles()
{
string Solve(bool fullSweep)
{
var filler = new DefaultPlateFiller(MakePlate()) { ForceFullAngleSweep = fullSweep };
return Layout(filler.Nest(LearnedAngleItems(), null, CancellationToken.None));
}
Assert.NotEqual(Solve(fullSweep: true), Solve(fullSweep: false));
}
// --- interactive fill beside clamps ---
[Theory]
[InlineData("Fill", 40, "9BF5FC7FC47364A9")]
[InlineData("Strip", 40, "9BF5FC7FC47364A9")]
[InlineData("Vertical Remnant", 40, "9BF5FC7FC47364A9")]
[InlineData("Horizontal Remnant", 40, "C1E81564A620302A")]
public void FillItem_WithClampZones_PinnedLayout(
string strategy,
int expectedCount,
string expectedDigest
)
{
var layout = Stable(() =>
{
var plate = MakeClampedPlate();
// A quantity close to a full sheet makes clamp-dropped copies trigger refills.
var item = new NestItem { Drawing = new Drawing("lshape", LShape()), Quantity = 40 };
return Layout(
PlateFillService.FillItem(
strategy,
plate,
item,
plate.WorkArea(),
null,
CancellationToken.None
)
);
});
AssertPinned(layout, expectedCount, expectedDigest);
}
// --- whole-job fixed strategies ---
private static NestJobPart JobPart(string id, int quantity) =>
new(id, PartGeometrySnapshot.FromProgram(Shape(id)), quantity);
private static NestJob IrregularJob() =>
new(
new[]
{
JobPart("lshape", 10),
JobPart("notched", 6),
JobPart("arc", 7),
JobPart("rect", 3),
},
new[] { new NestPlateStock("stock", new Size(30, 50), 3, 0.5, new Spacing(1, 1, 1, 1)) },
new NestJobOptions("Fill")
);
[Theory]
[InlineData("Fill", 26, "64CE33B01B205978")]
[InlineData("Strip", 26, "A66602F707290A36")]
[InlineData("Vertical Remnant", 26, "BDCA1DA111B396F0")]
[InlineData("Horizontal Remnant", 26, "C356EC216CD56627")]
public void FixedStrategyEngine_IrregularJob_PinnedLayout(
string strategy,
int expectedCount,
string expectedDigest
)
{
var layout = Stable(() =>
{
var job = IrregularJob();
var result = new FixedStrategyNestingEngine(strategy).Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
// Not LayoutAssert.Valid: today the Fill and Vertical Remnant layouts of this job
// fail NestLayoutCheck's arc-to-arc spacing check on an interlocked arc pair, though
// the runner's placement validator accepts them. These tests pin current behavior.
return Layout(result);
});
AssertPinned(layout, expectedCount, expectedDigest);
}
}