mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 16:11:07 -04:00
Dropping the parts that land on a clamp suits small parts, which fill the gaps between clamps. Large parts often do better if the fill area stops short of the clamps. The rows still fit, and no part is lost on a clamp. When every clamp in the fill area lies on one edge, interactive Fill now also fills the area with that edge moved just past the clamp keep-outs, as a larger edge spacing would. It keeps whichever layout the strategy's comparer ranks better, and a tie keeps the dropped-parts layout. Clamps on more than one edge skip the comparison. The second fill reuses the cached best-fit pairs. While it runs, a preview counts as the new best only when it beats the first layout, so the preview never gets worse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
362 lines
15 KiB
C#
362 lines
15 KiB
C#
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;
|
|
|
|
namespace OpenNest.Engine.Tests.Jobs;
|
|
|
|
/// <summary>
|
|
/// Slice 4 (C1): the interactive single-plate surface - PlateFillService strategies
|
|
/// (Fill/Strip/Vertical+Horizontal Remnant), PackArea and whole-plate Nest - never
|
|
/// proposes a part inside a clamp keep-out inflated by the plate's bottom-edge
|
|
/// spacing, while material outside the zones stays usable. Oracles are analytic:
|
|
/// strict positive-area overlap against independently inflated zone rectangles.
|
|
/// </summary>
|
|
public class ClampZoneInteractiveFillTests
|
|
{
|
|
// Plate: 48 (X) by 24 (Y), uniform 0.25 edge spacing -> bottom clearance 0.25.
|
|
// Two 1.5 x 0.625 clamps on the front edge at X 10 and X 20.
|
|
private const double Edge = 0.25;
|
|
private const double PartSpacing = 0.5;
|
|
|
|
private static readonly Box ZoneA = new(10, 0, 1.5, 0.625);
|
|
private static readonly Box ZoneB = new(20, 0, 1.5, 0.625);
|
|
private static readonly Box BackZone = new(20, 24 - 0.625, 1.5, 0.625);
|
|
|
|
private static Plate MakePlate(params ClampZone[] zones)
|
|
{
|
|
var plate = new Plate(new Size(24, 48));
|
|
plate.EdgeSpacing = new Spacing(Edge, Edge);
|
|
plate.PartSpacing = PartSpacing;
|
|
foreach (var zone in zones)
|
|
plate.ClampZones.Add(zone);
|
|
return plate;
|
|
}
|
|
|
|
private static Program RectProgram(double lengthX, double widthY)
|
|
{
|
|
var program = new Program();
|
|
program.MoveTo(0, 0);
|
|
program.LineTo(lengthX, 0);
|
|
program.LineTo(lengthX, widthY);
|
|
program.LineTo(0, widthY);
|
|
program.LineTo(0, 0);
|
|
return program;
|
|
}
|
|
|
|
private static Drawing RectDrawing(double lengthX, double widthY) =>
|
|
new($"rect-{lengthX}-{widthY}", RectProgram(lengthX, widthY));
|
|
|
|
private static Box Inflated(Box zone, double clearance) =>
|
|
new(zone.X - clearance, zone.Y - clearance, zone.Length + 2 * clearance, zone.Width + 2 * clearance);
|
|
|
|
/// <summary>Strict positive-area overlap: touching edges (the admitted closure) are clear.</summary>
|
|
private static bool Overlaps(Box a, Box b) =>
|
|
a.Left < b.Right - 1e-6 && b.Left < a.Right - 1e-6 && a.Bottom < b.Top - 1e-6 && b.Bottom < a.Top - 1e-6;
|
|
|
|
private static void AssertClearOfClamps(IEnumerable<Part> parts, params Box[] zones)
|
|
{
|
|
foreach (var part in parts)
|
|
foreach (var zone in zones)
|
|
{
|
|
var keepOut = Inflated(zone, Edge);
|
|
Assert.False(
|
|
Overlaps(part.BoundingBox, keepOut),
|
|
$"'{part.BaseDrawing.Name}' at ({part.Location.X:R},{part.Location.Y:R}) "
|
|
+ $"box [{part.BoundingBox.Left:R}..{part.BoundingBox.Right:R} x "
|
|
+ $"{part.BoundingBox.Bottom:R}..{part.BoundingBox.Top:R}] enters the clamp keep-out"
|
|
);
|
|
}
|
|
}
|
|
|
|
public static TheoryData<string> Strategies => new() { "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" };
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Strategies))]
|
|
public void FillItemNeverProposesPartInsideClampKeepOut(string strategy)
|
|
{
|
|
var occupant = new Part(RectDrawing(6, 6), new Vector(38, 14)); // occupied-plate fixture
|
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
|
plate.Parts.Add(occupant);
|
|
|
|
var item = new NestItem { Drawing = RectDrawing(4, 4), Quantity = 3 };
|
|
var parts = PlateFillService.FillItem(
|
|
strategy, plate, item, plate.WorkArea(), null, CancellationToken.None);
|
|
|
|
Assert.Equal(3, parts.Count);
|
|
AssertClearOfClamps(parts, ZoneA, ZoneB);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Strategies))]
|
|
public void FillUsesMaterialBetweenAndBehindTheClamps(string strategy)
|
|
{
|
|
// The band covers both clamps only where y < 3: parts must land in the strip
|
|
// above the clamps and in the slot between them - not merely fewer parts.
|
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
|
var band = new Box(9, 0.25, 13, 2.75);
|
|
|
|
var item = new NestItem { Drawing = RectDrawing(3, 1.8), Quantity = 2 };
|
|
var parts = PlateFillService.FillItem(strategy, plate, item, band, null, CancellationToken.None);
|
|
|
|
Assert.Equal(2, parts.Count);
|
|
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 FillGroupRetainsCompleteCopiesAheadOfAPartialTrailingCopy()
|
|
{
|
|
var drawing = RectDrawing(2, 2);
|
|
var group = new List<Part>
|
|
{
|
|
new(drawing, new Vector(0, 0)), new(drawing, new Vector(3, 0)),
|
|
};
|
|
var plate = new Plate(new Size(20, 20)) { PartSpacing = 0 };
|
|
var area = new Box(0, 0, 8, 2);
|
|
var original = PlateFillService.FillGroup("Fill", plate, group, area,
|
|
null, CancellationToken.None);
|
|
Assert.True(original.Count > group.Count && original.Count % group.Count != 0,
|
|
$"expected a full copy and partial tail, got {original.Count}");
|
|
plate.ClampZones.Add(new ClampZone("remote", new Box(18, 0, 1, 0.5)));
|
|
var clamped = PlateFillService.FillGroup("Fill", plate, group, area,
|
|
null, CancellationToken.None);
|
|
Assert.Equal(group.Count, clamped.Count);
|
|
}
|
|
|
|
[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()
|
|
{
|
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
|
var items = new List<NestItem> { new() { Drawing = RectDrawing(4, 4), Quantity = 8 } };
|
|
|
|
var parts = PlateFillService.PackArea(
|
|
"Fill", plate, plate.WorkArea(), items, null, CancellationToken.None);
|
|
|
|
Assert.True(parts.Count >= 5, $"expected the unclamped material to pack parts, got {parts.Count}");
|
|
AssertClearOfClamps(parts, ZoneA, ZoneB);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProgressNeverPublishesUnverifiedClampPlacements()
|
|
{
|
|
var plate = MakePlate(new ClampZone("front-1", ZoneA));
|
|
var updates = new List<NestProgress>();
|
|
var progress = new InlineProgress(update => updates.Add(update));
|
|
var item = new NestItem { Drawing = RectDrawing(4, 4), Quantity = 4 };
|
|
var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(),
|
|
progress, CancellationToken.None);
|
|
Assert.NotEmpty(parts);
|
|
Assert.NotEmpty(updates);
|
|
Assert.All(updates, update => AssertClearOfClamps(update.BestParts, ZoneA));
|
|
}
|
|
|
|
[Fact]
|
|
public void ClampsOnTwoEdgesFillTheUnclampedLayoutLessThePartsThatHitAClamp()
|
|
{
|
|
// ZoneB moved to the back edge, so no single edge can be moved past the clamps.
|
|
var item = new NestItem { Drawing = RectDrawing(4, 4) };
|
|
var unclamped = PlateFillService.FillItem("Fill", MakePlate(), item, MakePlate().WorkArea(),
|
|
null, CancellationToken.None);
|
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("back-1", BackZone));
|
|
Assert.Null(PlateFillService.ClampFreeArea(plate, plate.WorkArea()));
|
|
|
|
var clamped = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(),
|
|
null, CancellationToken.None);
|
|
|
|
var hits = unclamped.Count(part =>
|
|
Overlaps(part.BoundingBox, Inflated(ZoneA, Edge)) || Overlaps(part.BoundingBox, Inflated(BackZone, Edge)));
|
|
Assert.True(hits > 0, "fixture should put parts on the clamps");
|
|
Assert.Equal(unclamped.Count - hits, clamped.Count);
|
|
Assert.All(clamped, part => Assert.Contains(unclamped, other => other.Location == part.Location));
|
|
AssertClearOfClamps(clamped, ZoneA, BackZone);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClampedFillShowsPreviewsThatCanBeAcceptedEarly()
|
|
{
|
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
|
var item = new NestItem { Drawing = RectDrawing(4, 4) };
|
|
using var cts = new CancellationTokenSource();
|
|
NestProgress first = null;
|
|
|
|
var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(),
|
|
new InlineProgress(update =>
|
|
{
|
|
first ??= update;
|
|
cts.Cancel();
|
|
}),
|
|
cts.Token);
|
|
|
|
Assert.NotNull(first);
|
|
Assert.True(first.IsOverallBest);
|
|
AssertClearOfClamps(first.BestParts, ZoneA, ZoneB);
|
|
Assert.Equal(first.BestParts.Count, parts.Count);
|
|
AssertClearOfClamps(parts, ZoneA, ZoneB);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClampFreeAreaMovesTheClampedEdgePastTheKeepOuts()
|
|
{
|
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
|
var work = plate.WorkArea();
|
|
|
|
var area = PlateFillService.ClampFreeArea(plate, work);
|
|
|
|
Assert.NotNull(area);
|
|
Assert.Equal(ZoneA.Top + Edge, area.Bottom, 9);
|
|
Assert.Equal(work.Top, area.Top, 9);
|
|
Assert.Equal(work.Left, area.Left, 9);
|
|
Assert.Equal(work.Right, area.Right, 9);
|
|
Assert.Null(PlateFillService.ClampFreeArea(plate, new Box(30, 5, 10, 10)));
|
|
}
|
|
|
|
[Fact]
|
|
public void SmallPartsKeepTheMaterialBetweenTheClamps()
|
|
{
|
|
// Losing a whole edge strip costs a row of small parts; dropping the few on the
|
|
// clamps costs less, so that layout wins.
|
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
|
var item = new NestItem { Drawing = RectDrawing(1, 1) };
|
|
|
|
var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(),
|
|
null, CancellationToken.None);
|
|
|
|
Assert.Contains(parts, part => part.BoundingBox.Bottom < ZoneA.Top + Edge);
|
|
AssertClearOfClamps(parts, ZoneA, ZoneB);
|
|
}
|
|
|
|
[Fact]
|
|
public void LargePartsMovePastTheClampStripInsteadOfLosingParts()
|
|
{
|
|
// Three 7-high rows still fit above the clamp strip, while the same rows on the
|
|
// edge would lose the parts on both clamps.
|
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
|
var item = new NestItem { Drawing = RectDrawing(10, 7) };
|
|
var strip = PlateFillService.ClampFreeArea(plate, plate.WorkArea());
|
|
var unclampedInStrip = PlateFillService.FillItem("Fill", MakePlate(), item, strip,
|
|
null, CancellationToken.None);
|
|
|
|
var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(),
|
|
null, CancellationToken.None);
|
|
|
|
Assert.Equal(unclampedInStrip.Count, parts.Count);
|
|
Assert.All(parts, part => Assert.True(part.BoundingBox.Bottom >= strip.Bottom - 1e-9));
|
|
AssertClearOfClamps(parts, ZoneA, ZoneB);
|
|
}
|
|
|
|
private sealed class InlineProgress(System.Action<NestProgress> report) : System.IProgress<NestProgress>
|
|
{
|
|
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)
|
|
{
|
|
var occupant = new Part(RectDrawing(6, 6), new Vector(38, 14));
|
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
|
plate.Parts.Add(occupant);
|
|
|
|
var items = new List<NestItem> { new() { Drawing = RectDrawing(4, 4), Quantity = 10 } };
|
|
var parts = PlateFillService.Nest(strategy, plate, items, null, CancellationToken.None);
|
|
|
|
Assert.Equal(10, parts.Count);
|
|
AssertClearOfClamps(parts, ZoneA, ZoneB);
|
|
}
|
|
}
|