mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-03 11:11:36 -05:00
feat(fill): keep interlocked pairs whole and place leftovers as pairs
A quantity-limited fill trimmed the parts farthest along the trim axis, which could take one half from each of two pairs. TrimToCount now finds interlocked pairs and keeps or drops them whole (from d1d68a91), so the spare space stays one usable cell. After a fill, leftovers now run in two orders, pack-first and pairs-first, and the strategy's comparer keeps the better whole layout, so its ranking of this step cannot get worse. Even quantities go in as best-fit pairs, may turn a quarter when unrestricted, are tried in every remnant and are ranked with the layout around them. Each placed pair slides left and down against true outlines to take the space its bounding box could not. On compare.nest Vertical Remnant goes from 6665.1 to 5862.6 and Lattice from 7200 to 5915.8. Nine characterization pins change from the trim with counts unchanged; the fill pins now also check layout validity. Vertical Remnant's golden layout shortens from 28 to 26. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
be4886f876
commit
4b2aba83bb
7 files changed
+376
-124
No files matched your search
@@ -27,6 +27,9 @@ namespace OpenNest.Engine.Tests.Jobs;
|
||||
/// processor count); fix that rather than repinning. The whole-plate Fill nests fill several
|
||||
/// irregular drawings with one filler, so later items use the angles
|
||||
/// <c>AngleCandidateBuilder.RecordProductive</c> learned from earlier ones.
|
||||
/// Re-captured when quantity trimming began keeping interlocked pairs whole
|
||||
/// (<c>ShrinkFiller.TrimToCount</c>): nine quantity-limited cases kept their counts but drop
|
||||
/// different copies. Whole-job leftovers trying pairs first changed none of these pins.
|
||||
/// Not pinned: richer mixes that pair parallelogram, chevron or tee parts at oblique angles
|
||||
/// with other irregular items. Their Fill and Vertical Remnant nests vary between runs, most
|
||||
/// likely because best-fit pair candidates are gathered in a <c>ConcurrentBag</c> and equal
|
||||
@@ -211,16 +214,22 @@ public class FillStackCharacterizationTests
|
||||
{
|
||||
var plate = MakePlate();
|
||||
var item = new NestItem { Drawing = new Drawing(shape, Shape(shape)), Quantity = 0 };
|
||||
return Layout(
|
||||
PlateFillService.FillItem(
|
||||
strategy,
|
||||
plate,
|
||||
item,
|
||||
plate.WorkArea(),
|
||||
null,
|
||||
CancellationToken.None
|
||||
)
|
||||
var parts = PlateFillService.FillItem(
|
||||
strategy,
|
||||
plate,
|
||||
item,
|
||||
plate.WorkArea(),
|
||||
null,
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Empty(NestLayoutCheck.Validate(
|
||||
new List<(Plate, List<Part>)> { (plate, parts) },
|
||||
new Dictionary<Drawing, (string Name, int Quantity)>
|
||||
{
|
||||
[item.Drawing] = (item.Drawing.Name, parts.Count),
|
||||
}
|
||||
));
|
||||
return Layout(parts);
|
||||
});
|
||||
|
||||
AssertPinned(layout, expectedCount, expectedDigest);
|
||||
@@ -281,7 +290,7 @@ public class FillStackCharacterizationTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("Fill", 30, "5732030365D13B9A")]
|
||||
[InlineData("Strip", 30, "A573BA6919FCD300")]
|
||||
[InlineData("Strip", 30, "666F8E276808A9C2")]
|
||||
[InlineData("Vertical Remnant", 30, "5732030365D13B9A")]
|
||||
[InlineData("Horizontal Remnant", 30, "C05F6A30A648B929")]
|
||||
public void Nest_LearnedAngleItems_PinnedLayout(
|
||||
@@ -315,10 +324,10 @@ public class FillStackCharacterizationTests
|
||||
// --- interactive fill beside clamps ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("Fill", 40, "9B3EFF9B72243576")]
|
||||
[InlineData("Strip", 40, "9B3EFF9B72243576")]
|
||||
[InlineData("Vertical Remnant", 40, "039C0D607166D805")]
|
||||
[InlineData("Horizontal Remnant", 40, "C1E81564A620302A")]
|
||||
[InlineData("Fill", 40, "5D07DCF3DC9B5F57")]
|
||||
[InlineData("Strip", 40, "5D07DCF3DC9B5F57")]
|
||||
[InlineData("Vertical Remnant", 40, "7072140D787044BD")]
|
||||
[InlineData("Horizontal Remnant", 40, "8FDDB0EA5D6D3620")]
|
||||
public void FillItem_WithClampZones_PinnedLayout(
|
||||
string strategy,
|
||||
int expectedCount,
|
||||
@@ -334,16 +343,22 @@ public class FillStackCharacterizationTests
|
||||
Drawing = new Drawing("lshape", TestDrawingFactory.LShape()),
|
||||
Quantity = 40,
|
||||
};
|
||||
return Layout(
|
||||
PlateFillService.FillItem(
|
||||
strategy,
|
||||
plate,
|
||||
item,
|
||||
plate.WorkArea(),
|
||||
null,
|
||||
CancellationToken.None
|
||||
)
|
||||
var parts = PlateFillService.FillItem(
|
||||
strategy,
|
||||
plate,
|
||||
item,
|
||||
plate.WorkArea(),
|
||||
null,
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Empty(NestLayoutCheck.Validate(
|
||||
new List<(Plate, List<Part>)> { (plate, parts) },
|
||||
new Dictionary<Drawing, (string Name, int Quantity)>
|
||||
{
|
||||
[item.Drawing] = (item.Drawing.Name, parts.Count),
|
||||
}
|
||||
));
|
||||
return Layout(parts);
|
||||
});
|
||||
|
||||
AssertPinned(layout, expectedCount, expectedDigest);
|
||||
@@ -368,10 +383,10 @@ public class FillStackCharacterizationTests
|
||||
);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Fill", 26, "64CE33B01B205978")]
|
||||
[InlineData("Strip", 26, "FD03A4529D37E32C")]
|
||||
[InlineData("Vertical Remnant", 26, "BDCA1DA111B396F0")]
|
||||
[InlineData("Horizontal Remnant", 26, "C356EC216CD56627")]
|
||||
[InlineData("Fill", 26, "0A7A67E19F821E84")]
|
||||
[InlineData("Strip", 26, "06ABFA7EBBF15472")]
|
||||
[InlineData("Vertical Remnant", 26, "0A7A67E19F821E84")]
|
||||
[InlineData("Horizontal Remnant", 26, "25DC495707C2BCA4")]
|
||||
public void FixedStrategyEngine_IrregularJob_PinnedLayout(
|
||||
string strategy,
|
||||
int expectedCount,
|
||||
|
||||
@@ -170,17 +170,17 @@ public class GoldenLayoutTests
|
||||
MixedJob("Vertical Remnant")
|
||||
);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
// Coincides with Default on this job; pinned independently so strategy divergence
|
||||
// after extraction is caught even where layouts agree today.
|
||||
// Pairs-first leftovers beat pack-first here (26 long, not 28); pinned independently
|
||||
// so strategy divergence after extraction is caught.
|
||||
AssertGolden(
|
||||
result,
|
||||
[
|
||||
P("arc", 0, 20, 1, 1.5707963267948966),
|
||||
P("arc", 1, 20, 7.5, 1.5707963267948966),
|
||||
P("arc", 2, 20, 14, 1.5707963267948966),
|
||||
P("lshape", 0, 19, 20.5, 1.5707963267948966),
|
||||
P("lshape", 1, 24, 20.5, 1.5707963267948966),
|
||||
P("lshape", 2, 25, 1, 1.5707963267948966),
|
||||
P("arc", 0, 21, 1, 1.5707963267948966),
|
||||
P("arc", 1, 21, 7.5, 1.5707963267948966),
|
||||
P("arc", 2, 21, 14, 1.5707963267948966),
|
||||
P("lshape", 0, 20, 20.5, 1.5707963267948966),
|
||||
P("lshape", 1, 25, 20.5, 1.5707963267948966),
|
||||
P("lshape", 2, 26, 1, 1.5707963267948966),
|
||||
P("rect-a", 0, 5, 21, 1.5707963267948966),
|
||||
P("rect-a", 1, 1, 1, 0),
|
||||
P("rect-a", 2, 1, 6, 0),
|
||||
@@ -189,12 +189,12 @@ public class GoldenLayoutTests
|
||||
P("rect-a", 5, 10, 21, 1.5707963267948966),
|
||||
P("rect-a", 6, 8, 1, 0),
|
||||
P("rect-a", 7, 8, 6, 0),
|
||||
P("rect-b", 0, 24, 8, 1.5707963267948966),
|
||||
P("rect-b", 1, 24, 13, 1.5707963267948966),
|
||||
P("rect-b", 2, 28, 8, 1.5707963267948966),
|
||||
P("rect-b", 3, 28, 13, 1.5707963267948966),
|
||||
P("rect-b", 4, 28, 18, 1.5707963267948966),
|
||||
P("rect-b", 5, 28, 23, 1.5707963267948966),
|
||||
P("rect-b", 0, 11, 11, 1.5707963267948966),
|
||||
P("rect-b", 1, 11, 16, 1.5707963267948966),
|
||||
P("rect-b", 2, 15, 11, 1.5707963267948966),
|
||||
P("rect-b", 3, 15, 16, 1.5707963267948966),
|
||||
P("rect-b", 4, 11, 21, 0),
|
||||
P("rect-b", 5, 11, 25, 0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the interlocked pairs in a fill pattern. Separate parts keep spacing apart, so only
|
||||
/// an interlocked pair's bounding boxes overlap; each part pairs with the one it overlaps most.
|
||||
/// </summary>
|
||||
internal static class InterlockedPairs
|
||||
{
|
||||
/// <returns>Each part's partner index, or -1 for a part outside any pair.</returns>
|
||||
internal static int[] Partners(IReadOnlyList<Box> boxes)
|
||||
{
|
||||
var partner = Enumerable.Repeat(-1, boxes.Count).ToArray();
|
||||
var overlaps = new List<(double Area, int A, int B)>();
|
||||
// Sweep by left edge: once a box starts past this one's right edge, so do the rest.
|
||||
var byLeft = Enumerable.Range(0, boxes.Count).OrderBy(i => boxes[i].Left).ThenBy(i => i).ToArray();
|
||||
for (var m = 0; m < byLeft.Length; m++)
|
||||
for (var n = m + 1; n < byLeft.Length && boxes[byLeft[n]].Left < boxes[byLeft[m]].Right; n++)
|
||||
{
|
||||
var i = System.Math.Min(byLeft[m], byLeft[n]);
|
||||
var j = System.Math.Max(byLeft[m], byLeft[n]);
|
||||
var a = boxes[i];
|
||||
var b = boxes[j];
|
||||
var length = System.Math.Min(a.Right, b.Right) - System.Math.Max(a.Left, b.Left);
|
||||
var width = System.Math.Min(a.Top, b.Top) - System.Math.Max(a.Bottom, b.Bottom);
|
||||
if (length > Tolerance.Epsilon && width > Tolerance.Epsilon)
|
||||
overlaps.Add((length * width, i, j));
|
||||
}
|
||||
foreach (var (_, a, b) in overlaps.OrderByDescending(o => o.Area).ThenBy(o => o.A).ThenBy(o => o.B))
|
||||
if (partner[a] < 0 && partner[b] < 0)
|
||||
{
|
||||
partner[a] = b;
|
||||
partner[b] = a;
|
||||
}
|
||||
return partner;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Engine.Jobs.Placement.Fillers;
|
||||
using OpenNest.Engine.RectanglePacking;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
@@ -161,6 +161,8 @@ namespace OpenNest.Engine.Fill
|
||||
/// <summary>
|
||||
/// Keeps the <paramref name="targetCount"/> parts nearest to the origin
|
||||
/// along the given axis, discarding parts farthest from the origin.
|
||||
/// Interlocked pairs are kept or discarded whole; a pair is split only when an
|
||||
/// odd count leaves no unpaired part to keep instead.
|
||||
/// Returns the input list unchanged if count is already at or below target.
|
||||
/// </summary>
|
||||
internal static List<Part> TrimToCount(List<Part> parts, int targetCount, ShrinkAxis axis)
|
||||
@@ -168,9 +170,34 @@ namespace OpenNest.Engine.Fill
|
||||
if (parts == null || parts.Count <= targetCount)
|
||||
return parts;
|
||||
|
||||
return axis == ShrinkAxis.Width
|
||||
? parts.OrderBy(p => p.BoundingBox.Right).Take(targetCount).ToList()
|
||||
: parts.OrderBy(p => p.BoundingBox.Top).Take(targetCount).ToList();
|
||||
var boxes = parts.Select(p => p.BoundingBox).ToArray();
|
||||
(double, double) Key(int i) => axis == ShrinkAxis.Width
|
||||
? (boxes[i].Right, boxes[i].Top)
|
||||
: (boxes[i].Top, boxes[i].Right);
|
||||
var partner = InterlockedPairs.Partners(boxes);
|
||||
// A pair's later member decides when it is discarded; within a unit, the nearer first.
|
||||
var units = Enumerable.Range(0, parts.Count)
|
||||
.Where(i => partner[i] < 0 || i < partner[i])
|
||||
.Select(i => (partner[i] < 0 ? new[] { i } : new[] { i, partner[i] })
|
||||
.OrderBy(Key).ThenBy(j => j).ToArray())
|
||||
.OrderBy(u => Key(u[^1])).ThenBy(u => u[0])
|
||||
.ToList();
|
||||
|
||||
var kept = new List<int>(targetCount);
|
||||
int[] split = null;
|
||||
foreach (var unit in units)
|
||||
{
|
||||
if (kept.Count == targetCount)
|
||||
break;
|
||||
if (kept.Count + unit.Length <= targetCount)
|
||||
kept.AddRange(unit);
|
||||
else
|
||||
split ??= unit;
|
||||
}
|
||||
if (kept.Count < targetCount)
|
||||
kept.Add(split[0]);
|
||||
|
||||
return kept.OrderBy(Key).ThenBy(i => i).Select(i => parts[i]).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,44 +73,38 @@ internal static class PlateFillOrchestrator
|
||||
|
||||
DeductPlacedQuantities(fillItems, fillParts);
|
||||
|
||||
var placedObstacles = fillParts
|
||||
.Select(part => part.BoundingBox.Offset(plate.PartSpacing))
|
||||
.ToList();
|
||||
placedObstacles.AddRange(clampKeepOuts);
|
||||
var finder = new RemnantFinder(workArea, placedObstacles);
|
||||
var remnants = finder.FindRemnants();
|
||||
if (remnants.Count > 0)
|
||||
workArea = remnants[0];
|
||||
else
|
||||
workArea = new Box(0, 0, 0, 0);
|
||||
workArea = LargestRemnant(plate, workArea, fillParts, clampKeepOuts);
|
||||
}
|
||||
}
|
||||
|
||||
packItems = packItems.Where(item => item.Quantity > 0).ToList();
|
||||
var pairItems = packItems.Where(item => item.Quantity == 2).ToList();
|
||||
var regularPackItems = packItems.Where(item => item.Quantity != 2).ToList();
|
||||
var leftovers = packItems.Where(item => item.Quantity > 0).ToList();
|
||||
|
||||
if (
|
||||
regularPackItems.Count > 0
|
||||
&& workArea.Width > 0
|
||||
&& workArea.Length > 0
|
||||
&& !token.IsCancellationRequested
|
||||
)
|
||||
if (leftovers.Count > 0 && !token.IsCancellationRequested)
|
||||
{
|
||||
var packParts = pack(workArea, regularPackItems, progress, token);
|
||||
var packFirst = PackThenPairs(plate, leftovers, allParts, workArea, comparer, pack,
|
||||
clampKeepOuts, progress, token);
|
||||
var chosen = packFirst;
|
||||
|
||||
if (packParts.Count > 0)
|
||||
// Neither order wins every job: pairs reach the gaps a fill leaves before singles
|
||||
// take them, but plain rectangles often pack tighter one at a time. The strategy's
|
||||
// own ranking of the whole layout decides, and a tie keeps pack-first.
|
||||
if (
|
||||
leftovers.Any(IsPairable)
|
||||
&& !token.IsCancellationRequested
|
||||
)
|
||||
{
|
||||
allParts.AddRange(packParts);
|
||||
|
||||
DeductPlacedQuantities(regularPackItems, packParts);
|
||||
var pairsFirst = PairsThenPack(plate, leftovers, allParts, workArea, comparer, pack,
|
||||
clampKeepOuts, token);
|
||||
if (comparer.IsBetter(
|
||||
allParts.Concat(pairsFirst).ToList(),
|
||||
allParts.Concat(packFirst).ToList(),
|
||||
plate.WorkArea()
|
||||
))
|
||||
chosen = pairsFirst;
|
||||
}
|
||||
}
|
||||
|
||||
if (pairItems.Count > 0 && !token.IsCancellationRequested)
|
||||
{
|
||||
var placed = PlaceBestFitPairs(plate, comparer, pairItems, allParts, plate.WorkArea(), clampKeepOuts);
|
||||
allParts.AddRange(placed);
|
||||
allParts.AddRange(chosen);
|
||||
DeductPlacedQuantities(leftovers, chosen);
|
||||
}
|
||||
|
||||
Compactor.Settle(allParts, plate);
|
||||
@@ -118,6 +112,97 @@ internal static class PlateFillOrchestrator
|
||||
return allParts;
|
||||
}
|
||||
|
||||
private static bool IsPairable(NestItem item) => item.Quantity >= 2 && item.Quantity % 2 == 0;
|
||||
|
||||
private static List<NestItem> Copies(IEnumerable<NestItem> items) =>
|
||||
items.Select(item => new NestItem
|
||||
{
|
||||
Drawing = item.Drawing,
|
||||
Priority = item.Priority,
|
||||
Quantity = item.Quantity,
|
||||
StepAngle = item.StepAngle,
|
||||
RotationStart = item.RotationStart,
|
||||
RotationEnd = item.RotationEnd,
|
||||
}).ToList();
|
||||
|
||||
/// <summary>Packs everything but quantity-two items, then places those as pairs.</summary>
|
||||
private static List<Part> PackThenPairs(
|
||||
Plate plate,
|
||||
List<NestItem> leftovers,
|
||||
List<Part> existingParts,
|
||||
Box workArea,
|
||||
IFillComparer comparer,
|
||||
Func<Box, List<NestItem>, IProgress<NestProgress>, CancellationToken, List<Part>> pack,
|
||||
IReadOnlyList<Box> clampKeepOuts,
|
||||
IProgress<NestProgress> progress,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
var items = Copies(leftovers);
|
||||
var placed = new List<Part>();
|
||||
var packItems = items.Where(item => item.Quantity != 2).ToList();
|
||||
|
||||
if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0)
|
||||
placed.AddRange(pack(workArea, packItems, progress, token));
|
||||
|
||||
var pairItems = items.Where(item => item.Quantity == 2).ToList();
|
||||
if (pairItems.Count > 0 && !token.IsCancellationRequested)
|
||||
placed.AddRange(PlaceBestFitPairs(plate, comparer, pairItems,
|
||||
existingParts.Concat(placed).ToList(), plate.WorkArea(), clampKeepOuts));
|
||||
|
||||
return placed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places every even quantity as pairs, largest box first so a part that fits a gap reaches
|
||||
/// it before a smaller one takes it, then packs the rest around them.
|
||||
/// </summary>
|
||||
private static List<Part> PairsThenPack(
|
||||
Plate plate,
|
||||
List<NestItem> leftovers,
|
||||
List<Part> existingParts,
|
||||
Box workArea,
|
||||
IFillComparer comparer,
|
||||
Func<Box, List<NestItem>, IProgress<NestProgress>, CancellationToken, List<Part>> pack,
|
||||
IReadOnlyList<Box> clampKeepOuts,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
var items = Copies(leftovers);
|
||||
var pairItems = items
|
||||
.Where(IsPairable)
|
||||
.OrderByDescending(item => item.Drawing.Program.BoundingBox().Area())
|
||||
.ToList();
|
||||
|
||||
var placed = PlaceBestFitPairs(plate, comparer, pairItems, existingParts, plate.WorkArea(), clampKeepOuts);
|
||||
if (placed.Count > 0)
|
||||
workArea = LargestRemnant(plate, plate.WorkArea(), existingParts.Concat(placed), clampKeepOuts);
|
||||
|
||||
var packItems = items.Where(item => item.Quantity > 0).ToList();
|
||||
if (
|
||||
packItems.Count > 0
|
||||
&& workArea.Width > 0
|
||||
&& workArea.Length > 0
|
||||
&& !token.IsCancellationRequested
|
||||
)
|
||||
placed.AddRange(pack(workArea, packItems, null, token));
|
||||
|
||||
return placed;
|
||||
}
|
||||
|
||||
private static Box LargestRemnant(
|
||||
Plate plate,
|
||||
Box area,
|
||||
IEnumerable<Part> parts,
|
||||
IReadOnlyList<Box> clampKeepOuts
|
||||
)
|
||||
{
|
||||
var obstacles = parts.Select(part => part.BoundingBox.Offset(plate.PartSpacing)).ToList();
|
||||
obstacles.AddRange(clampKeepOuts);
|
||||
var remnants = new RemnantFinder(area, obstacles).FindRemnants();
|
||||
return remnants.Count > 0 ? remnants[0] : new Box(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
private static List<Part> BetterWithClampHitsDropped(
|
||||
Plate plate,
|
||||
NestItem item,
|
||||
@@ -167,12 +252,10 @@ internal static class PlateFillOrchestrator
|
||||
if (clampKeepOuts != null)
|
||||
obstacles.AddRange(clampKeepOuts);
|
||||
var finder = new RemnantFinder(fullWorkArea, obstacles);
|
||||
var layout = new List<Part>(existingParts);
|
||||
|
||||
foreach (var item in pairItems)
|
||||
{
|
||||
if (item.Quantity < 2)
|
||||
continue;
|
||||
|
||||
var bestFits = BestFitCache.GetOrCompute(
|
||||
item.Drawing,
|
||||
plate.Size.Length,
|
||||
@@ -182,67 +265,114 @@ internal static class PlateFillOrchestrator
|
||||
|
||||
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing);
|
||||
|
||||
List<Part> bestPlacement = null;
|
||||
Box bestTarget = null;
|
||||
// A part free to rotate may lie along either sheet axis as a pair.
|
||||
var turns = item.StepAngle == 0 && item.RotationStart == 0 && item.RotationEnd == 0
|
||||
? new[] { 0, Angle.HalfPI }
|
||||
: new[] { 0.0 };
|
||||
|
||||
foreach (var fit in bestFits)
|
||||
while (item.Quantity >= 2)
|
||||
{
|
||||
if (!fit.Keep)
|
||||
continue;
|
||||
List<Part> bestPlacement = null;
|
||||
Box bestTarget = null;
|
||||
|
||||
var parts = fit.BuildParts(canonicalDrawing);
|
||||
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||
var pairWidth = pairBbox.Width;
|
||||
var pairLength = pairBbox.Length;
|
||||
var minDimension = System.Math.Min(pairWidth, pairLength);
|
||||
|
||||
var remnants = finder.FindRemnants(minDimension);
|
||||
|
||||
foreach (var remnant in remnants)
|
||||
foreach (var fit in bestFits)
|
||||
{
|
||||
if (
|
||||
pairWidth <= remnant.Width + Tolerance.Epsilon
|
||||
&& pairLength <= remnant.Length + Tolerance.Epsilon
|
||||
)
|
||||
{
|
||||
var offset = remnant.Location - pairBbox.Location;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
part.Offset(offset);
|
||||
part.UpdateBounds();
|
||||
}
|
||||
if (!fit.Keep)
|
||||
continue;
|
||||
|
||||
if (bestPlacement == null || comparer.IsBetter(parts, bestPlacement, remnant))
|
||||
foreach (var turn in turns)
|
||||
{
|
||||
var probe = ((IEnumerable<IBoundable>)BuildPair(fit, canonicalDrawing, turn)).GetBoundingBox();
|
||||
var pairWidth = probe.Width;
|
||||
var pairLength = probe.Length;
|
||||
|
||||
foreach (var remnant in finder.FindRemnants(System.Math.Min(pairWidth, pairLength)))
|
||||
{
|
||||
bestPlacement = parts;
|
||||
bestTarget = remnant;
|
||||
if (
|
||||
pairWidth > remnant.Width + Tolerance.Epsilon
|
||||
|| pairLength > remnant.Length + Tolerance.Epsilon
|
||||
)
|
||||
continue;
|
||||
|
||||
var parts = BuildPair(fit, canonicalDrawing, turn);
|
||||
var offset = remnant.Location - probe.Location;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
part.Offset(offset);
|
||||
part.UpdateBounds();
|
||||
}
|
||||
|
||||
// Ranked with the layout around it: alone, a pair's own extent says
|
||||
// nothing about whether it stays inside the layout or lengthens it.
|
||||
if (
|
||||
bestPlacement == null
|
||||
|| comparer.IsBetter(layout.Concat(parts).ToList(), layout.Concat(bestPlacement).ToList(), fullWorkArea)
|
||||
)
|
||||
{
|
||||
bestPlacement = parts;
|
||||
bestTarget = remnant;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestPlacement == null)
|
||||
break;
|
||||
|
||||
bestPlacement = CanonicalFrame.RebindToOriginal(bestPlacement, item.Drawing);
|
||||
CompactPair(bestPlacement, layout, plate, fullWorkArea);
|
||||
|
||||
result.AddRange(bestPlacement);
|
||||
layout.AddRange(bestPlacement);
|
||||
item.Quantity -= 2;
|
||||
|
||||
var envelope = ((IEnumerable<IBoundable>)bestPlacement).GetBoundingBox();
|
||||
finder.AddObstacle(envelope.Offset(plate.PartSpacing));
|
||||
|
||||
Debug.WriteLine(
|
||||
$"[Nest] Placed best-fit pair for {item.Drawing.Name} "
|
||||
+ $"at ({bestTarget.X:F1},{bestTarget.Y:F1}), "
|
||||
+ $"size {envelope.Width:F1}x{envelope.Length:F1}"
|
||||
);
|
||||
}
|
||||
|
||||
if (bestPlacement == null)
|
||||
continue;
|
||||
|
||||
bestPlacement = CanonicalFrame.RebindToOriginal(bestPlacement, item.Drawing);
|
||||
|
||||
result.AddRange(bestPlacement);
|
||||
item.Quantity = 0;
|
||||
|
||||
var envelope = ((IEnumerable<IBoundable>)bestPlacement).GetBoundingBox();
|
||||
finder.AddObstacle(envelope.Offset(plate.PartSpacing));
|
||||
|
||||
Debug.WriteLine(
|
||||
$"[Nest] Placed best-fit pair for {item.Drawing.Name} "
|
||||
+ $"at ({bestTarget.X:F1},{bestTarget.Y:F1}), "
|
||||
+ $"size {envelope.Width:F1}x{envelope.Length:F1}"
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slides a pair placed by its bounding box left then down against the true outlines around
|
||||
/// it, so it takes the space its box could not. The whole-layout settle cannot: it moves
|
||||
/// everything as one group. Clamp keep-outs stay walls, here at the part spacing.
|
||||
/// </summary>
|
||||
private static void CompactPair(List<Part> pair, List<Part> layout, Plate plate, Box workArea)
|
||||
{
|
||||
const double threshold = 0.01;
|
||||
const int maxIterations = 20;
|
||||
|
||||
var obstacles = layout.Concat(plate.Parts).Concat(InteractiveClamps.ObstacleParts(plate)).ToList();
|
||||
|
||||
for (var i = 0; i < maxIterations; i++)
|
||||
{
|
||||
var moved = Compactor.Push(pair, obstacles, workArea, plate.PartSpacing, PushDirection.Left)
|
||||
+ Compactor.Push(pair, obstacles, workArea, plate.PartSpacing, PushDirection.Down);
|
||||
if (moved < threshold)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Part> BuildPair(BestFitResult fit, Drawing drawing, double turn)
|
||||
{
|
||||
var parts = fit.BuildParts(drawing);
|
||||
if (turn != 0)
|
||||
foreach (var part in parts)
|
||||
{
|
||||
part.Rotate(turn, new Vector());
|
||||
part.UpdateBounds();
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
private static bool ShouldFill(NestItem item, Plate plate, double plateArea)
|
||||
{
|
||||
if (item.Quantity <= 1)
|
||||
|
||||
@@ -137,6 +137,42 @@ public class ShrinkFillerTests
|
||||
Assert.Same(parts, fewer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimToCount_KeepsInterlockedPairsWhole()
|
||||
{
|
||||
// Overlapping bounding boxes mark an interlocked pair: (0, 1) and (10, 11).
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(1, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
TestHelpers.MakePartAt(11, 0, 5),
|
||||
TestHelpers.MakePartAt(30, 0, 5),
|
||||
};
|
||||
|
||||
var trimmed = ShrinkFiller.TrimToCount(parts, 3, ShrinkAxis.Width);
|
||||
|
||||
Assert.Equal(new[] { parts[0], parts[1], parts[4] }, trimmed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimToCount_SplitsTheNearestPairOnlyWithoutAnUnpairedPart()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(1, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
TestHelpers.MakePartAt(11, 0, 5),
|
||||
TestHelpers.MakePartAt(20, 0, 5),
|
||||
TestHelpers.MakePartAt(21, 0, 5),
|
||||
};
|
||||
|
||||
var trimmed = ShrinkFiller.TrimToCount(parts, 3, ShrinkAxis.Width);
|
||||
|
||||
Assert.Equal(new[] { parts[0], parts[1], parts[2] }, trimmed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimToCount_DoesNotMutateInput()
|
||||
{
|
||||
|
||||
@@ -16,7 +16,9 @@ The category covers comparer, group-pattern, rotated-pattern, extents-column, fe
|
||||
Irregular's private block proposals opt into quantity-limited Row/Column stripe fills.
|
||||
`FillLinear.MaxParts` is a soft row limit: the first primary row stays complete, and
|
||||
perpendicular copies stop after reaching the requested count. The final fill still
|
||||
trims its winner to the exact quantity. Existing overlap fallbacks and block spacing
|
||||
trims its winner to the exact quantity, keeping or dropping interlocked pairs whole
|
||||
(`ShrinkFiller.TrimToCount`; a pair is split only for an odd count with no unpaired part
|
||||
left). Existing overlap fallbacks and block spacing
|
||||
certification remain active; fallback tiling can exceed the soft limit. Nonpositive
|
||||
limits remain uncapped. Ordinary Fill-strategy fills and the zero-quantity cutout lattice
|
||||
keep their full-area behavior.
|
||||
|
||||
Reference in new issue
Block a user