fix(clamps): preserve interactive clearance and group integrity

This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent 3e58a9e93f
commit 74544c689a
5 files changed
+222 -28

No files matched your search

@@ -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<Part> { 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<Part> { 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()
{
@@ -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<Part> { 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<Part>
{
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<Part>
{
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<NestItem> { 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<NestItem> { item },
null, CancellationToken.None);
if (strategy != "Strip")
Assert.Empty(parts);
}
[Theory]
[MemberData(nameof(Strategies))]
public void WholePlateNestAvoidsClampsOnAnOccupiedPlate(string strategy)
+2
View File
@@ -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++)
{
+30 -2
View File
@@ -65,8 +65,10 @@ internal static class InteractiveClamps
public static List<Part> ObstacleParts(Plate plate) =>
ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom - plate.PartSpacing));
/// <summary>Expander walls: the raw keep-out rectangles themselves (no part spacing).</summary>
public static List<Part> WallParts(Plate plate) => ObstaclePartsFor(ZoneRectangles(plate), 0);
/// <summary>Expander walls use the bottom-edge clearance as a fixed floor,
/// independent of the separation target or its final achieved spacing.</summary>
public static List<Part> WallParts(Plate plate) =>
ObstaclePartsFor(ZoneRectangles(plate), System.Math.Max(0, plate.EdgeSpacing.Bottom));
private static List<Part> ObstaclePartsFor(IReadOnlyList<Box> 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<Polygon> { outerA };
var ringsB = new List<Polygon> { 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<Polygon> HolePolygons(ShapeProfile profile)
{
if (profile.Cutouts.Count == 0)
@@ -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<Part> ClearAreas(Plate plate, Box box, CancellationToken token,
Func<Box, NestItem, List<Part>> fill, int quantity, NestItem item = null,
List<Part> previouslyPlaced = null)
List<Part> previouslyPlaced = null, int groupSize = 1)
{
var result = new List<Part>();
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<NestItem> { item }, previouslyPlaced);
item == null ? null : new List<NestItem> { item }, previouslyPlaced, groupSize);
}
return result;
}
@@ -195,35 +200,67 @@ public static class PlateFillService
}
private static void AddClear(List<Part> accepted, List<Part> proposed, Plate plate,
Box area, List<NestItem> demand = null, List<Part> previouslyPlaced = null)
Box area, List<NestItem> demand = null, List<Part> 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<Part>();
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<Part> batch, Part part, List<Part> accepted,
Plate plate, Box area, List<NestItem> demand, List<Part> 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;