From 040776807a8782e11dc44d126579ad99a3a26a4a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 12 Oct 2026 18:10:34 -0400 Subject: [PATCH] fix(engine): keep solved clamp zones on committed plates ApplyToEmptyPlates copied size, spacing, quadrant and quantity from the solved stock but not its clamp zones, so a sheet the commit created had no keep-outs although its parts were validated against them, and a reused plate could keep zones the layout was not solved with. Map the stock's placement-frame zones back to plate coordinates on every committed plate; zones already equal in the placement frame are left untouched so an unchanged plate raises no change events. Project Memory: bcc2dd2a-2ab6-4a2a-9c13-197e0aeb24bc --- .../Jobs/ClampZoneCommitTests.cs | 110 ++++++++++++++++++ OpenNest.Engine/Jobs/NestPipelineCommit.cs | 40 +++++++ 2 files changed, 150 insertions(+) create mode 100644 OpenNest.Engine.Tests/Jobs/ClampZoneCommitTests.cs diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneCommitTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneCommitTests.cs new file mode 100644 index 00000000..db1e8d07 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampZoneCommitTests.cs @@ -0,0 +1,110 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// A committed live plate must carry exactly the clamp keep-outs its layout was solved and +/// validated against, in plate coordinates, including sheets the commit had to create. +/// +public class ClampZoneCommitTests +{ + // A 1.5 x 0.625 clamp on the front edge of a 48 x 96 sheet (Length 96 runs along X). + private const double ZoneX = 5.25; + + private static ClampZone Zone() => new("clamp-1", new Box(ZoneX, 0, 1.5, 0.625)); + + /// Two different one-part sheets so repeat grouping cannot merge them. + private sealed class TwoSheetStub : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) + { + var stock = job.Plates[0]; + var work = stock.WorkArea; + var builder = new NestJobResultBuilder(job); + builder.AddSheet(stock, new[] { (job.Parts[0].Id, work.Left + 20, work.Bottom + 10, 0.0) }); + builder.AddSheet(stock, new[] { (job.Parts[0].Id, work.Left + 40, work.Bottom + 20, 0.0) }); + return builder.Build(NestJobStopReason.Completed); + } + } + + private static NestPipelineResult Result(Drawing drawing, int quadrant, params ClampZone[] zones) + { + var result = NestPipeline.Run(new TwoSheetStub(), "stub", new NestPipelineRequest("stub", + new[] { new NestItem { Drawing = drawing, Quantity = 2 } }, + new[] + { + new NestPlateStock("sheet", new Size(48, 96), null, 0.25, new Spacing(1, 1, 1, 1), + quadrant, cost: null, clampZones: zones), + })); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(2, result.Plates.Count); + return result; + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void EveryCommittedPlateCarriesTheSolvedZonesInPlateCoordinates(int quadrant) + { + var drawing = new Drawing("part", TestDrawingFactory.Rectangle()); + var nest = new Nest(); + nest.Drawings.Add(drawing); + nest.CreatePlate(); // empty template without zones; the second sheet is created by commit + using var manager = new PlateManager(nest); + + var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, quadrant, Zone()), manager); + + Assert.Equal(2, applied.Count); + Assert.All(applied, plate => + { + var zone = Assert.Single(plate.ClampZones); + Assert.Equal("clamp-1", zone.Id); + // Exact bits: the inverse of the stock's placement-frame translation. + Assert.Equal(ZoneX, zone.Bounds.X); + Assert.Equal(0, zone.Bounds.Y); + Assert.Equal(1.5, zone.Bounds.Length); + Assert.Equal(0.625, zone.Bounds.Width); + }); + } + + [Fact] + public void MatchingPlateZonesAreKeptWithoutChangeEvents() + { + var drawing = new Drawing("part", TestDrawingFactory.Rectangle()); + var nest = new Nest(); + nest.Drawings.Add(drawing); + var template = nest.CreatePlate(); + template.Quadrant = 3; + template.Size = new Size(48, 96); + var original = Zone(); + template.ClampZones.Add(original); + var changes = 0; + template.ClampZonesChanged += (_, _) => changes++; + using var manager = new PlateManager(nest); + + var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, 3, Zone()), manager); + + Assert.Same(template, applied[0]); + Assert.Same(original, Assert.Single(template.ClampZones)); + Assert.Equal(0, changes); + } + + [Fact] + public void ZonesTheLayoutWasNotSolvedWithAreRemoved() + { + var drawing = new Drawing("part", TestDrawingFactory.Rectangle()); + var nest = new Nest(); + nest.Drawings.Add(drawing); + var template = nest.CreatePlate(); + template.ClampZones.Add(Zone()); + using var manager = new PlateManager(nest); + + var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, 1), manager); + + Assert.All(applied, plate => Assert.Empty(plate.ClampZones)); + } +} diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs index a4333458..fbfc4764 100644 --- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs +++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs @@ -42,6 +42,7 @@ public static class NestPipelineCommit plate.EdgeSpacing = proposed.Stock.EdgeSpacing; plate.Quadrant = proposed.Stock.Quadrant; plate.Quantity = proposed.Quantity; + SyncClampZones(plate, proposed.Stock); plate.Parts.AddRange(proposed.Parts); applied.Add(plate); } @@ -52,4 +53,43 @@ public static class NestPipelineCommit } return applied.AsReadOnly(); } + + /// + /// The live plate keeps exactly the clamp keep-outs its layout was solved and validated + /// against, including sheets created for this commit. Stock zones live in the placement + /// frame; plate zones in plate coordinates, so the stock's translation is inverted. Zones + /// already equal in the placement frame are left untouched (no change events). + /// + private static void SyncClampZones(Plate plate, NestPlateStock stock) + { + var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length; + var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width; + if (SameInPlacementFrame(plate.ClampZones, stock.ClampZones, left, bottom)) + return; + + plate.ClampZones.Clear(); + foreach (var zone in stock.ClampZones) + plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds.Translate(-left, -bottom))); + } + + private static bool SameInPlacementFrame( + IList plateZones, + IReadOnlyList stockZones, + double left, + double bottom) + { + if (plateZones.Count != stockZones.Count) + return false; + for (var i = 0; i < plateZones.Count; i++) + { + // Same arithmetic NestPlateStock uses to carry plate zones into the placement frame. + var placed = plateZones[i].Bounds.Translate(left, bottom); + var solved = stockZones[i].Bounds; + if (!string.Equals(plateZones[i].Id, stockZones[i].Id, StringComparison.Ordinal) + || placed.X != solved.X || placed.Y != solved.Y + || placed.Length != solved.Length || placed.Width != solved.Width) + return false; + } + return true; + } }