Files
OpenNest/OpenNest.Engine/Fill/IterativeShrinkFiller.cs
T
ajandClaude Opus 5.5 7dc471bbf8 fix(engine): keep Strip layouts clear of clamp keep-outs
The Strip whole-job engine laid its strips, settled them and picked its pack
remnant from the bare work area, never adding the plate's clamp keep-outs the
Lattice orchestration uses. On a clamped sheet it put parts over the clamp, the
runner's layout check rejected the candidate, and Strip produced nothing (N26-2A5
rev 5: door stop at (21.09,1.50) over clamp-1).

NestStrips now treats InteractiveClamps.ClearanceRectangles as occupied space for
the iterative shrink strips (through RemnantFiller), settles with the clamp
obstacles when there are zones, and finds the pack remnant around them even
before any strip is placed. Unclamped plates take the old path unchanged.

A FixedStrategyNestingEngine theory runs all four fixed strategies on a clamped
26 x 26 sheet; only Strip failed it before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:59:21 -05:00

330 lines
12 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Engine.Jobs.Placement.Fillers;
using OpenNest.Geometry;
namespace OpenNest.Engine.Fill
{
/// <summary>
/// Result returned by <see cref="IterativeShrinkFiller.Fill"/>.
/// </summary>
public class IterativeShrinkResult
{
public List<Part> Parts { get; set; } = new List<Part>();
public List<NestItem> Leftovers { get; set; } = new List<NestItem>();
}
/// <summary>
/// Composes <see cref="RemnantFiller"/> and <see cref="ShrinkFiller"/> with
/// dual-direction shrink selection. Wraps the caller's fill function in a
/// closure that tries both <see cref="ShrinkAxis.Length"/> and
/// <see cref="ShrinkAxis.Width"/>, picks the better <see cref="FillScore"/>,
/// and passes the wrapper to <see cref="RemnantFiller.FillItems"/>.
/// </summary>
public static class IterativeShrinkFiller
{
public static IterativeShrinkResult Fill(
List<NestItem> items,
Box workArea,
Func<NestItem, Box, List<Part>> fillFunc,
double spacing,
CancellationToken token = default,
IProgress<NestProgress> progress = null,
int plateNumber = 0,
Func<NestItem, Box, List<Part>> widthFillFunc = null,
IEnumerable<Box> keepOuts = null
)
{
if (items == null || items.Count == 0)
return new IterativeShrinkResult();
// RemnantFiller.FillItems skips items with Quantity == 0 (its localQty
// check treats them as done). Convert unlimited items (Quantity <= 0)
// to an estimated max capacity so they are actually processed.
var workItems = new List<NestItem>(items.Count);
foreach (var item in items)
{
if (item.Quantity <= 0)
{
var bbox = item.Drawing.Program.BoundingBox();
var estimatedMax =
bbox.Area() > 0 ? (int)(workArea.Area() / bbox.Area()) * 2 : 1000;
workItems.Add(
new NestItem
{
Drawing = item.Drawing,
Quantity = System.Math.Max(1, estimatedMax),
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
}
);
}
else
{
workItems.Add(item);
}
}
var filler = new RemnantFiller(workArea, spacing, keepOuts);
// Track parts placed by previous items so ShrinkFiller can
// include them in progress reports.
var placedSoFar = new List<Part>();
var wFillFunc = widthFillFunc ?? fillFunc;
Func<NestItem, Box, List<Part>> shrinkWrapper = (ni, box) =>
{
var target = ni.Quantity > 0 ? ni.Quantity : 0;
// Run height and width shrinks in parallel — they are independent
// (same inputs, no shared mutable state).
ShrinkResult heightResult = null;
ShrinkResult widthResult = null;
Parallel.Invoke(
() =>
heightResult = ShrinkFiller.Shrink(
fillFunc,
ni,
box,
spacing,
ShrinkAxis.Length,
token,
targetCount: target,
progress: progress,
plateNumber: plateNumber,
placedParts: placedSoFar
),
() =>
widthResult = ShrinkFiller.Shrink(
wFillFunc,
ni,
box,
spacing,
ShrinkAxis.Width,
token,
targetCount: target,
progress: progress,
plateNumber: plateNumber,
placedParts: placedSoFar
)
);
var heightScore = FillScore.Compute(heightResult.Parts, box);
var widthScore = FillScore.Compute(widthResult.Parts, box);
var best = widthScore > heightScore ? widthResult.Parts : heightResult.Parts;
// Sort pair groups so shorter/narrower groups are closer to the origin,
// creating a staircase profile that maximizes remnant area.
// Height shrink → columns vary in height → sort columns.
// Width shrink → rows vary in width → sort rows.
if (widthScore > heightScore)
SortRowsByWidth(best, spacing);
else
SortColumnsByHeight(best, spacing);
// Report the winner as overall best so the UI shows it as settled.
if (progress != null && best != null && best.Count > 0)
{
var allParts = new List<Part>(placedSoFar.Count + best.Count);
allParts.AddRange(placedSoFar);
allParts.AddRange(best);
NestProgressReporter.Report(
progress,
new ProgressReport
{
Phase = NestPhase.Custom,
PlateNumber = plateNumber,
Parts = allParts,
WorkArea = box,
Description = $"Shrink: {best.Count} parts placed",
IsOverallBest = true,
}
);
}
// Accumulate for the next item's progress reports.
placedSoFar.AddRange(best);
return best;
};
var placed = filler.FillItems(workItems, shrinkWrapper, token);
// Build leftovers: compare placed count to original quantities by drawing
// reference. RemnantFiller.FillItems does NOT mutate NestItem.Quantity.
var leftovers = new List<NestItem>();
foreach (var item in items)
{
var placedCount = placed.Count(p => ReferenceEquals(p.BaseDrawing, item.Drawing));
if (item.Quantity <= 0)
continue; // unlimited items are always "satisfied" — no leftover
var remaining = item.Quantity - placedCount;
if (remaining > 0)
{
leftovers.Add(
new NestItem
{
Drawing = item.Drawing,
Quantity = remaining,
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
}
);
}
}
return new IterativeShrinkResult { Parts = placed, Leftovers = leftovers };
}
/// <summary>
/// Sorts pair columns by height (shortest first on the left) to create
/// a staircase profile that maximizes usable remnant area.
/// </summary>
internal static void SortColumnsByHeight(List<Part> parts, double spacing) =>
SortStrips(
parts,
spacing,
primaryEdge: b => b.Left,
extentEdge: b => b.Right,
sortMetric: MaxTop,
stripMin: MinLeft,
stripMax: MaxRight,
makeOffset: d => new Vector(d, 0)
);
/// <summary>
/// Sorts pair rows by width (narrowest first on the bottom) to create
/// a staircase profile on the right side that maximizes usable remnant area.
/// </summary>
internal static void SortRowsByWidth(List<Part> parts, double spacing) =>
SortStrips(
parts,
spacing,
primaryEdge: b => b.Bottom,
extentEdge: b => b.Top,
sortMetric: MaxRight,
stripMin: MinBottom,
stripMax: MaxTop,
makeOffset: d => new Vector(0, d)
);
private static void SortStrips(
List<Part> parts,
double spacing,
Func<Box, double> primaryEdge,
Func<Box, double> extentEdge,
Func<List<Part>, double> sortMetric,
Func<List<Part>, double> stripMin,
Func<List<Part>, double> stripMax,
Func<double, Vector> makeOffset
)
{
if (parts == null || parts.Count <= 1)
return;
parts.Sort((a, b) => primaryEdge(a.BoundingBox).CompareTo(primaryEdge(b.BoundingBox)));
var strips = new List<List<Part>>();
var strip = new List<Part> { parts[0] };
var stripExtent = extentEdge(parts[0].BoundingBox);
for (var i = 1; i < parts.Count; i++)
{
if (primaryEdge(parts[i].BoundingBox) > stripExtent + spacing / 2)
{
strips.Add(strip);
strip = new List<Part> { parts[i] };
stripExtent = extentEdge(parts[i].BoundingBox);
}
else
{
strip.Add(parts[i]);
var extent = extentEdge(parts[i].BoundingBox);
if (extent > stripExtent)
stripExtent = extent;
}
}
strips.Add(strip);
if (strips.Count <= 1)
return;
// Use the required clearance as the inter-strip gap, not a gap sampled from one
// original pair: actual placement gaps vary for irregular/mixed-size geometry, and
// replaying a larger sampled gap across every reordered pair can push the trailing
// strip past the original (already plate-fitted) footprint.
strips.Sort((a, b) => sortMetric(a).CompareTo(sortMetric(b)));
var pos = primaryEdge(parts[0].BoundingBox);
foreach (var s in strips)
{
var delta = pos - stripMin(s);
if (System.Math.Abs(delta) > OpenNest.Math.Tolerance.Epsilon)
{
var offset = makeOffset(delta);
foreach (var part in s)
part.Offset(offset);
}
pos = stripMax(s) + spacing;
}
parts.Clear();
foreach (var s in strips)
parts.AddRange(s);
}
private static double MaxTop(List<Part> col)
{
var max = double.MinValue;
foreach (var p in col)
if (p.BoundingBox.Top > max)
max = p.BoundingBox.Top;
return max;
}
private static double MaxRight(List<Part> col)
{
var max = double.MinValue;
foreach (var p in col)
if (p.BoundingBox.Right > max)
max = p.BoundingBox.Right;
return max;
}
private static double MinLeft(List<Part> col)
{
var min = double.MaxValue;
foreach (var p in col)
if (p.BoundingBox.Left < min)
min = p.BoundingBox.Left;
return min;
}
private static double MinBottom(List<Part> row)
{
var min = double.MaxValue;
foreach (var p in row)
if (p.BoundingBox.Bottom < min)
min = p.BoundingBox.Bottom;
return min;
}
}
}