From 74544c689ac85a0fa9c8c456964216d11b01c3b7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 12 Oct 2026 23:04:57 -0400 Subject: [PATCH] fix(clamps): preserve interactive clearance and group integrity --- .../Fill/ClampZonePushTests.cs | 35 +++++++ .../Jobs/ClampZoneInteractiveFillTests.cs | 92 +++++++++++++++++++ OpenNest.Engine/Fill/Compactor.cs | 2 + OpenNest.Engine/Jobs/InteractiveClamps.cs | 32 ++++++- .../Jobs/Placement/PlateFillService.cs | 89 ++++++++++++------ 5 files changed, 222 insertions(+), 28 deletions(-) diff --git a/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs b/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs index fe342715..b84acfdf 100644 --- a/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs +++ b/OpenNest.Engine.Tests/Fill/ClampZonePushTests.cs @@ -93,6 +93,24 @@ public class ClampZonePushTests AssertClear(plate, mover); } + [Fact] + public void SettleDoesNotMoveGroupThroughAnOccupiedPart() + { + var plate = new Plate(new Size(20, 20)) + { + EdgeSpacing = new Spacing(0.25, 0.25), + PartSpacing = 0.1, + }; + plate.ClampZones.Add(new ClampZone("remote", new Box(15, 0, 1.5, 0.625))); + var occupant = AddSquare(plate, 0.25, 0.25, 4); + var first = AddSquare(plate, 6, 7); + var second = AddSquare(plate, 11, 7); + Compactor.Settle(new List { first, second }, plate, plate.WorkArea()); + Assert.False(Overlaps(first.BoundingBox, occupant.BoundingBox)); + Assert.False(Overlaps(second.BoundingBox, occupant.BoundingBox)); + AssertClear(plate, first, second); + } + [Fact] public void SettleKeepsGroupOutsideClampsInsideItsFillArea() { @@ -113,6 +131,23 @@ public class ClampZonePushTests AssertClear(plate, clear); } + [Fact] + public void ExpanderPreservesBottomEdgeClearanceWhenItsAchievedSpacingIsSmaller() + { + var plate = new Plate(new Size(12, 12)) + { + EdgeSpacing = new Spacing(2, 2), + PartSpacing = 0.1, + }; + plate.ClampZones.Add(new ClampZone("front", new Box(4, 0, 2, 1))); + var upper = AddSquare(plate, 4, 5, 2); + var lower = AddSquare(plate, 4, 3.1, 2); + var result = Expander.Expand(new List { upper, lower }, plate); + Assert.False(result.Cancelled); + Assert.True(lower.BoundingBox.Bottom >= 3 - 1e-6, + $"Achieved={result.AchievedSpacing}, lower at {lower.Location.Y}"); + } + [Fact] public void ExpanderNeverTargetsAPoseInsideAClampWhenGrowthWouldEnterOne() { diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneInteractiveFillTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneInteractiveFillTests.cs index d36b3dd6..7430b0f7 100644 --- a/OpenNest.Engine.Tests/Jobs/ClampZoneInteractiveFillTests.cs +++ b/OpenNest.Engine.Tests/Jobs/ClampZoneInteractiveFillTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using OpenNest.CNC; +using OpenNest.Engine.Fill; using OpenNest.Engine.Jobs.Placement; using OpenNest.Geometry; using Xunit; @@ -105,6 +106,85 @@ public class ClampZoneInteractiveFillTests AssertClearOfClamps(parts, ZoneA, ZoneB); } + [Fact] + public void DesktopGroupFillThenSettleRemainsClearOfPlateOccupant() + { + var plate = new Plate(new Size(20, 20)) + { + EdgeSpacing = new Spacing(0.25, 0.25), + PartSpacing = 0.1, + }; + plate.ClampZones.Add(new ClampZone("remote", new Box(15, 0, 1.5, 0.625))); + var occupant = new Part(RectDrawing(4, 19.5), new Vector(0.25, 0.25)); + plate.Parts.Add(occupant); + var group = new List { new(RectDrawing(3, 3)) }; + var result = PlateFillService.FillGroup("Fill", plate, group, + plate.WorkArea(), null, CancellationToken.None); + Assert.True(result.Count > 1); + Assert.DoesNotContain(result, part => Overlaps(part.BoundingBox, occupant.BoundingBox)); + Compactor.Settle(result, plate, plate.WorkArea()); + Assert.DoesNotContain(result, part => Overlaps(part.BoundingBox, occupant.BoundingBox)); + AssertClearOfClamps(result, new Box(15, 0, 1.5, 0.625)); + } + + [Fact] + public void FillGroupRejectsTheWholeCopyWhenOnlyOneMemberTouchesAClamp() + { + var plate = new Plate(new Size(20, 20)) { PartSpacing = 0 }; + plate.ClampZones.Add(new ClampZone("front", new Box(5, 0, 1, 0.5))); + var drawing = RectDrawing(2, 2); + var group = new List + { + new(drawing, new Vector(2, 0)), new(drawing, new Vector(5, 0)), + }; + var parts = PlateFillService.FillGroup("Fill", plate, group, new Box(2, 0, 5, 2), + null, CancellationToken.None); + Assert.Equal(0, parts.Count % group.Count); + Assert.Empty(parts); + } + + [Fact] + public void FillGroupKeepsAnInsertInItsFrameHoleWhenAClampIsElsewhere() + { + var program = RectProgram(4, 4); + program.MoveTo(1, 1); + program.LineTo(1, 3); + program.LineTo(3, 3); + program.LineTo(3, 1); + program.LineTo(1, 1); + var frame = new Drawing("frame", program); + var insert = RectDrawing(1, 1); + var plate = new Plate(new Size(20, 20)) { PartSpacing = 0.25 }; + plate.ClampZones.Add(new ClampZone("remote", new Box(18, 0, 1, 0.5))); + var group = new List + { + new(frame, new Vector(1, 3)), new(insert, new Vector(2.5, 4.5)), + }; + var parts = PlateFillService.FillGroup("Fill", plate, group, new Box(1, 3, 4, 4), + null, CancellationToken.None); + Assert.Contains(parts, part => ReferenceEquals(part.BaseDrawing, frame)); + Assert.Contains(parts, part => ReferenceEquals(part.BaseDrawing, insert)); + Assert.Equal(0, parts.Count % 2); + } + + [Fact] + public void PackAreaKeepsAllowedOverhangOnlyAtThePlateBoundary() + { + var plate = new Plate(new Size(12, 12)) + { + EdgeSpacing = new Spacing(1, 1), + PartSpacing = 0, + }; + plate.ClampZones.Add(new ClampZone("front", new Box(5, 0, 1, 0.5))); + var drawing = RectDrawing(10 + OpenNest.Engine.NestingEngines.Rectangles.SheetPacker.OverhangAllowance * 0.8, 4); + var items = new List { new() { Drawing = drawing, Quantity = 2 } }; + var parts = PlateFillService.PackArea("Fill", plate, plate.WorkArea(), items, + null, CancellationToken.None); + Assert.Equal(2, parts.Count); + Assert.All(parts, part => Assert.True(part.BoundingBox.Right > plate.WorkArea().Right)); + AssertClearOfClamps(parts, new Box(5, 0, 1, 0.5)); + } + [Fact] public void PackAreaNeverPacksInsideClampKeepOut() { @@ -137,6 +217,18 @@ public class ClampZoneInteractiveFillTests public void Report(NestProgress value) => report(value); } + [Theory] + [MemberData(nameof(Strategies))] + public void WholePlateNestWithNoRequestedPartsDoesNotFillBecauseOfClamps(string strategy) + { + var plate = MakePlate(new ClampZone("front-1", ZoneA)); + var item = new NestItem { Drawing = RectDrawing(3, 3), Quantity = 0 }; + var parts = PlateFillService.Nest(strategy, plate, new List { item }, + null, CancellationToken.None); + if (strategy != "Strip") + Assert.Empty(parts); + } + [Theory] [MemberData(nameof(Strategies))] public void WholePlateNestAvoidsClampsOnAnOccupiedPlate(string strategy) diff --git a/OpenNest.Engine/Fill/Compactor.cs b/OpenNest.Engine/Fill/Compactor.cs index 3c4dd3cd..e32a6a08 100644 --- a/OpenNest.Engine/Fill/Compactor.cs +++ b/OpenNest.Engine/Fill/Compactor.cs @@ -419,6 +419,8 @@ namespace OpenNest.Engine.Fill return; var obstacles = Jobs.InteractiveClamps.ObstacleParts(plate); + obstacles.AddRange(plate.Parts.Where(part => !parts.Contains(part) + && !part.BaseDrawing.IsCutOff)); for (var i = 0; i < maxIterations; i++) { diff --git a/OpenNest.Engine/Jobs/InteractiveClamps.cs b/OpenNest.Engine/Jobs/InteractiveClamps.cs index cac9116e..cf7f6c45 100644 --- a/OpenNest.Engine/Jobs/InteractiveClamps.cs +++ b/OpenNest.Engine/Jobs/InteractiveClamps.cs @@ -65,8 +65,10 @@ internal static class InteractiveClamps public static List ObstacleParts(Plate plate) => ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom - plate.PartSpacing)); - /// Expander walls: the raw keep-out rectangles themselves (no part spacing). - public static List WallParts(Plate plate) => ObstaclePartsFor(ZoneRectangles(plate), 0); + /// Expander walls use the bottom-edge clearance as a fixed floor, + /// independent of the separation target or its final achieved spacing. + public static List WallParts(Plate plate) => + ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom)); private static List ObstaclePartsFor(IReadOnlyList rectangles, double inflate) { @@ -100,6 +102,32 @@ internal static class InteractiveClamps HolePolygons(zoneProfile)); } + public static bool MaterialsTooClose(Part first, Part second, double spacing) + { + var a = new ShapeProfile(PartGeometry.GetPartEntities(first)); + var b = new ShapeProfile(PartGeometry.GetPartEntities(second)); + if (a.Perimeter == null || b.Perimeter == null) + return true; + var outerA = a.Perimeter.ToPolygonWithTolerance(0.001); + var outerB = b.Perimeter.ToPolygonWithTolerance(0.001); + var holesA = HolePolygons(a); + var holesB = HolePolygons(b); + if (Collision.HasOverlap(outerA, outerB, holesA, holesB)) + return true; + if (spacing <= NestTolerances.SpacingSlack) + return false; + var ringsA = new List { outerA }; + var ringsB = new List { outerB }; + if (holesA != null) ringsA.AddRange(holesA); + if (holesB != null) ringsB.AddRange(holesB); + foreach (var ringA in ringsA) + foreach (var ringB in ringsB) + if (Clearance.BoundaryDistance(ringA, ringB).Distance + < spacing - NestTolerances.SpacingSlack) + return true; + return false; + } + private static List HolePolygons(ShapeProfile profile) { if (profile.Cutouts.Count == 0) diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs index 497dbe53..5a118714 100644 --- a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs +++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs @@ -63,7 +63,7 @@ public static class PlateFillService if (!InteractiveClamps.HasZones(plate)) return filler.Fill(groupParts, workArea, progress, token); var result = ClearAreas(plate, workArea, token, (area, _) => - filler.Fill(groupParts, area, null, token), 0); + filler.Fill(groupParts, area, null, token), 0, groupSize: groupParts.Count); ReportSafe(progress, result, workArea); return result; } @@ -131,6 +131,11 @@ public static class PlateFillService foreach (var item in items.OrderBy(item => item.Priority)) { token.ThrowIfCancellationRequested(); + // Fill/Remnant whole-plate orchestration skips exhausted demand; the + // single-item Fill API uses zero to mean unlimited. Strip has its own + // historical zero-quantity behavior, preserved here. + if (item.Quantity <= 0 && ResolveStrategy(strategy) != "Strip") + continue; var proposed = FillClearAreas(plate, plate.WorkArea(), item, token, (area, demand) => filler.Fill(demand, area, null, token), result); result.AddRange(proposed); @@ -170,7 +175,7 @@ public static class PlateFillService private static List ClearAreas(Plate plate, Box box, CancellationToken token, Func> fill, int quantity, NestItem item = null, - List previouslyPlaced = null) + List previouslyPlaced = null, int groupSize = 1) { var result = new List(); foreach (var area in ClearRegionBoxes(plate, box)) @@ -181,7 +186,7 @@ public static class PlateFillService var demand = item == null ? null : CopyDemand(item, quantity > 0 ? quantity - result.Count : quantity); AddClear(result, fill(area, demand), plate, area, - item == null ? null : new List { item }, previouslyPlaced); + item == null ? null : new List { item }, previouslyPlaced, groupSize); } return result; } @@ -195,35 +200,67 @@ public static class PlateFillService } private static void AddClear(List accepted, List proposed, Plate plate, - Box area, List demand = null, List previouslyPlaced = null) + Box area, List demand = null, List previouslyPlaced = null, + int groupSize = 1) { - if (proposed == null) + if (proposed == null || groupSize < 1 || proposed.Count % groupSize != 0) return; - foreach (var part in proposed) + for (var start = 0; start < proposed.Count; start += groupSize) { - var bounds = part.BoundingBox; - if (bounds.Left < area.Left - 1e-6 || bounds.Right > area.Right + 1e-6 - || bounds.Bottom < area.Bottom - 1e-6 || bounds.Top > area.Top + 1e-6) - continue; - if (demand != null && !demand.Any(item => - ReferenceEquals(item.Drawing, part.BaseDrawing) && - (item.Quantity <= 0 || accepted.Count(p => - ReferenceEquals(p.BaseDrawing, item.Drawing)) < item.Quantity))) - continue; - if (InteractiveClamps.ValidationKeepOuts(plate).Any(zone => - Overlaps(bounds, zone) && InteractiveClamps.MaterialInKeepOut(part, zone))) - continue; - if (plate.Parts.Any(existing => !existing.BaseDrawing.IsCutOff && - Overlaps(bounds, existing.BoundingBox.Offset(plate.PartSpacing))) - || accepted.Any(existing => Overlaps(bounds, - existing.BoundingBox.Offset(plate.PartSpacing))) - || (previouslyPlaced?.Any(existing => Overlaps(bounds, - existing.BoundingBox.Offset(plate.PartSpacing))) ?? false)) - continue; - accepted.Add(part); + // A group is one proposal: never return just its frame or just its + // insert when a member collides with a clamp or occupied material. + var batch = new List(); + for (var index = start; index < start + groupSize; index++) + { + if (!TryAddClear(batch, proposed[index], accepted, plate, area, + demand, previouslyPlaced)) + break; + } + if (batch.Count == groupSize) + accepted.AddRange(batch); } } + private static bool TryAddClear(List batch, Part part, List accepted, + Plate plate, Box area, List demand, List previouslyPlaced) + { + var bounds = part.BoundingBox; + var full = plate.WorkArea(); + // The packer permits a tiny overhang only at a real sheet edge, + // never across a remnant boundary cut by a clamp or another part. + double Slack(double edge, double sheet) => System.Math.Abs(edge - sheet) < 1e-9 + ? NestTolerances.WorkAreaSlack : 1e-6; + if (bounds.Left < area.Left - Slack(area.Left, full.Left) + || bounds.Right > area.Right + Slack(area.Right, full.Right) + || bounds.Bottom < area.Bottom - Slack(area.Bottom, full.Bottom) + || bounds.Top > area.Top + Slack(area.Top, full.Top)) + return false; + if (demand != null && !demand.Any(item => + ReferenceEquals(item.Drawing, part.BaseDrawing) && + (item.Quantity <= 0 || accepted.Count(p => + ReferenceEquals(p.BaseDrawing, item.Drawing)) + batch.Count(p => + ReferenceEquals(p.BaseDrawing, item.Drawing)) < item.Quantity))) + return false; + if (InteractiveClamps.ValidationKeepOuts(plate).Any(zone => + Overlaps(bounds, zone) && InteractiveClamps.MaterialInKeepOut(part, zone))) + return false; + if (plate.Parts.Any(existing => !existing.BaseDrawing.IsCutOff && + Overlaps(bounds, existing.BoundingBox.Offset(plate.PartSpacing)) + && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) + || accepted.Any(existing => Overlaps(bounds, + existing.BoundingBox.Offset(plate.PartSpacing)) + && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) + || batch.Any(existing => Overlaps(bounds, + existing.BoundingBox.Offset(plate.PartSpacing)) + && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) + || (previouslyPlaced?.Any(existing => Overlaps(bounds, + existing.BoundingBox.Offset(plate.PartSpacing)) + && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) ?? false)) + return false; + batch.Add(part); + return true; + } + private static bool Overlaps(Box a, Box b) => a.Left < b.Right && a.Right > b.Left && a.Bottom < b.Top && a.Top > b.Bottom;