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
This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent 3e9e336072
commit df2f1b796d
8 files changed
+158 -15

No files matched your search

+3 -3
View File
@@ -5,12 +5,12 @@ using OpenNest.Geometry;
namespace OpenNest;
/// <summary>
/// Maps a live plate's <see cref="Plate.ClampZones"/>, stored in the sheet's lower-left frame
/// Maps a plate's <see cref="Plate.ClampZones"/>, stored in the sheet's lower-left frame
/// (<see cref="ClampRail.ResolveFor"/>), into the signed placement frame of its parts,
/// cut-offs and motions. The translation matches the sheet itself and the engine stock:
/// -Length on X in quadrants 2 and 3, -Width on Y in quadrants 3 and 4. Whole footprints are
/// kept, never clipped. Engine-internal plates built from a <c>NestPlateStock</c> already
/// carry placement-frame zones and must not be mapped again.
/// kept, never clipped. Every plate uses the sheet frame, including plates the engine builds
/// from a <c>NestPlateStock</c>, so this is the only translation any consumer applies.
/// </summary>
public static class ClampZoneFrame
{
@@ -78,12 +78,16 @@ public class ClampZoneEngineTests
Assert.Equal(ZoneX - 96, zone.Bounds.X);
Assert.Equal(0, zone.Bounds.Y);
// Materializing the stock back keeps the placement frame: no second transform.
// 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(zone.Bounds.X, copy.Bounds.X);
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]
@@ -0,0 +1,112 @@
using System;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Diagnostics;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Every <see cref="Plate"/> stores clamp zones in the sheet's lower-left frame, including
/// plates the engine builds from stock; consumers map them into the signed placement frame
/// through <see cref="ClampZoneFrame"/>. Fixtures use a real rail in all quadrants, with
/// expected footprints from the physical rule (center +/- width/2 from the origin along the
/// edge, depth into the sheet).
/// </summary>
public class ClampZoneSheetFrameTests
{
private static (string, long, long, long, long) Bits(ClampZone zone) =>
(zone.Id, BitConverter.DoubleToInt64Bits(zone.Bounds.X), BitConverter.DoubleToInt64Bits(zone.Bounds.Y),
BitConverter.DoubleToInt64Bits(zone.Bounds.Length), BitConverter.DoubleToInt64Bits(zone.Bounds.Width));
private static Box World(int quadrant, double center, double width = 1.5, double depth = 0.625)
{
var x = quadrant is 2 or 3 ? -(center + width / 2) : center - width / 2;
var y = quadrant is 3 or 4 ? -depth : 0;
return new Box(x, y, width, depth);
}
private static Program Rectangle(double length, double width)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(length, 0);
program.LineTo(length, width);
program.LineTo(0, width);
program.LineTo(0, 0);
return program;
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MaterializedPlatesStoreSheetFrameZonesAndWarnForCutsThroughThem(int quadrant)
{
var live = new Plate(81, 120) { Quadrant = quadrant };
live.SetClampRail(new ClampRail(1.5, 0.625, new[] { 35.0, 120.5 }));
var stock = DrawingJobMapper.FromPlate("s", live, 1);
var job = new NestJob(Array.Empty<NestJobPart>(), new[] { stock });
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, Array.Empty<NestJobPlacement>()) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
var nest = NestResultMaterializer.Materialize(job, result).Nest;
var plate = Assert.Single(nest.Plates);
Assert.Equal(live.ClampZones.Select(Bits), plate.ClampZones.Select(Bits));
Assert.Equal(stock.ClampZones.Select(Bits), ClampZoneFrame.ToPlacement(plate).Select(Bits));
var footprint = World(quadrant, 35);
Assert.Equal(footprint.Left, stock.ClampZones[0].Bounds.Left, 9);
Assert.Equal(footprint.Bottom, stock.ClampZones[0].Bounds.Bottom, 9);
// A short cut-off through the physical clamp must be found and require consent.
plate.CutOffs.Add(new CutOff(new Vector(quadrant is 2 or 3 ? -35 : 35, 0), CutOffAxis.Vertical)
{
StartLimit = quadrant is 3 or 4 ? -2 : 0,
EndLimit = quadrant is 3 or 4 ? 0 : 2,
});
Assert.Equal("clamp-1", Assert.Single(ClampCutDiagnostics.Find(plate)).ClampId);
plate.RegenerateCutOffs(new CutOffSettings());
var report = PostVerificationAnalyzer.Analyze(nest);
Assert.Contains(report.Findings, f => f.ClampId == "clamp-1");
Assert.False(report.CanPost(false));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void InteractiveFillOnLiveRailPlateAvoidsClampsInEveryQuadrant(int quadrant)
{
// Five rows of 4.29 squares at 0.5 spacing span 23.45 of the 23.5 work-area height, so an
// unobstructed fill reaches the front edge in every quadrant.
var plate = new Plate(new Size(24, 48))
{
Quadrant = quadrant,
EdgeSpacing = new Spacing(0.25, 0.25),
PartSpacing = 0.5,
};
var centers = new[] { 10.75, 20.75, 30.75, 40.75 };
plate.SetClampRail(new ClampRail(1.5, 0.625, centers));
var keepOuts = centers.Select(c => World(quadrant, c).Offset(0.25)).ToArray();
var item = new NestItem { Drawing = new Drawing("square", Rectangle(4.29, 4.29)), Quantity = 500 };
var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(), null, CancellationToken.None);
Assert.True(parts.Count >= 20, $"only {parts.Count} parts placed");
foreach (var part in parts)
{
var box = part.BoundingBox;
Assert.DoesNotContain(keepOuts, k => box.Left < k.Right - 1e-6 && k.Left < box.Right - 1e-6
&& box.Bottom < k.Top - 1e-6 && k.Bottom < box.Top - 1e-6);
}
}
}
@@ -116,9 +116,10 @@ public static class DrawingJobMapper
EdgeSpacing = stock.EdgeSpacing,
Quadrant = stock.Quadrant,
};
// Zones are immutable and already carry the placement-frame transform.
foreach (var zone in stock.ClampZones)
plate.ClampZones.Add(zone);
// Every Plate stores zones in plate (sheet lower-left) coordinates; consumers map
// them into the placement frame through ClampZoneFrame, exactly as the stock did.
foreach (var zone in stock.SheetClampZones)
plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds));
return plate;
}
}
+11 -6
View File
@@ -10,7 +10,7 @@ namespace OpenNest.Engine.Jobs;
/// <summary>
/// Interactive-path clamp keep-outs (Slice 4): the inflated rectangles and static obstacle
/// parts that make the single-plate fill, push and pack surfaces respect
/// <see cref="Plate.ClampZones"/> like existing parts. Zones are never material: the fake
/// <see cref="Plate.ClampZones"/> (mapped into the placement frame) like existing parts. Zones are never material: the fake
/// obstacle parts exist only inside a single call - they are never added to
/// <see cref="Plate.Parts"/>, plates, jobs or files.
/// </summary>
@@ -18,14 +18,18 @@ internal static class InteractiveClamps
{
public static bool HasZones(Plate plate) => plate.ClampZones.Count > 0;
/// <summary>Raw zone rectangles, detached copies, capture order.</summary>
/// <summary>
/// Raw zone rectangles in the parts' placement frame (plate zones are stored in sheet
/// coordinates, see <see cref="ClampZoneFrame"/>), detached copies, capture order.
/// </summary>
public static IReadOnlyList<Box> ZoneRectangles(Plate plate)
{
if (plate.ClampZones.Count == 0)
return Array.Empty<Box>();
var boxes = new Box[plate.ClampZones.Count];
var zones = ClampZoneFrame.ToPlacement(plate);
var boxes = new Box[zones.Count];
for (var i = 0; i < boxes.Length; i++)
boxes[i] = Copy(plate.ClampZones[i].Bounds);
boxes[i] = Copy(zones[i].Bounds);
return boxes;
}
@@ -49,9 +53,10 @@ internal static class InteractiveClamps
{
if (plate.ClampZones.Count == 0)
return Array.Empty<Box>();
var boxes = new Box[plate.ClampZones.Count];
var zones = ClampZoneFrame.ToPlacement(plate);
var boxes = new Box[zones.Count];
for (var i = 0; i < boxes.Length; i++)
boxes[i] = plate.ClampZones[i].Bounds.Offset(clearance);
boxes[i] = zones[i].Bounds.Offset(clearance);
return boxes;
}
+2 -1
View File
@@ -174,7 +174,8 @@ public static class NestLayoutCheck
{
var inflateBy = InflationFor(plate.EdgeSpacing.Bottom);
foreach (var zone in plate.ClampZones)
// Plate zones are stored in sheet coordinates; parts are in the placement frame.
foreach (var zone in ClampZoneFrame.ToPlacement(plate))
{
// Box.Offset(d) grows the rectangle on all four sides.
var keepOut = zone.Bounds.Offset(inflateBy);
+14
View File
@@ -49,13 +49,19 @@ public sealed class NestPlateStock
if (clampZones == null || clampZones.Count == 0)
{
ClampZones = Array.Empty<ClampZone>();
SheetClampZones = Array.Empty<ClampZone>();
}
else
{
var zones = new ClampZone[clampZones.Count];
var sheetZones = 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));
sheetZones[i] = new ClampZone(clampZones[i].Id, clampZones[i].Bounds);
}
ClampZones = zones;
SheetClampZones = sheetZones;
}
}
@@ -65,6 +71,14 @@ public sealed class NestPlateStock
/// </summary>
public IReadOnlyList<ClampZone> ClampZones { get; }
/// <summary>
/// The same zones exactly as given, in plate coordinates (the frame of
/// <see cref="Plate.ClampZones"/>), for plates built from this stock. Keeping the input
/// avoids a lossy translate-and-back; <see cref="ClampZoneFrame.ToPlacement"/> of such a
/// plate reproduces <see cref="ClampZones"/> bit for bit.
/// </summary>
internal IReadOnlyList<ClampZone> SheetClampZones { get; }
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
public double? Cost { get; }
public string Id { get; }
+6
View File
@@ -74,6 +74,12 @@ expanded on all sides by its bottom edge spacing. Part spacing is not added to c
clearance. Default runs and validates both lanes as usual. Empty clamp collections
leave the existing packing path unchanged; clamps never become requested or placed parts.
Every `Plate`, including plates the engine builds from stock, stores clamp zones relative
to the sheet's lower-left corner; `NestPlateStock.ClampZones` carries them in the signed
placement frame of part coordinates. Code that compares a plate's zones with parts, cuts
or motions maps them through `ClampZoneFrame.ToPlacement`, which reproduces the stock's
zones exactly; reading `Plate.ClampZones` bounds directly is wrong in quadrants 2-4.
Irregular prepares static rectangle obstacles and their cached no-fit polygons once
per stock per solve, separately from the part-spacing cache. Every orientation's initial
free region excludes these obstacles, including extra orientations introduced by pairs