feat(engine): carry clamp zones into stock, validation and repeat identity

NestPlateStock takes an optional clamp-zone list, translated into the
placement frame exactly like WorkArea (quadrants 1-4) and detached from
caller boxes; empty input keeps stock bit-identical to before. Plate
mappings (DrawingJobMapper.FromPlate/CreatePlate, NestStockBuilder) map
zones across; materialized plates carry them for checking.
NestJobValidator rejects zones outside the sheet or duplicate ids.
RepeatedPlateGrouping and NestLayoutCheck treat clamp sets as stock
identity (C8), and the layout checker flags part material inside a zone
inflated by bottom-edge spacing with the shared spacing slack (C1/C5) -
no tolerance loosened.
This commit is contained in:
aj committed 2026-10-12 15:40:27 -04:00
1 parent 4e0c4967a2
commit a3f1124d11
7 files changed
+385 -9

No files matched your search

@@ -0,0 +1,207 @@
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);
// Materializing the stock back keeps the placement frame: no second transform.
var materialized = DrawingJobMapper.CreatePlate(stock);
var copy = Assert.Single(materialized.ClampZones);
Assert.Equal(zone.Bounds.X, copy.Bounds.X);
Assert.Equal(0, copy.Bounds.Y); // quadrant 2 keeps Y at zero
Assert.Equal("clamp-0", copy.Id);
}
[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 ValidatorRejectsZonesOutsideTheSheet()
{
var beyond = new NestJob([], [Stock(zones: Zone(x: 95.5))]);
Assert.Throws<ArgumentException>(() => NestJobValidator.Validate(beyond));
var belowEdge = new NestJob([], [new NestPlateStock("s", new Size(48, 96),
clampZones: [new ClampZone("under", new Box(5, -0.5, 1.5, 0.625))])]);
Assert.Throws<ArgumentException>(() => NestJobValidator.Validate(belowEdge));
}
[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"));
}
}
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using OpenNest.CNC; using OpenNest.CNC;
namespace OpenNest.Engine.Jobs.Adapters; namespace OpenNest.Engine.Jobs.Adapters;
@@ -54,7 +55,9 @@ public static class DrawingJobMapper
quantity, quantity,
plate.PartSpacing, plate.PartSpacing,
plate.EdgeSpacing, plate.EdgeSpacing,
plate.Quadrant plate.Quadrant,
cost: null,
clampZones: new List<ClampZone>(plate.ClampZones)
); );
} }
@@ -104,12 +107,18 @@ public static class DrawingJobMapper
internal static double LegacyStep(RotationPolicy policy) => internal static double LegacyStep(RotationPolicy policy) =>
policy.Kind == RotationPolicyKind.Fixed ? OpenNest.Math.Angle.TwoPI : policy.Step; policy.Kind == RotationPolicyKind.Fixed ? OpenNest.Math.Angle.TwoPI : policy.Step;
internal static Plate CreatePlate(NestPlateStock stock) => internal static Plate CreatePlate(NestPlateStock stock)
new(stock.Size) {
var plate = new Plate(stock.Size)
{ {
Quantity = 1, Quantity = 1,
PartSpacing = stock.PartSpacing, PartSpacing = stock.PartSpacing,
EdgeSpacing = stock.EdgeSpacing, EdgeSpacing = stock.EdgeSpacing,
Quadrant = stock.Quadrant, Quadrant = stock.Quadrant,
}; };
// Zones are immutable and already carry the placement-frame transform.
foreach (var zone in stock.ClampZones)
plate.ClampZones.Add(zone);
return plate;
}
} }
+38
View File
@@ -32,6 +32,7 @@ public static class NestJobValidator
$"Invalid stock dimensions/settings: {stock.Id}.", $"Invalid stock dimensions/settings: {stock.Id}.",
nameof(job) nameof(job)
); );
ValidateClampZones(stock, job);
} }
foreach (var part in job.Parts) foreach (var part in job.Parts)
{ {
@@ -71,6 +72,43 @@ public static class NestJobValidator
+ string.Join(", ", available.Where(s => !s.Cost.HasValue).Select(s => s.Id))); + string.Join(", ", available.Where(s => !s.Cost.HasValue).Select(s => s.Id)));
} }
/// <summary>
/// Every stock zone must be uniquely identified and lie within its own sheet
/// (edge-inclusive; geometry and finiteness already enforced by <see cref="ClampZone"/>).
/// Zones are compared in the placement frame, where the stock carries them.
/// </summary>
private static void ValidateClampZones(NestPlateStock stock, NestJob job)
{
if (stock.ClampZones.Count == 0)
return;
var ids = new HashSet<string>(StringComparer.Ordinal);
var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
const double slack = NestTolerances.WorkAreaSlack;
foreach (var zone in stock.ClampZones)
{
if (!ids.Add(zone.Id))
throw new ArgumentException(
$"Clamp zone id '{zone.Id}' is defined twice on stock {stock.Id}.",
nameof(job)
);
var b = zone.Bounds;
if (
b.Left < left - slack
|| b.Right > left + stock.Size.Length + slack
|| b.Bottom < bottom - slack
|| b.Top > bottom + stock.Size.Width + slack
)
throw new ArgumentException(
$"Clamp zone '{zone.Id}' on stock {stock.Id} lies outside the sheet.",
nameof(job)
);
}
}
internal static void ValidateCandidate( internal static void ValidateCandidate(
PlateCandidate candidate, PlateCandidate candidate,
NestPlateStock stock, NestPlateStock stock,
+75
View File
@@ -153,11 +153,63 @@ public static class NestLayoutCheck
ValidateBounds(parts, plate, requirements, result); ValidateBounds(parts, plate, requirements, result);
ValidateAreaBudget(parts, plate, result); ValidateAreaBudget(parts, plate, result);
ValidateSpacing(parts, plate.PartSpacing, requirements, result); ValidateSpacing(parts, plate.PartSpacing, requirements, result);
if (plate.ClampZones.Count > 0)
ValidateClamps(parts, plate, requirements, result);
} }
return result; return result;
} }
/// <summary>
/// C1/C5: no part material may enter a clamp zone inflated by the plate's bottom-edge
/// spacing (D6) - the same clearance the part owes the sheet edge, enforced with the
/// shared spacing slack so exact engine placements are not reddened by rounding.
/// </summary>
private static void ValidateClamps(
List<Part> parts,
Plate plate,
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements,
List<string> result
)
{
var inflateBy = InflationFor(plate.EdgeSpacing.Bottom);
foreach (var zone in plate.ClampZones)
{
// Box.Offset(d) grows the rectangle on all four sides.
var keepOut = zone.Bounds.Offset(inflateBy);
var keepOutOutline = RectangleOutline(keepOut);
foreach (var part in parts)
{
if (!BoxesTouch(keepOut, MaterialBounds(part)))
continue;
var material = Outline(part, 0);
if (material == null || !Overlaps(material, keepOutOutline))
continue;
result.Add(
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) overlaps "
+ $"clamp zone '{zone.Id}' including its edge clearance"
);
}
}
}
private static PartOutline RectangleOutline(Box box)
{
var ring = new List<Geometry.Entity>
{
new Line(box.Left, box.Bottom, box.Right, box.Bottom),
new Line(box.Right, box.Bottom, box.Right, box.Top),
new Line(box.Right, box.Top, box.Left, box.Top),
new Line(box.Left, box.Top, box.Left, box.Bottom),
};
var profile = new ShapeProfile(ring);
return Outline(profile, new Vector(0, 0), 0);
}
/// <summary> /// <summary>
/// Checks what the materialized layout cannot show: every sheet must be /// Checks what the materialized layout cannot show: every sheet must be
/// one of the job's own stock entries (an engine may not invent a sheet /// one of the job's own stock entries (an engine may not invent a sheet
@@ -239,8 +291,31 @@ public static class NestLayoutCheck
&& expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top) && expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top)
&& expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom) && expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom)
&& expected.Quadrant == actual.Quadrant && expected.Quadrant == actual.Quadrant
&& SameZones(expected.ClampZones, actual.ClampZones)
); );
// Clamp sets are part of stock settings; a different set is not the offered stock.
private static bool SameZones(
IReadOnlyList<ClampZone> x,
IReadOnlyList<ClampZone> y
)
{
if (x.Count != y.Count)
return false;
for (var i = 0; i < x.Count; i++)
{
if (
!string.Equals(x[i].Id, y[i].Id, StringComparison.Ordinal)
|| !x[i].Bounds.X.IsEqualTo(y[i].Bounds.X)
|| !x[i].Bounds.Y.IsEqualTo(y[i].Bounds.Y)
|| !x[i].Bounds.Length.IsEqualTo(y[i].Bounds.Length)
|| !x[i].Bounds.Width.IsEqualTo(y[i].Bounds.Width)
)
return false;
}
return true;
}
private static string Describe(RotationPolicy policy) => private static string Describe(RotationPolicy policy) =>
policy.Kind switch policy.Kind switch
{ {
+26 -4
View File
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs; namespace OpenNest.Engine.Jobs;
@@ -14,19 +15,21 @@ public sealed class NestPlateStock
int? quantity = null, int? quantity = null,
double partSpacing = 0, double partSpacing = 0,
Spacing edgeSpacing = default, Spacing edgeSpacing = default,
int quadrant = 1 int quadrant = 1,
IReadOnlyList<ClampZone> clampZones = null
) )
: this(id, size, quantity, partSpacing, edgeSpacing, quadrant, null) { } : this(id, size, quantity, partSpacing, edgeSpacing, quadrant, null, clampZones) { }
public NestPlateStock(string id, Size size, int? quantity, double partSpacing, public NestPlateStock(string id, Size size, int? quantity, double partSpacing,
Spacing edgeSpacing, int quadrant, double? cost) Spacing edgeSpacing, int quadrant, double? cost,
IReadOnlyList<ClampZone> clampZones = null)
{ {
if (cost.HasValue && (!double.IsFinite(cost.Value) || cost.Value <= 0)) if (cost.HasValue && (!double.IsFinite(cost.Value) || cost.Value <= 0))
throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null."); throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null.");
Cost = cost;
ArgumentException.ThrowIfNullOrWhiteSpace(id); ArgumentException.ThrowIfNullOrWhiteSpace(id);
if (quantity < 0) if (quantity < 0)
throw new ArgumentOutOfRangeException(nameof(quantity)); throw new ArgumentOutOfRangeException(nameof(quantity));
Cost = cost;
Id = id; Id = id;
Size = size; Size = size;
Quantity = quantity; Quantity = quantity;
@@ -41,8 +44,27 @@ public sealed class NestPlateStock
size.Length - edgeSpacing.Left - edgeSpacing.Right, size.Length - edgeSpacing.Left - edgeSpacing.Right,
size.Width - edgeSpacing.Bottom - edgeSpacing.Top size.Width - edgeSpacing.Bottom - edgeSpacing.Top
); );
// Zones arrive in plate coordinates and are carried in the placement frame,
// translated exactly like WorkArea. Detached copies: stock is immutable input.
if (clampZones == null || clampZones.Count == 0)
{
ClampZones = Array.Empty<ClampZone>();
}
else
{
var zones = new ClampZone[clampZones.Count];
for (var i = 0; i < zones.Length; i++)
zones[i] = new ClampZone(clampZones[i].Id, clampZones[i].Bounds.Translate(left, bottom));
ClampZones = zones;
}
} }
/// <summary>
/// Keep-out rectangles in the placement frame (quadrant applied), translated exactly
/// like <see cref="WorkArea"/>. Empty when the plate defines none; never null.
/// </summary>
public IReadOnlyList<ClampZone> ClampZones { get; }
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary> /// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
public double? Cost { get; } public double? Cost { get; }
public string Id { get; } public string Id { get; }
+2 -1
View File
@@ -30,7 +30,8 @@ public static class NestStockBuilder
template.PartSpacing, template.PartSpacing,
template.EdgeSpacing, template.EdgeSpacing,
template.Quadrant, template.Quadrant,
option.Cost == 0 ? null : option.Cost option.Cost == 0 ? null : option.Cost,
new List<ClampZone>(template.ClampZones)
)); ));
} }
NestJobValidator.ValidateStockCosts(stock); NestJobValidator.ValidateStockCosts(stock);
+25 -1
View File
@@ -41,7 +41,31 @@ internal static class RepeatedPlateGrouping
&& Bits(a.EdgeSpacing.Bottom) == Bits(b.EdgeSpacing.Bottom) && Bits(a.EdgeSpacing.Bottom) == Bits(b.EdgeSpacing.Bottom)
&& a.Quantity == b.Quantity && a.Quantity == b.Quantity
&& a.Cost.HasValue == b.Cost.HasValue && a.Cost.HasValue == b.Cost.HasValue
&& (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value)); && (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value))
&& SameZones(a.ClampZones, b.ClampZones);
// C8: a layout solved with clamps is never reused for a plate without them - and a
// different clamp set is different stock. Ordered; ids and exact bits participate.
private static bool SameZones(
System.Collections.Generic.IReadOnlyList<ClampZone> x,
System.Collections.Generic.IReadOnlyList<ClampZone> y
)
{
if (x.Count != y.Count)
return false;
for (var i = 0; i < x.Count; i++)
{
if (
!string.Equals(x[i].Id, y[i].Id, StringComparison.Ordinal)
|| Bits(x[i].Bounds.X) != Bits(y[i].Bounds.X)
|| Bits(x[i].Bounds.Y) != Bits(y[i].Bounds.Y)
|| Bits(x[i].Bounds.Length) != Bits(y[i].Bounds.Length)
|| Bits(x[i].Bounds.Width) != Bits(y[i].Bounds.Width)
)
return false;
}
return true;
}
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);