Files
OpenNest/OpenNest.Engine.Tests/Jobs/ClampZoneEngineTests.cs
T
aj df2f1b796d fix(engine): keep every plate's clamp zones in the sheet frame
Plates the engine built from stock (DrawingJobMapper.CreatePlate, used
by the public result materializer, the layout check and the placement
fillers) stored placement-frame zones, while live plates store them
relative to the sheet's lower-left corner. The same Plate.ClampZones
therefore meant two frames: the new clamp cut checks double-translated
materialized plates in quadrants 2-4, and interactive fill and push on
live plates read sheet-frame zones as placement coordinates, so they
could put parts on clamps there.

Plates built from stock now keep the stock's input zones (sheet frame,
no lossy translate-and-back), and the layout check and interactive
clamp obstacles map through ClampZoneFrame.ToPlacement, which
reproduces the stock's placement zones bit for bit, so engine
placements and validation are unchanged.

Project Memory: opennest 14f93b26-2e41-41f1-b09f-87dc895bb4da
2026-10-13 18:15:49 -04:00

224 lines
9.0 KiB
C#

using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Clamp zones as engine input: immutable stock carry with the WorkArea quadrant
/// transform, validation of zone geometry, repeated-layout identity (C8) and the
/// layout-check violation against the D6-inflated rectangle (C1/C5).
/// </summary>
public class ClampZoneEngineTests
{
private const double ZoneX = 5.25;
private const double ZoneLength = 1.5;
private const double ZoneWidth = 0.625;
private static ClampZone Zone(string id = "clamp-0", double x = ZoneX) =>
new(id, new Box(x, 0, ZoneLength, ZoneWidth));
private static NestPlateStock Stock(
int quadrant = 1,
double bottomSpacing = 0.25,
params ClampZone[] zones
) =>
new("sheet", new Size(48, 96), null, 0.25,
new Spacing(0.25, bottomSpacing, 0.25, 0.25), quadrant, null, zones);
// ---- stock carry and quadrant transform ----------------------------------------
[Fact]
public void StockWithoutZonesExposesAnEmptyList()
{
var stock = new NestPlateStock("s", new Size(48, 96));
Assert.Empty(stock.ClampZones);
}
[Theory]
[InlineData(1, 0.0, 0.0)]
[InlineData(2, -96.0, 0.0)]
[InlineData(3, -96.0, -48.0)]
[InlineData(4, 0.0, -48.0)]
public void ZonesTransformWithTheWorkAreaIntoEveryQuadrant(int quadrant, double dx, double dy)
{
var stock = Stock(quadrant: quadrant, zones: Zone());
var zone = Assert.Single(stock.ClampZones);
Assert.Equal("clamp-0", zone.Id);
Assert.Equal(ZoneX + dx, zone.Bounds.X); // exact bits: same translation WorkArea uses
Assert.Equal(dy, zone.Bounds.Y);
Assert.Equal(ZoneLength, zone.Bounds.Length);
Assert.Equal(ZoneWidth, zone.Bounds.Width);
// The keep-out stays inside the sheet box of this quadrant.
var work = stock.WorkArea;
Assert.True(zone.Bounds.Right <= work.Right + 0.25);
Assert.True(zone.Bounds.Bottom >= work.Bottom - 0.25 - 0.25);
}
[Fact]
public void StockDetachesZonesFromCallerBoxes()
{
var bounds = new Box(ZoneX, 0, ZoneLength, ZoneWidth);
var zone = new ClampZone("clamp", bounds);
var stock = Stock(zones: zone);
bounds.Offset(500, 500);
Assert.Equal(ZoneX, Assert.Single(stock.ClampZones).Bounds.X);
}
[Fact]
public void MapperCarriesPlateZonesInBothDirections()
{
var plate = new Plate(48, 96) { Quadrant = 2 };
plate.ClampZones.Add(Zone());
var stock = DrawingJobMapper.FromPlate("s", plate, quantity: null);
var zone = Assert.Single(stock.ClampZones);
Assert.Equal(ZoneX - 96, zone.Bounds.X);
Assert.Equal(0, zone.Bounds.Y);
// A plate materialized from the stock stores plate coordinates like every Plate;
// mapping it into the placement frame reproduces the stock zone exactly.
var materialized = DrawingJobMapper.CreatePlate(stock);
var copy = Assert.Single(materialized.ClampZones);
Assert.Equal(ZoneX, copy.Bounds.X);
Assert.Equal(0, copy.Bounds.Y); // quadrant 2 keeps Y at zero
Assert.Equal("clamp-0", copy.Id);
var placed = Assert.Single(ClampZoneFrame.ToPlacement(materialized));
Assert.Equal(zone.Bounds.X, placed.Bounds.X);
Assert.Equal(zone.Bounds.Y, placed.Bounds.Y);
}
[Fact]
public void StockBuilderCarriesTemplateZonesForEveryOption()
{
var template = new Plate(48, 96);
template.ClampZones.Add(Zone("a", 3));
template.ClampZones.Add(Zone("b", 20));
var single = Assert.Single(NestStockBuilder.SinglePlate(template));
Assert.Equal(2, single.ClampZones.Count);
var options = NestStockBuilder.FromTemplate(template,
[new PlateOption { Width = 48, Length = 96 }, new PlateOption { Width = 40, Length = 80 }]);
Assert.All(options, stock => Assert.Equal(2, stock.ClampZones.Count));
}
// ---- validator ------------------------------------------------------------------
[Fact]
public void ValidatorAcceptsEdgeFlushZones()
{
var job = new NestJob([], [Stock(zones: new[] { Zone(), Zone("flush", 94.5) })]);
NestJobValidator.Validate(job); // y = 0 front edge and right-edge flush both legal
}
[Fact]
public void ValidatorAcceptsWholeClampsHangingPastTheSheet()
{
// A clamp is an avoidance area: a footprint reaching onto the sheet is kept whole.
NestJobValidator.Validate(new NestJob([], [Stock(zones: Zone(x: 95.5))]));
NestJobValidator.Validate(new NestJob([], [new NestPlateStock("s", new Size(48, 96),
clampZones: [new ClampZone("under", new Box(5, -0.5, 1.5, 0.625))])]));
NestJobValidator.Validate(new NestJob([], [Stock(quadrant: 3, zones: Zone(x: -0.5))]));
}
[Theory]
[InlineData(96, 0)] // touches the far end only
[InlineData(100, 0)]
[InlineData(-1.5, 0)] // touches the origin end only
[InlineData(5, -0.625)] // below the front edge, touching it
[InlineData(5, 48)] // beyond the back edge
public void ValidatorRejectsZonesWhollyOffTheSheet(double x, double y)
{
var job = new NestJob([], [new NestPlateStock("s", new Size(48, 96),
clampZones: [new ClampZone("off", new Box(x, y, 1.5, 0.625))])]);
Assert.Throws<ArgumentException>(() => NestJobValidator.Validate(job));
}
[Fact]
public void ValidatorRejectsDuplicateZoneIds()
{
var job = new NestJob([], [Stock(zones: new[] { Zone("dup", 3), Zone("dup", 20) })]);
Assert.Throws<ArgumentException>(() => NestJobValidator.Validate(job));
}
// ---- repeated-layout identity (C8) ----------------------------------------------
[Fact]
public void DifferentClampSetsAreDifferentStock()
{
var plain = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25);
var clamped = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25,
clampZones: [Zone()]);
var sameAsPlain = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25,
clampZones: [Zone()]);
Assert.False(RepeatedPlateGrouping.SameStock(plain, clamped));
Assert.True(RepeatedPlateGrouping.SameStock(clamped, sameAsPlain));
var pose = new NestJobPlacement("p", 0, 1, 1, 0);
var groups = RepeatedPlateGrouping.Create(
[
new NestJobPlateResult(0, clamped, [pose]),
new NestJobPlateResult(1, plain, [pose]),
new NestJobPlateResult(2, sameAsPlain, [pose]),
], consolidate: true);
// sheet2 repeats sheet0's exact layout and an identical clamp set, so only the
// plain-clamp sheet stays a separate group; the clamped layout is never reused
// for the plain sheet (C8).
Assert.Equal(new[] { 2, 1 }, groups.Select(g => g.Quantity).ToArray());
}
// ---- layout check (C1/C5) ---------------------------------------------------------
private static NestJobPart RectPart(string id = "p", double w = 2, double h = 2) =>
new(id, PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(w, h)), 1);
private static IReadOnlyList<string> Check(NestPlateStock stock, double x, double y)
{
var job = new NestJob([RectPart()], [stock]);
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, stock, [new NestJobPlacement("p", 0, x, y, 0)])],
[], []);
return NestLayoutCheck.Violations(job, result);
}
[Fact]
public void CheckerFlagsPartsInsideTheInflatedClampRectangle()
{
// keep-out = zone inflated by bottom EdgeSpacing (0.25): x [~5.0, ~7.0], y up to ~0.875
var violations = Check(Stock(zones: Zone()), x: 5.3, y: 0.4);
Assert.Contains(violations, v => v.Contains("clamp-0") && v.Contains("clamp zone"));
}
[Fact]
public void CheckerAcceptsPartsClearOfTheInflatedClampRectangle()
{
// Above the inflated depth (0.625 + 0.25) and clear in X beyond the inflated width.
Assert.DoesNotContain(Check(Stock(zones: Zone()), x: 5.3, y: 1.0),
v => v.Contains("clamp zone"));
Assert.DoesNotContain(Check(Stock(zones: Zone()), x: 7.2, y: 0.4),
v => v.Contains("clamp zone"));
}
[Fact]
public void CheckerEnforcesTransformedZonesInOtherQuadrants()
{
// Quadrant 3 moves the front-edge zone to (5.25-96, -48); a part inside it must red.
var stock = Stock(quadrant: 3, zones: Zone());
var violations = Check(stock, x: 5.25 - 96 + 0.1, y: -48 + 0.4);
Assert.Contains(violations, v => v.Contains("clamp zone"));
// The same sheet a clear distance away from the transformed zone passes.
Assert.DoesNotContain(Check(stock, x: -50, y: -40), v => v.Contains("clamp zone"));
}
[Fact]
public void EmptyClampListAddsNoViolations()
{
Assert.DoesNotContain(Check(Stock(), x: 10, y: 10), v => v.Contains("clamp"));
}
}