mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 05:01:05 -04:00
fix(clamps): preserve interactive clearance and group integrity
This commit is contained in:
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)
|
||||
|
||||
@@ -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++)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user