mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 08:21:05 -04:00
ZeroClampMixedShapeLayoutIsBitIdenticalToBaseline has failed on Windows since it was added inde9a087while passing on Linux CI (bisect pointing atc374e02was an artifact: the assumed-good start commit fails too). No commit changed the layout; the two platforms always chose differently. In that fixture two candidate sheets (ContactPoint/Global and BestShortSide/Ordered) both place all 30 parts at the same cost, so Choose falls to its "more material placed" tie-break. SheetPacker summed material area in placement order, and the disc's area differs in the last bit between the Windows CRT and glibc math libraries. On Linux the two orders rounded to ...253 vs ...257, so the later candidate won on rounding noise; on Windows both rounded to ...257 and the tie fell to enumeration order as Choose documents. Sum a plan's area per type in type order instead (the same form as the engine's demand area), so plans placing the same parts get bit-identical areas and exact ties keep enumeration order everywhere. No tolerance is loosened. The Windows layout is unchanged; Linux now matches it, so the frozen hash moves to the Windows value, verified identical on both. Verified: full Engine.Tests pass on Windows and Linux (1105 passed, 2 skipped); Rectangles and Default benchmark results on the six synthetic nests are identical before and after (validity, placed parts, cost, utilization, plates). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
308 lines
14 KiB
C#
308 lines
14 KiB
C#
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Geometry;
|
|
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
|
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
|
|
|
namespace OpenNest.Engine.Tests.NestingEngines;
|
|
|
|
/// <summary>
|
|
/// The Rectangles lane treats clamp zones as hard obstacles: placements never enter
|
|
/// the keep-out, demand uses the material between clamps, and layouts with zero
|
|
/// clamps stay bit-identical to the pre-clamp baseline (C2). Irregular and Default
|
|
/// parity is covered separately by IrregularClampZoneTests.
|
|
/// </summary>
|
|
public class RectanglesClampZoneTests
|
|
{
|
|
private static OpenNest.Engine.NestingEngines.Rectangles.RectanglesNestingEngine Engine() => new();
|
|
|
|
// ---- C2 characterization: zero-clamp layouts must not shift by a single bit -------
|
|
|
|
private static string LayoutHash(NestJobResult result)
|
|
{
|
|
var sb = new StringBuilder();
|
|
foreach (var sheet in result.Plates)
|
|
foreach (var p in sheet.Placements)
|
|
sb.Append(sheet.PlateIndex).Append('|').Append(sheet.StockId).Append('|')
|
|
.Append(p.PartId).Append('|')
|
|
.Append(p.X.ToString("R", CultureInfo.InvariantCulture)).Append('|')
|
|
.Append(p.Y.ToString("R", CultureInfo.InvariantCulture)).Append('|')
|
|
.Append(p.Rotation.ToString("R", CultureInfo.InvariantCulture))
|
|
.Append('\n');
|
|
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())));
|
|
}
|
|
|
|
[Theory]
|
|
// Pre-clamp baseline hashes, captured from the unchanged packer at commit a3f1124.
|
|
[InlineData("1FA61E933D2D0D6F1E3336FCEBBB21CEB7E7D1533FDB253F9D48E947217575CE")]
|
|
public void ZeroClampLayoutIsBitIdenticalToBaseline(string hash) =>
|
|
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
|
|
new[] { Part("rect", Rectangle(10, 5), 12) },
|
|
new[] { Stock("sheet", 48, 96, spacing: 0.25) }))));
|
|
|
|
[Theory]
|
|
// Two complete candidates tie here on cost and placed material; the tie must fall to
|
|
// enumeration order on every platform (hash identical on Windows and Linux).
|
|
[InlineData("BEEEDEC81A7F987720FB5F331873103AD2470C839D568CE587F230BC39D210CC")]
|
|
public void ZeroClampMixedShapeLayoutIsBitIdenticalToBaseline(string hash) =>
|
|
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
|
|
new[]
|
|
{
|
|
Part("disc", Disc(3), 10),
|
|
Part("ell", LShape(12, 8, 4), 10),
|
|
Part("tri", Triangle(9, 6), 10),
|
|
},
|
|
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5)) }))));
|
|
|
|
[Theory]
|
|
[InlineData("DBF93C1684D85B5EE7EB24C96A84904D2BA0F9A8183631EFC61638CB8B9B935A")]
|
|
public void ZeroClampMultiSheetLayoutIsBitIdenticalToBaseline(string hash) =>
|
|
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
|
|
new[] { Part("square", Rectangle(10, 10), 30) },
|
|
new[] { Stock("sheet", 25, 45, spacing: 0.25) }))));
|
|
|
|
// ---- clamp avoidance ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void PartsNeverTouchAClampZone()
|
|
{
|
|
var clamp = new ClampZone("front-center", new Box(19.25, 0, 1.5, 0.625));
|
|
var job = Job(
|
|
new[] { Rectangle("blk", 6, 6, 6) },
|
|
new[] { Stock("sheet", 20, 40, spacing: 0.5, clampZones: [clamp]) });
|
|
|
|
var result = Engine().Solve(job);
|
|
|
|
LayoutAssert.Valid(job, result);
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
foreach (var placement in result.Plates.SelectMany(p => p.Placements))
|
|
{
|
|
// Fixed 0-degree rectangles: analytic independent oracle against the raw zone.
|
|
Assert.False(
|
|
placement.X < 20.75 && placement.X + 6 > 19.25 &&
|
|
placement.Y < 0.625 && placement.Y + 6 > 0,
|
|
$"placement at ({placement.X},{placement.Y}) intrudes into the clamp zone");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void DemandLandsBetweenClampsWhenItFits()
|
|
{
|
|
// One row of three 2x2 parts; only [0,3], [5,7] and [9,12] are open along X.
|
|
var clamps = new[]
|
|
{
|
|
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
|
|
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
|
|
};
|
|
var job = Job(
|
|
new[] { Rectangle("row", 2, 2, 3) },
|
|
new[] { Stock("strip", 2, 12, clampZones: clamps) });
|
|
|
|
var result = Engine().Solve(job);
|
|
|
|
LayoutAssert.Valid(job, result);
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
var placements = result.Plates.SelectMany(p => p.Placements).ToList();
|
|
Assert.Equal(3, placements.Count);
|
|
Assert.Contains(placements, p => System.Math.Abs(p.X - 5) < 1e-9); // the middle slot
|
|
}
|
|
|
|
[Fact]
|
|
public void ClampWallAcrossTheFrontEdgeBlocksTheOnlyViableRow()
|
|
{
|
|
// Without clamps the single viable row sits at y = bottom spacing (1.0). The full-width
|
|
// front clamp with its edge clearance raises that floor above the work area.
|
|
var wall = new ClampZone("rail-wall", new Box(0, 0, 10, 1));
|
|
var job = Job(
|
|
new[] { Rectangle("tall", 4, 4, 2) },
|
|
new[]
|
|
{
|
|
Stock("sheet", 6, 10, spacing: 0,
|
|
edge: new Spacing(left: 0.25, bottom: 1, right: 0.25, top: 0.25),
|
|
clampZones: [wall]),
|
|
});
|
|
|
|
var result = Engine().Solve(job);
|
|
|
|
LayoutAssert.Valid(job, result);
|
|
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
|
Assert.Empty(result.Plates.SelectMany(p => p.Placements));
|
|
}
|
|
|
|
[Fact]
|
|
public void ZonesClearOfTheWorkAreaNeedNotShiftAnything()
|
|
{
|
|
// Top-edge zone the demand never reaches: layout stays identical to no clamps.
|
|
var far = new ClampZone("back", new Box(0, 47.5, 1.5, 0.5));
|
|
var jobClamped = Job(
|
|
new[] { Part("rect", Rectangle(10, 5), 12) },
|
|
new[] { Stock("sheet", 48, 96, spacing: 0.25, clampZones: [far]) });
|
|
var jobPlain = Job(
|
|
new[] { Part("rect", Rectangle(10, 5), 12) },
|
|
new[] { Stock("sheet", 48, 96, spacing: 0.25) });
|
|
|
|
var clamped = Engine().Solve(jobClamped);
|
|
var plain = Engine().Solve(jobPlain);
|
|
|
|
LayoutAssert.Valid(jobClamped, clamped);
|
|
Assert.Equal(LayoutHash(plain), LayoutHash(clamped));
|
|
}
|
|
|
|
// ---- large part spacing vs shallow clamp spans (PM d86d22c9, decision D11) --------
|
|
//
|
|
// The packer reserves a trailing part-spacing tail behind every item, so the block it
|
|
// carves per axis is [zone - clearance + s, zone + clearance]. When s exceeds the
|
|
// inflated span, that interval inverts. D11: block a zero-extent line at the clamp
|
|
// edge facing the sheet interior — at (zone max + clearance) when the zone center lies
|
|
// in the lower half of the bin on that axis, else at (zone min - clearance + s). The
|
|
// old collapse to the zone centerline let a part start inside the clamp.
|
|
//
|
|
// The oracle below is independent of the packer and of NestLayoutCheck: material
|
|
// [x, x+w] x [y, y+h] must not share interior with the clamp footprint predicted
|
|
// directly from the rail rule (center +/- width/2 from the origin edge, depth into the
|
|
// sheet, translated exactly like the work area), inflated by the bottom-edge spacing.
|
|
// Boundary contact is clear (C16).
|
|
|
|
private static Box InflatedRail(int quadrant, double length, double width,
|
|
double center, double clampWidth, double depth, double clearance)
|
|
{
|
|
var left = quadrant is 1 or 4 ? 0.0 : -length;
|
|
var bottom = quadrant is 1 or 2 ? 0.0 : -width;
|
|
var near = center - clampWidth / 2 + left;
|
|
var far = center + clampWidth / 2 + left;
|
|
return new Box(
|
|
System.Math.Min(near, far) - clearance,
|
|
bottom - clearance,
|
|
clampWidth + 2 * clearance,
|
|
depth + 2 * clearance);
|
|
}
|
|
|
|
private static void AssertMaterialClearOfRail(NestJobResult result, Box inflated,
|
|
double partW, double partH)
|
|
{
|
|
Assert.NotEmpty(result.Plates.SelectMany(p => p.Placements));
|
|
foreach (var p in result.Plates.SelectMany(pl => pl.Placements))
|
|
{
|
|
var overlaps = p.X < inflated.Right - 1e-9 && p.X + partW > inflated.Left + 1e-9
|
|
&& p.Y < inflated.Top - 1e-9 && p.Y + partH > inflated.Bottom + 1e-9;
|
|
Assert.False(overlaps,
|
|
$"material [{p.X},{p.X + partW}] x [{p.Y},{p.Y + partH}] intrudes into the " +
|
|
$"inflated clamp footprint [{inflated.Left},{inflated.Right}] x " +
|
|
$"[{inflated.Bottom},{inflated.Top}]");
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
// PM d86d22c9 probe fixture: Y degenerate (s=4 > 0.625 + 2x0.25), full-width front
|
|
// rail. Red on ab50900: Rectangles completed at y=0.3125, material on the clamp.
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
[InlineData(3)]
|
|
[InlineData(4)]
|
|
public void DegenerateYSpanKeepsExactClearanceInEveryQuadrant(int quadrant)
|
|
{
|
|
var stock = Stock("sheet", 4, 5, spacing: 4, edge: new Spacing(0, 0.25, 0, 0),
|
|
quadrant: quadrant, clampZones: [new ClampZone("rail", new Box(0, 0, 5, 0.625))]);
|
|
var job = Job(new[] { Rectangle("square", 2, 2, 1) }, new[] { stock });
|
|
|
|
var result = Engine().Solve(job);
|
|
|
|
LayoutAssert.Valid(job, result);
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
AssertMaterialClearOfRail(result,
|
|
InflatedRail(quadrant, 5, 4, 2.5, 5, 0.625, 0.25), 2, 2);
|
|
}
|
|
|
|
[Fact]
|
|
public void DegenerateXSpanMidRailKeepsExactClearance()
|
|
{
|
|
// X degenerate (s=3 > 1.5 + 2x0.25); the 10-wide part is forced across the clamp's
|
|
// X span because neither side can hold it with the tail. Red on ab50900: completed
|
|
// at x=11.5 (the centerline), material inside the clamp.
|
|
var stock = Stock("sheet", 4, 23, spacing: 3, edge: new Spacing(0, 0.25, 0, 0),
|
|
clampZones: [new ClampZone("mid-rail", new Box(10.75, 0, 1.5, 0.625))]);
|
|
var job = Job(new[] { Rectangle("wide", 10, 2, 1) }, new[] { stock });
|
|
|
|
var result = Engine().Solve(job);
|
|
|
|
LayoutAssert.Valid(job, result);
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
AssertMaterialClearOfRail(result, InflatedRail(1, 23, 4, 11.5, 1.5, 0.625, 0.25), 10, 2);
|
|
}
|
|
|
|
[Fact]
|
|
public void DegenerateXSpanFarRailClearsTheInteriorFacingEdge()
|
|
{
|
|
// The clamp center sits in the upper half of the bin, so D11 blocks the
|
|
// origin-facing edge (zone min - clearance + s = 20). Red on ab50900: the
|
|
// centerline split let the 2x2 land at x=21, material inside the clamp;
|
|
// after D11 it lands at x=20 — boundary contact, which is clear.
|
|
var stock = Stock("sheet", 4, 23, spacing: 3, edge: new Spacing(0, 0.25, 0, 0),
|
|
clampZones: [new ClampZone("far-rail", new Box(20.25, 0, 1.5, 0.625))]);
|
|
var job = Job(new[] { Rectangle("small", 2, 2, 1) }, new[] { stock });
|
|
|
|
var result = Engine().Solve(job);
|
|
|
|
LayoutAssert.Valid(job, result);
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
AssertMaterialClearOfRail(result, InflatedRail(1, 23, 4, 21, 1.5, 0.625, 0.25), 2, 2);
|
|
}
|
|
|
|
// ---- C15 keep rows: non-inverted spans and between-clamp packing stay bit-exact ----
|
|
|
|
private static NestJobResult KeepFixtureLargeSpacingNonInverted() => Engine().Solve(Job(
|
|
new[] { Rectangle("blk", 6, 6, 6) },
|
|
new[] { Stock("sheet", 20, 40, spacing: 0.5, clampZones: [new ClampZone("front-center", new Box(19.25, 0, 1.5, 0.625))]) }));
|
|
|
|
private static NestJobResult KeepFixtureBetweenClamps() => Engine().Solve(Job(
|
|
new[] { Rectangle("row", 2, 2, 3) },
|
|
new[] { Stock("strip", 2, 12, clampZones: new[]
|
|
{
|
|
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
|
|
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
|
|
}) }));
|
|
|
|
private static NestJobResult KeepFixtureBetweenClampsWithSpacing() => Engine().Solve(Job(
|
|
new[] { Rectangle("row", 2, 2, 3) },
|
|
new[] { Stock("strip", 2, 12, spacing: 0.5, clampZones: new[]
|
|
{
|
|
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
|
|
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
|
|
}) }));
|
|
|
|
private static NestJobResult KeepFixtureMidRailSmallSpacing() => Engine().Solve(Job(
|
|
new[] { Rectangle("wide", 10, 2, 2) },
|
|
new[] { Stock("sheet", 4, 23, spacing: 0.5, edge: new Spacing(0, 0.25, 0, 0),
|
|
clampZones: [new ClampZone("mid-rail", new Box(10.75, 0, 1.5, 0.625))]) }));
|
|
|
|
[Theory]
|
|
// Captured from the unchanged packer at ab50900 (see progress task-6.2 evidence):
|
|
// non-inverted clamp spans must keep today's blocks and layouts bit-for-bit.
|
|
[InlineData("KEEP_LARGE_SPACING")]
|
|
[InlineData("KEEP_BETWEEN_CLAMPS")]
|
|
[InlineData("KEEP_BETWEEN_SPACED")]
|
|
[InlineData("KEEP_MID_RAIL_SMALL_SPACING")]
|
|
public void NonInvertedClampLayoutsMatchAb50900Digests(string fixture)
|
|
{
|
|
var hash = fixture switch
|
|
{
|
|
"KEEP_LARGE_SPACING" => "F7E4691C085FFB2164CEFFFB9B082B5F271E56E8B8B3D2AE23ECF5646FD40450",
|
|
"KEEP_BETWEEN_CLAMPS" => "F09B878211EB7C49649647003410B20191F635E3CD8DED6E03BEA5BD63B17D61",
|
|
"KEEP_BETWEEN_SPACED" => "F09B878211EB7C49649647003410B20191F635E3CD8DED6E03BEA5BD63B17D61",
|
|
"KEEP_MID_RAIL_SMALL_SPACING" => "97868486E834CA0B1DB110FF717635E38537DB96DF5CDFBB2AFE37E4B1366B71",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(fixture)),
|
|
};
|
|
var actual = fixture switch
|
|
{
|
|
"KEEP_LARGE_SPACING" => LayoutHash(KeepFixtureLargeSpacingNonInverted()),
|
|
"KEEP_BETWEEN_CLAMPS" => LayoutHash(KeepFixtureBetweenClamps()),
|
|
"KEEP_BETWEEN_SPACED" => LayoutHash(KeepFixtureBetweenClampsWithSpacing()),
|
|
"KEEP_MID_RAIL_SMALL_SPACING" => LayoutHash(KeepFixtureMidRailSmallSpacing()),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(fixture)),
|
|
};
|
|
Assert.Equal(hash, actual);
|
|
}
|
|
}
|