feat(clamps): keep the whole footprint of a partly used clamp

A clamp is an avoidance area, so a clamp that only partly reaches a
sheet (for example centered at 60.5" on a 60" sheet, or near the
origin) now keeps its whole 1.5" x 0.625" footprint instead of being
clipped to the sheet; clamps wholly beyond the sheet are still left
off. Job validation accepts a zone that hangs past the sheet edges and
still rejects one that does not reach the sheet at all. Both packing
lanes already block keep-outs extending past the bin; a hanging-clamp
solve through Rectangles, Irregular and Default keeps every part clear
of the whole inflated footprint.

User decision 2026-10-12, settling the plan's pending D2 question.
This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent 37700b3ef9
commit bafb311735
7 files changed
+96 -43

No files matched your search

+11 -14
View File
@@ -9,8 +9,9 @@ namespace OpenNest
/// The machine's fixed clamp rail: identical rectangular clamps whose X centers are
/// measured from the plate origin along the origin-side (operator) edge. Plates always
/// start at the origin, so the same rail resolves onto every sheet size without
/// re-scaling; a clamp whose footprint lies wholly beyond a sheet is skipped and one
/// that still reaches onto it is kept, clipped to the sheet.
/// re-scaling. A clamp whose footprint lies wholly beyond a sheet is skipped; one that
/// still reaches onto it is kept whole: the zone is an avoidance area, so its full
/// footprint applies even where it hangs past the sheet.
/// </summary>
public sealed class ClampRail : IEquatable<ClampRail>
{
@@ -57,8 +58,10 @@ namespace OpenNest
/// The zones this rail places on a sheet, in plate coordinates (the frame of
/// <see cref="Plate.ClampZones"/>: origin at the sheet's lower-left corner). The
/// quadrant locates the plate origin, and with it the origin-side edge, on the sheet.
/// Ids are <c>clamp-N</c> by rail position, so one physical clamp keeps its id on
/// every sheet even when others are skipped.
/// Zones are whole clamp footprints, never clipped, so one reaching past a sheet end
/// (or deeper than a narrow sheet) extends beyond the sheet. Ids are <c>clamp-N</c> by
/// rail position, so one physical clamp keeps its id on every sheet even when others
/// are skipped.
/// </summary>
public IReadOnlyList<ClampZone> ResolveFor(Size size, int quadrant)
{
@@ -69,8 +72,8 @@ namespace OpenNest
var extendsLeft = quadrant is 2 or 3;
var extendsDown = quadrant is 3 or 4;
var depth = System.Math.Min(Depth, sheetWidth);
var y = extendsDown ? sheetWidth - depth : 0;
// The origin-side edge is y = 0 of the sheet, or its top when it extends to -Y.
var y = extendsDown ? sheetWidth - Depth : 0;
var half = Width / 2;
var zones = new List<ClampZone>(Centers.Count);
@@ -82,16 +85,10 @@ namespace OpenNest
if (near >= length)
continue; // wholly beyond this sheet
var clipped = near < 0 || far > length;
var start = near < 0 ? 0 : near;
var extent = clipped ? System.Math.Min(far, length) - start : Width;
if (!(extent > 0))
continue;
// Plate coordinates are measured from the lower-left corner; a sheet extending
// to -X from the origin has its origin edge at x = length.
var x = extendsLeft ? length - (start + extent) : start;
zones.Add(new ClampZone($"clamp-{i + 1}", new Box(x, y, extent, depth)));
var x = extendsLeft ? length - far : near;
zones.Add(new ClampZone($"clamp-{i + 1}", new Box(x, y, Width, Depth)));
}
return zones;
@@ -1,3 +1,4 @@
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
@@ -54,6 +55,41 @@ public class ClampRailStockTests
NestJobValidator.Validate(new NestJob([], stock));
}
[Theory]
[InlineData("Rectangles")]
[InlineData("Irregular")]
[InlineData("Default")]
public void LanesAvoidTheWholeFootprintOfAClampHangingPastTheSheetEnd(string engine)
{
// 12" sheet, clamp centered 12.25" from the origin: 1" of its 1.5" lies past the end.
var template = new Plate(10, 12) { EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25), PartSpacing = 0.25 };
template.SetClampRail(new ClampRail(1.5, 0.625, new[] { 12.25 }));
var zone = Assert.Single(template.ClampZones);
Assert.Equal(11.5, zone.Bounds.X);
Assert.Equal(1.5, zone.Bounds.Length);
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(1, 1));
var result = NestPipeline.Run(new NestPipelineRequest(engine,
[new NestItem { Drawing = drawing, Quantity = 80 }], NestStockBuilder.SinglePlate(template)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var parts = result.Plates.SelectMany(p => p.Parts).ToList();
Assert.NotEmpty(parts);
// Independent oracle: the whole clamp grown by the bottom edge spacing.
var keepOut = zone.Bounds.Offset(0.25);
Assert.All(parts, part =>
{
var box = part.BoundingBox;
var overlapX = System.Math.Min(box.Right, keepOut.Right) - System.Math.Max(box.Left, keepOut.Left);
var overlapY = System.Math.Min(box.Top, keepOut.Top) - System.Math.Max(box.Bottom, keepOut.Bottom);
Assert.False(overlapX > 1e-6 && overlapY > 1e-6, $"{box} enters {keepOut}");
});
// Rectangles still uses the front edge beside the clamp. (Irregular may lift its whole
// block above the clamp band at the same count; that is a layout choice, not a leak.)
if (engine == "Rectangles")
Assert.Contains(parts, p => p.BoundingBox.Bottom < keepOut.Top && p.BoundingBox.Right <= keepOut.Left + 1e-6);
}
[Fact]
public void SingleSheetStockCarriesTheTemplatesRailZones()
{
@@ -111,14 +111,26 @@ public class ClampZoneEngineTests
}
[Fact]
public void ValidatorRejectsZonesOutsideTheSheet()
public void ValidatorAcceptsWholeClampsHangingPastTheSheet()
{
var beyond = new NestJob([], [Stock(zones: Zone(x: 95.5))]);
Assert.Throws<ArgumentException>(() => NestJobValidator.Validate(beyond));
// 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))]));
}
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));
[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]
+9 -8
View File
@@ -73,9 +73,11 @@ public static class NestJobValidator
}
/// <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.
/// Every stock zone must be uniquely identified and reach onto its own sheet (a positive
/// overlap). A clamp is an avoidance area, so its whole footprint may hang past the sheet
/// edges; a zone wholly off the sheet is malformed. Geometry and finiteness are 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)
{
@@ -85,7 +87,6 @@ public static class NestJobValidator
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)
{
@@ -97,10 +98,10 @@ public static class NestJobValidator
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
!(b.Right > left)
|| !(b.Left < left + stock.Size.Length)
|| !(b.Top > bottom)
|| !(b.Bottom < bottom + stock.Size.Width)
)
throw new ArgumentException(
$"Clamp zone '{zone.Id}' on stock {stock.Id} lies outside the sheet.",
+17 -12
View File
@@ -74,31 +74,36 @@ public class ClampRailTests
}
[Fact]
public void ClampStraddlingTheSheetEndIsClippedNotDropped()
public void ClampReachingOntoTheSheetIsKeptWhole()
{
// A clamp is an avoidance area: if any of it reaches the sheet, all of it applies.
var rail = new ClampRail(1.5, 0.625, new[] { 59.5, 60.5, 60.75, 61 });
var zones = rail.ResolveFor(new Size(48, 60), quadrant: 1);
// 59.5 covers [58.75, 60]; 60.5 still reaches onto the sheet [59.75, 60];
// 60.75 only touches the end and 61 lies wholly beyond it.
// 59.5 covers [58.75, 60.25] and 60.5 still reaches the last 0.25" [59.75, 61.25];
// 60.75 only touches the sheet end and 61 lies wholly beyond it.
Assert.Equal(new[] { "clamp-1", "clamp-2" }, zones.Select(z => z.Id));
Assert.Equal(58.75, zones[0].Bounds.X);
Assert.Equal(1.25, zones[0].Bounds.Length);
Assert.Equal(59.75, zones[1].Bounds.X);
Assert.Equal(0.25, zones[1].Bounds.Length);
Assert.Equal(new[] { 58.75, 59.75 }, zones.Select(z => z.Bounds.X));
Assert.All(zones, z => Assert.Equal(1.5, z.Bounds.Length));
}
[Fact]
public void ClampAtTheOriginAndDeepClampsAreClippedToTheSheet()
public void ClampsNearTheOriginOrDeeperThanTheSheetStayWhole()
{
var rail = new ClampRail(1.5, 0.625, new[] { 0.25 });
var origin = Assert.Single(rail.ResolveFor(new Size(48, 96), quadrant: 1));
Assert.Equal(0, origin.Bounds.X);
Assert.Equal(1.0, origin.Bounds.Length);
Assert.Equal(-0.5, origin.Bounds.X);
Assert.Equal(1.5, origin.Bounds.Length);
var deep = Assert.Single(new ClampRail(1.5, 10, new[] { 6.0 }).ResolveFor(new Size(4, 96), 1));
Assert.Equal(4, deep.Bounds.Width);
var deep = new ClampRail(1.5, 10, new[] { 6.0 });
var front = Assert.Single(deep.ResolveFor(new Size(4, 96), 1));
Assert.Equal(0, front.Bounds.Y);
Assert.Equal(10, front.Bounds.Width);
// Sheets extending to -Y have the origin edge on top; the clamp hangs below the sheet.
var top = Assert.Single(deep.ResolveFor(new Size(4, 96), 3));
Assert.Equal(4.0, top.Bounds.Top);
Assert.Equal(10, top.Bounds.Width);
}
[Theory]
+3 -2
View File
@@ -60,8 +60,9 @@ file keeps clamps off; an unreadable file, or one with ignored invalid values
match the machine.
Every plate follows the nest's rail at its own size and quadrant: clamps wholly
beyond a shorter sheet are left off it, one still reaching onto the sheet is
clipped to it, and resizing or rotating a plate re-resolves its clamps. Auto Nest
beyond a shorter sheet are left off it, one still reaching onto the sheet keeps
its whole footprint as the avoidance area (it may hang past the sheet edge), and
resizing or rotating a plate re-resolves its clamps. Auto Nest
offers each stock size with the rail resolved for that size, and every committed
sheet keeps exactly the clamps its layout was validated against. Clamps are
drawn as hatched amber rectangles over the sheet; they cannot be selected,
+2 -1
View File
@@ -73,7 +73,8 @@ dropped while the rest of the nest loads.
`width` (along the edge), `depth` (into the sheet) and `centers` (X positions
measured from the plate origin along the origin-side edge). New plates follow
the rail and resolve it for their own size and quadrant; clamps wholly beyond a
sheet are skipped and one still reaching onto it is clipped. The key is omitted
sheet are skipped and one still reaching onto it keeps its whole footprint, so
a zone may extend past the sheet edge. The key is omitted
when a nest has no rail, so such files are unchanged; a missing, empty or
malformed rail loads as none. On load a plate follows the rail again only when
its saved `clamps` are exactly the rail's zones for that sheet; any other saved