Compare commits
27 Commits
0f953b8701
...
2cb2808c79
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cb2808c79 | |||
| e969260f3d | |||
| 8bfc13d529 | |||
| ca35945c13 | |||
| fab2214149 | |||
| e3b89f2660 | |||
| 1e9640d4fc | |||
| 116a386152 | |||
| 8957b20bac | |||
| c31ef9f80c | |||
| 3b6e4bdd3a | |||
| ef737ffa6d | |||
| 1bc635acde | |||
| ed555ba56a | |||
| 20aa172f46 | |||
| 9a58782c46 | |||
| e656956c1c | |||
| f13443b6b3 | |||
| a7688f4c9d | |||
| e324e15fc0 | |||
| d7cc08dff7 | |||
| 1c8b35bcfb | |||
| 84679b40ce | |||
| b6bd7eda6e | |||
| cfe8a38620 | |||
| 4be0b0db09 | |||
| 2f5d20f972 |
15
OpenNest.Api/NestRequest.cs
Normal file
15
OpenNest.Api/NestRequest.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public class NestRequest
|
||||
{
|
||||
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
|
||||
public Size SheetSize { get; init; } = new(60, 120);
|
||||
public string Material { get; init; } = "Steel, A1011 HR";
|
||||
public double Thickness { get; init; } = 0.06;
|
||||
public double Spacing { get; init; } = 0.1;
|
||||
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
|
||||
public CutParameters Cutting { get; init; } = CutParameters.Default;
|
||||
}
|
||||
9
OpenNest.Api/NestRequestPart.cs
Normal file
9
OpenNest.Api/NestRequestPart.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public class NestRequestPart
|
||||
{
|
||||
public string DxfPath { get; init; }
|
||||
public int Quantity { get; init; } = 1;
|
||||
public bool AllowRotation { get; init; } = true;
|
||||
public int Priority { get; init; } = 0;
|
||||
}
|
||||
112
OpenNest.Api/NestResponse.cs
Normal file
112
OpenNest.Api/NestResponse.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public class NestResponse
|
||||
{
|
||||
public int SheetCount { get; init; }
|
||||
public double Utilization { get; init; }
|
||||
public TimeSpan CutTime { get; init; }
|
||||
public TimeSpan Elapsed { get; init; }
|
||||
public Nest Nest { get; init; }
|
||||
public NestRequest Request { get; init; }
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
IncludeFields = true // Required for OpenNest.Geometry.Size (public fields)
|
||||
};
|
||||
|
||||
public async Task SaveAsync(string path)
|
||||
{
|
||||
using var fs = new FileStream(path, FileMode.Create);
|
||||
using var zip = new ZipArchive(fs, ZipArchiveMode.Create);
|
||||
|
||||
// Write request.json
|
||||
var requestEntry = zip.CreateEntry("request.json");
|
||||
await using (var stream = requestEntry.Open())
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, Request, JsonOptions);
|
||||
}
|
||||
|
||||
// Write response.json (metrics only)
|
||||
var metrics = new
|
||||
{
|
||||
SheetCount,
|
||||
Utilization,
|
||||
CutTimeTicks = CutTime.Ticks,
|
||||
ElapsedTicks = Elapsed.Ticks
|
||||
};
|
||||
var responseEntry = zip.CreateEntry("response.json");
|
||||
await using (var stream = responseEntry.Open())
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, metrics, JsonOptions);
|
||||
}
|
||||
|
||||
// Write embedded nest.nest via NestWriter → MemoryStream → ZIP entry
|
||||
var nestEntry = zip.CreateEntry("nest.nest");
|
||||
using var nestMs = new MemoryStream();
|
||||
var writer = new NestWriter(Nest);
|
||||
writer.Write(nestMs);
|
||||
nestMs.Position = 0;
|
||||
await using (var stream = nestEntry.Open())
|
||||
{
|
||||
await nestMs.CopyToAsync(stream);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<NestResponse> LoadAsync(string path)
|
||||
{
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
|
||||
using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
|
||||
|
||||
// Read request.json
|
||||
var requestEntry = zip.GetEntry("request.json")
|
||||
?? throw new InvalidOperationException("Missing request.json in .nestquote file");
|
||||
NestRequest request;
|
||||
await using (var stream = requestEntry.Open())
|
||||
{
|
||||
request = await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions);
|
||||
}
|
||||
|
||||
// Read response.json
|
||||
var responseEntry = zip.GetEntry("response.json")
|
||||
?? throw new InvalidOperationException("Missing response.json in .nestquote file");
|
||||
JsonElement metricsJson;
|
||||
await using (var stream = responseEntry.Open())
|
||||
{
|
||||
metricsJson = await JsonSerializer.DeserializeAsync<JsonElement>(stream, JsonOptions);
|
||||
}
|
||||
|
||||
// Read embedded nest.nest via NestReader(Stream)
|
||||
var nestEntry = zip.GetEntry("nest.nest")
|
||||
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
|
||||
Nest nest;
|
||||
using (var nestMs = new MemoryStream())
|
||||
{
|
||||
await using (var stream = nestEntry.Open())
|
||||
{
|
||||
await stream.CopyToAsync(nestMs);
|
||||
}
|
||||
nestMs.Position = 0;
|
||||
var reader = new NestReader(nestMs);
|
||||
nest = reader.Read();
|
||||
}
|
||||
|
||||
return new NestResponse
|
||||
{
|
||||
SheetCount = metricsJson.GetProperty("sheetCount").GetInt32(),
|
||||
Utilization = metricsJson.GetProperty("utilization").GetDouble(),
|
||||
CutTime = TimeSpan.FromTicks(metricsJson.GetProperty("cutTimeTicks").GetInt64()),
|
||||
Elapsed = TimeSpan.FromTicks(metricsJson.GetProperty("elapsedTicks").GetInt64()),
|
||||
Nest = nest,
|
||||
Request = request
|
||||
};
|
||||
}
|
||||
}
|
||||
131
OpenNest.Api/NestRunner.cs
Normal file
131
OpenNest.Api/NestRunner.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public static class NestRunner
|
||||
{
|
||||
public static Task<NestResponse> RunAsync(
|
||||
NestRequest request,
|
||||
IProgress<NestProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
if (request.Parts.Count == 0)
|
||||
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// 1. Import DXFs → Drawings
|
||||
var drawings = new List<Drawing>();
|
||||
var importer = new DxfImporter();
|
||||
|
||||
foreach (var part in request.Parts)
|
||||
{
|
||||
if (!File.Exists(part.DxfPath))
|
||||
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
|
||||
|
||||
if (!importer.GetGeometry(part.DxfPath, out var geometry) || geometry.Count == 0)
|
||||
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
|
||||
var drawing = new Drawing(name);
|
||||
drawing.Program = pgm;
|
||||
drawings.Add(drawing);
|
||||
}
|
||||
|
||||
// 2. Build NestItems
|
||||
var items = new List<NestItem>();
|
||||
for (var i = 0; i < request.Parts.Count; i++)
|
||||
{
|
||||
var part = request.Parts[i];
|
||||
items.Add(new NestItem
|
||||
{
|
||||
Drawing = drawings[i],
|
||||
Quantity = part.Quantity,
|
||||
Priority = part.Priority,
|
||||
StepAngle = part.AllowRotation ? 0 : OpenNest.Math.Angle.TwoPI,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Multi-plate loop
|
||||
var nest = new Nest();
|
||||
var remaining = items.Select(item => item.Quantity).ToList();
|
||||
|
||||
while (remaining.Any(q => q > 0))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var plate = new Plate(request.SheetSize)
|
||||
{
|
||||
Thickness = request.Thickness,
|
||||
PartSpacing = request.Spacing,
|
||||
Material = new Material(request.Material)
|
||||
};
|
||||
|
||||
// Build items for this pass with remaining quantities
|
||||
var passItems = new List<NestItem>();
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
if (remaining[i] <= 0) continue;
|
||||
passItems.Add(new NestItem
|
||||
{
|
||||
Drawing = items[i].Drawing,
|
||||
Quantity = remaining[i],
|
||||
Priority = items[i].Priority,
|
||||
StepAngle = items[i].StepAngle,
|
||||
});
|
||||
}
|
||||
|
||||
// Run engine
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var parts = engine.Nest(passItems, progress, token);
|
||||
|
||||
if (parts.Count == 0)
|
||||
break; // No progress — part doesn't fit on fresh sheet
|
||||
|
||||
// Add parts to plate and nest
|
||||
foreach (var p in parts)
|
||||
plate.Parts.Add(p);
|
||||
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
// Deduct placed quantities
|
||||
foreach (var p in parts)
|
||||
{
|
||||
var idx = drawings.IndexOf(p.BaseDrawing);
|
||||
if (idx >= 0)
|
||||
remaining[idx]--;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Compute timing
|
||||
var timingInfo = Timing.GetTimingInfo(nest);
|
||||
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// 5. Build response
|
||||
var response = new NestResponse
|
||||
{
|
||||
SheetCount = nest.Plates.Count,
|
||||
Utilization = nest.Plates.Count > 0
|
||||
? nest.Plates.Average(p => p.Utilization())
|
||||
: 0,
|
||||
CutTime = cutTime,
|
||||
Elapsed = sw.Elapsed,
|
||||
Nest = nest,
|
||||
Request = request
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
3
OpenNest.Api/NestStrategy.cs
Normal file
3
OpenNest.Api/NestStrategy.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public enum NestStrategy { Auto }
|
||||
12
OpenNest.Api/OpenNest.Api.csproj
Normal file
12
OpenNest.Api/OpenNest.Api.csproj
Normal file
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>OpenNest.Api</RootNamespace>
|
||||
<AssemblyName>OpenNest.Api</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -191,7 +191,7 @@ static class NestConsole
|
||||
// DXF-only mode: create a fresh nest.
|
||||
if (dxfFiles.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: no nest (.opnest) or DXF (.dxf) files specified");
|
||||
Console.Error.WriteLine("Error: no nest (.nest) or DXF (.dxf) files specified");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -387,12 +387,12 @@ static class NestConsole
|
||||
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Arguments:");
|
||||
Console.Error.WriteLine(" input-files One or more .opnest nest files or .dxf drawing files");
|
||||
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf drawing files");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Modes:");
|
||||
Console.Error.WriteLine(" <nest.opnest> Load nest and fill (existing behavior)");
|
||||
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
|
||||
Console.Error.WriteLine(" <part.dxf> --size WxL Import DXF, create plate, and fill");
|
||||
Console.Error.WriteLine(" <nest.opnest> <part.dxf> Load nest and add imported DXF drawings");
|
||||
Console.Error.WriteLine(" <nest.nest> <part.dxf> Load nest and add imported DXF drawings");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Options:");
|
||||
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)");
|
||||
@@ -400,7 +400,7 @@ static class NestConsole
|
||||
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
|
||||
Console.Error.WriteLine(" --spacing <value> Override part spacing");
|
||||
Console.Error.WriteLine(" --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode");
|
||||
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.opnest)");
|
||||
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.nest)");
|
||||
Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)");
|
||||
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
|
||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace OpenNest
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public class CutParameters
|
||||
{
|
||||
public class CutParameters
|
||||
public double Feedrate { get; set; }
|
||||
public double RapidTravelRate { get; set; }
|
||||
public TimeSpan PierceTime { get; set; }
|
||||
public double LeadInLength { get; set; }
|
||||
public string PostProcessor { get; set; }
|
||||
public Units Units { get; set; }
|
||||
|
||||
public static CutParameters Default => new()
|
||||
{
|
||||
public double Feedrate { get; set; }
|
||||
|
||||
public double RapidTravelRate { get; set; }
|
||||
|
||||
public TimeSpan PierceTime { get; set; }
|
||||
|
||||
public Units Units { get; set; }
|
||||
}
|
||||
Feedrate = 100,
|
||||
RapidTravelRate = 300,
|
||||
PierceTime = TimeSpan.FromSeconds(0.5),
|
||||
Units = OpenNest.Units.Inches
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Api;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
|
||||
@@ -25,7 +25,8 @@ namespace OpenNest.Engine.Fill
|
||||
public List<Part> Fill(Drawing drawing, double rotationAngle = 0,
|
||||
int plateNumber = 0,
|
||||
CancellationToken token = default,
|
||||
IProgress<NestProgress> progress = null)
|
||||
IProgress<NestProgress> progress = null,
|
||||
List<Engine.BestFit.BestFitResult> bestFits = null)
|
||||
{
|
||||
var pair = BuildPair(drawing, rotationAngle);
|
||||
if (pair == null)
|
||||
|
||||
@@ -19,11 +19,6 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
public double HalfSpacing => PartSpacing / 2;
|
||||
|
||||
/// <summary>
|
||||
/// Optional multi-part patterns (e.g. interlocking pairs) to try in remainder strips.
|
||||
/// </summary>
|
||||
public List<Pattern> RemainderPatterns { get; set; }
|
||||
|
||||
private static Vector MakeOffset(NestDirection direction, double distance)
|
||||
{
|
||||
return direction == NestDirection.Horizontal
|
||||
@@ -346,215 +341,9 @@ namespace OpenNest.Engine.Fill
|
||||
var gridResult = new List<Part>(rowPattern.Parts);
|
||||
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
|
||||
|
||||
// Step 3: Fill remaining strip
|
||||
var remaining = FillRemainingStrip(gridResult, pattern, perpAxis, direction);
|
||||
if (remaining.Count > 0)
|
||||
gridResult.AddRange(remaining);
|
||||
|
||||
// Step 4: Try fewer rows optimization
|
||||
var fewerResult = TryFewerRows(gridResult, rowPattern, pattern, perpAxis, direction);
|
||||
if (fewerResult != null && fewerResult.Count > gridResult.Count)
|
||||
return fewerResult;
|
||||
|
||||
return gridResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries removing the last row/column from the grid and re-filling the
|
||||
/// larger remainder strip. Returns null if this doesn't improve the total.
|
||||
/// </summary>
|
||||
private List<Part> TryFewerRows(
|
||||
List<Part> fullResult, Pattern rowPattern, Pattern seedPattern,
|
||||
NestDirection tiledAxis, NestDirection primaryAxis)
|
||||
{
|
||||
var rowPartCount = rowPattern.Parts.Count;
|
||||
|
||||
if (fullResult.Count < rowPartCount * 2)
|
||||
return null;
|
||||
|
||||
var fewerParts = new List<Part>(fullResult.Count - rowPartCount);
|
||||
|
||||
for (var i = 0; i < fullResult.Count - rowPartCount; i++)
|
||||
fewerParts.Add(fullResult[i]);
|
||||
|
||||
var remaining = FillRemainingStrip(fewerParts, seedPattern, tiledAxis, primaryAxis);
|
||||
|
||||
if (remaining.Count <= rowPartCount)
|
||||
return null;
|
||||
|
||||
fewerParts.AddRange(remaining);
|
||||
return fewerParts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After tiling full rows/columns, fills the remaining strip with individual
|
||||
/// parts. The strip is the leftover space along the tiled axis between the
|
||||
/// last full row/column and the work area boundary. Each unique drawing and
|
||||
/// rotation from the seed pattern is tried in both directions.
|
||||
/// </summary>
|
||||
private List<Part> FillRemainingStrip(
|
||||
List<Part> placedParts, Pattern seedPattern,
|
||||
NestDirection tiledAxis, NestDirection primaryAxis)
|
||||
{
|
||||
var placedEdge = FindPlacedEdge(placedParts, tiledAxis);
|
||||
var remainingStrip = BuildRemainingStrip(placedEdge, tiledAxis);
|
||||
|
||||
if (remainingStrip == null)
|
||||
return new List<Part>();
|
||||
|
||||
var rotations = BuildRotationSet(seedPattern);
|
||||
var best = FindBestFill(rotations, remainingStrip);
|
||||
|
||||
if (RemainderPatterns != null)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Strip: {remainingStrip.Width:F1}x{remainingStrip.Length:F1}, individual best={best?.Count ?? 0}, trying {RemainderPatterns.Count} patterns");
|
||||
|
||||
foreach (var pattern in RemainderPatterns)
|
||||
{
|
||||
var filler = new FillLinear(remainingStrip, PartSpacing);
|
||||
var h = filler.Fill(pattern, NestDirection.Horizontal);
|
||||
var v = filler.Fill(pattern, NestDirection.Vertical);
|
||||
|
||||
System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Pattern ({pattern.Parts.Count} parts, bbox={pattern.BoundingBox.Width:F1}x{pattern.BoundingBox.Length:F1}): H={h?.Count ?? 0}, V={v?.Count ?? 0}");
|
||||
|
||||
if (h != null && h.Count > (best?.Count ?? 0))
|
||||
best = h;
|
||||
if (v != null && v.Count > (best?.Count ?? 0))
|
||||
best = v;
|
||||
}
|
||||
|
||||
System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Final best={best?.Count ?? 0}");
|
||||
}
|
||||
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
|
||||
private static double FindPlacedEdge(List<Part> placedParts, NestDirection tiledAxis)
|
||||
{
|
||||
var placedEdge = double.MinValue;
|
||||
|
||||
foreach (var part in placedParts)
|
||||
{
|
||||
var edge = tiledAxis == NestDirection.Vertical
|
||||
? part.BoundingBox.Top
|
||||
: part.BoundingBox.Right;
|
||||
|
||||
if (edge > placedEdge)
|
||||
placedEdge = edge;
|
||||
}
|
||||
|
||||
return placedEdge;
|
||||
}
|
||||
|
||||
private Box BuildRemainingStrip(double placedEdge, NestDirection tiledAxis)
|
||||
{
|
||||
if (tiledAxis == NestDirection.Vertical)
|
||||
{
|
||||
var bottom = placedEdge + PartSpacing;
|
||||
var height = WorkArea.Top - bottom;
|
||||
|
||||
if (height <= Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
return new Box(WorkArea.X, bottom, WorkArea.Width, height);
|
||||
}
|
||||
else
|
||||
{
|
||||
var left = placedEdge + PartSpacing;
|
||||
var width = WorkArea.Right - left;
|
||||
|
||||
if (width <= Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
return new Box(left, WorkArea.Y, width, WorkArea.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a set of (drawing, rotation) candidates: cardinal orientations
|
||||
/// (0° and 90°) for each unique drawing, plus any seed pattern rotations
|
||||
/// not already covered.
|
||||
/// </summary>
|
||||
private static List<(Drawing drawing, double rotation)> BuildRotationSet(Pattern seedPattern)
|
||||
{
|
||||
var rotations = new List<(Drawing drawing, double rotation)>();
|
||||
var drawings = new List<Drawing>();
|
||||
|
||||
foreach (var seedPart in seedPattern.Parts)
|
||||
{
|
||||
var found = false;
|
||||
|
||||
foreach (var d in drawings)
|
||||
{
|
||||
if (d == seedPart.BaseDrawing)
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
drawings.Add(seedPart.BaseDrawing);
|
||||
}
|
||||
|
||||
foreach (var drawing in drawings)
|
||||
{
|
||||
rotations.Add((drawing, 0));
|
||||
rotations.Add((drawing, Angle.HalfPI));
|
||||
}
|
||||
|
||||
foreach (var seedPart in seedPattern.Parts)
|
||||
{
|
||||
var skip = false;
|
||||
|
||||
foreach (var (d, r) in rotations)
|
||||
{
|
||||
if (d == seedPart.BaseDrawing && r.IsEqualTo(seedPart.Rotation))
|
||||
{
|
||||
skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip)
|
||||
rotations.Add((seedPart.BaseDrawing, seedPart.Rotation));
|
||||
}
|
||||
|
||||
return rotations;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries all rotation candidates in both directions in parallel, returns the
|
||||
/// fill with the most parts.
|
||||
/// </summary>
|
||||
private List<Part> FindBestFill(List<(Drawing drawing, double rotation)> rotations, Box strip)
|
||||
{
|
||||
var bag = new System.Collections.Concurrent.ConcurrentBag<List<Part>>();
|
||||
|
||||
Parallel.ForEach(rotations, entry =>
|
||||
{
|
||||
var filler = new FillLinear(strip, PartSpacing);
|
||||
var h = filler.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal);
|
||||
var v = filler.Fill(entry.drawing, entry.rotation, NestDirection.Vertical);
|
||||
|
||||
if (h != null && h.Count > 0)
|
||||
bag.Add(h);
|
||||
|
||||
if (v != null && v.Count > 0)
|
||||
bag.Add(v);
|
||||
});
|
||||
|
||||
List<Part> best = null;
|
||||
|
||||
foreach (var candidate in bag)
|
||||
{
|
||||
if (best == null || candidate.Count > best.Count)
|
||||
best = candidate;
|
||||
}
|
||||
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills a single row of identical parts along one axis using geometry-aware spacing.
|
||||
/// </summary>
|
||||
|
||||
322
OpenNest.Engine/Fill/IterativeShrinkFiller.cs
Normal file
322
OpenNest.Engine/Fill/IterativeShrinkFiller.cs
Normal file
@@ -0,0 +1,322 @@
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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.Height"/> 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)
|
||||
{
|
||||
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);
|
||||
|
||||
// Track parts placed by previous items so ShrinkFiller can
|
||||
// include them in progress reports.
|
||||
var placedSoFar = new List<Part>();
|
||||
|
||||
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.Height, token,
|
||||
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar),
|
||||
() => widthResult = ShrinkFiller.Shrink(fillFunc, 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);
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Custom, plateNumber,
|
||||
allParts, box, $"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.
|
||||
// RemnantFiller.FillItems does NOT mutate NestItem.Quantity.
|
||||
var leftovers = new List<NestItem>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var placedCount = 0;
|
||||
foreach (var p in placed)
|
||||
{
|
||||
if (p.BaseDrawing.Name == item.Drawing.Name)
|
||||
placedCount++;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (parts == null || parts.Count <= 1)
|
||||
return;
|
||||
|
||||
// Sort parts by Left edge for grouping.
|
||||
parts.Sort((a, b) => a.BoundingBox.Left.CompareTo(b.BoundingBox.Left));
|
||||
|
||||
// Group parts into columns by X overlap.
|
||||
var columns = new List<List<Part>>();
|
||||
var column = new List<Part> { parts[0] };
|
||||
var columnRight = parts[0].BoundingBox.Right;
|
||||
|
||||
for (var i = 1; i < parts.Count; i++)
|
||||
{
|
||||
if (parts[i].BoundingBox.Left > columnRight + spacing / 2)
|
||||
{
|
||||
columns.Add(column);
|
||||
column = new List<Part> { parts[i] };
|
||||
columnRight = parts[i].BoundingBox.Right;
|
||||
}
|
||||
else
|
||||
{
|
||||
column.Add(parts[i]);
|
||||
if (parts[i].BoundingBox.Right > columnRight)
|
||||
columnRight = parts[i].BoundingBox.Right;
|
||||
}
|
||||
}
|
||||
columns.Add(column);
|
||||
|
||||
if (columns.Count <= 1)
|
||||
return;
|
||||
|
||||
// Measure inter-column gap from original layout.
|
||||
var gap = MinLeft(columns[1]) - MaxRight(columns[0]);
|
||||
|
||||
// Sort columns by height ascending (shortest first).
|
||||
columns.Sort((a, b) => MaxTop(a).CompareTo(MaxTop(b)));
|
||||
|
||||
// Reposition columns left-to-right.
|
||||
var x = parts[0].BoundingBox.Left; // parts already sorted by Left
|
||||
|
||||
foreach (var col in columns)
|
||||
{
|
||||
var colLeft = MinLeft(col);
|
||||
var dx = x - colLeft;
|
||||
|
||||
if (System.Math.Abs(dx) > OpenNest.Math.Tolerance.Epsilon)
|
||||
{
|
||||
var offset = new Vector(dx, 0);
|
||||
foreach (var part in col)
|
||||
part.Offset(offset);
|
||||
}
|
||||
|
||||
x = MaxRight(col) + gap;
|
||||
}
|
||||
|
||||
// Rebuild the parts list in column order.
|
||||
parts.Clear();
|
||||
foreach (var col in columns)
|
||||
parts.AddRange(col);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (parts == null || parts.Count <= 1)
|
||||
return;
|
||||
|
||||
// Sort parts by Bottom edge for grouping.
|
||||
parts.Sort((a, b) => a.BoundingBox.Bottom.CompareTo(b.BoundingBox.Bottom));
|
||||
|
||||
// Group parts into rows by Y overlap.
|
||||
var rows = new List<List<Part>>();
|
||||
var row = new List<Part> { parts[0] };
|
||||
var rowTop = parts[0].BoundingBox.Top;
|
||||
|
||||
for (var i = 1; i < parts.Count; i++)
|
||||
{
|
||||
if (parts[i].BoundingBox.Bottom > rowTop + spacing / 2)
|
||||
{
|
||||
rows.Add(row);
|
||||
row = new List<Part> { parts[i] };
|
||||
rowTop = parts[i].BoundingBox.Top;
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Add(parts[i]);
|
||||
if (parts[i].BoundingBox.Top > rowTop)
|
||||
rowTop = parts[i].BoundingBox.Top;
|
||||
}
|
||||
}
|
||||
rows.Add(row);
|
||||
|
||||
if (rows.Count <= 1)
|
||||
return;
|
||||
|
||||
// Measure inter-row gap from original layout.
|
||||
var gap = MinBottom(rows[1]) - MaxTop(rows[0]);
|
||||
|
||||
// Sort rows by width ascending (narrowest first).
|
||||
rows.Sort((a, b) => MaxRight(a).CompareTo(MaxRight(b)));
|
||||
|
||||
// Reposition rows bottom-to-top.
|
||||
var y = parts[0].BoundingBox.Bottom; // parts already sorted by Bottom
|
||||
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var rowBottom = MinBottom(r);
|
||||
var dy = y - rowBottom;
|
||||
|
||||
if (System.Math.Abs(dy) > OpenNest.Math.Tolerance.Epsilon)
|
||||
{
|
||||
var offset = new Vector(0, dy);
|
||||
foreach (var part in r)
|
||||
part.Offset(offset);
|
||||
}
|
||||
|
||||
y = MaxTop(r) + gap;
|
||||
}
|
||||
|
||||
// Rebuild the parts list in row order.
|
||||
parts.Clear();
|
||||
foreach (var r in rows)
|
||||
parts.AddRange(r);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,12 @@ using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
public class PairFillResult
|
||||
{
|
||||
public List<Part> Parts { get; set; } = new List<Part>();
|
||||
public List<BestFitResult> BestFits { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills a work area using interlocking part pairs from BestFitCache.
|
||||
/// </summary>
|
||||
@@ -24,33 +30,40 @@ namespace OpenNest.Engine.Fill
|
||||
private readonly Size plateSize;
|
||||
private readonly double partSpacing;
|
||||
|
||||
/// <summary>
|
||||
/// The best-fit results computed during the last Fill call.
|
||||
/// Available after Fill returns so callers can reuse without recomputing.
|
||||
/// </summary>
|
||||
public List<BestFitResult> BestFits { get; private set; }
|
||||
|
||||
public PairFiller(Size plateSize, double partSpacing)
|
||||
{
|
||||
this.plateSize = plateSize;
|
||||
this.partSpacing = partSpacing;
|
||||
}
|
||||
|
||||
public List<Part> Fill(NestItem item, Box workArea,
|
||||
public PairFillResult Fill(NestItem item, Box workArea,
|
||||
int plateNumber = 0,
|
||||
CancellationToken token = default,
|
||||
IProgress<NestProgress> progress = null)
|
||||
{
|
||||
BestFits = BestFitCache.GetOrCompute(
|
||||
var bestFits = BestFitCache.GetOrCompute(
|
||||
item.Drawing, plateSize.Length, plateSize.Width, partSpacing);
|
||||
|
||||
var candidates = SelectPairCandidates(BestFits, workArea);
|
||||
Debug.WriteLine($"[PairFiller] Total: {BestFits.Count}, Kept: {BestFits.Count(r => r.Keep)}, Trying: {candidates.Count}");
|
||||
var candidates = SelectPairCandidates(bestFits, workArea);
|
||||
Debug.WriteLine($"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}");
|
||||
Debug.WriteLine($"[PairFiller] Plate: {plateSize.Length:F2}x{plateSize.Width:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}");
|
||||
|
||||
var targetCount = item.Quantity > 0 ? item.Quantity : 0;
|
||||
var parts = EvaluateCandidates(candidates, item.Drawing, workArea, targetCount,
|
||||
plateNumber, token, progress);
|
||||
|
||||
return new PairFillResult { Parts = parts, BestFits = bestFits };
|
||||
}
|
||||
|
||||
private List<Part> EvaluateCandidates(
|
||||
List<BestFitResult> candidates, Drawing drawing,
|
||||
Box workArea, int targetCount,
|
||||
int plateNumber, CancellationToken token, IProgress<NestProgress> progress)
|
||||
{
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
var sinceImproved = 0;
|
||||
var effectiveWorkArea = workArea;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -58,21 +71,15 @@ namespace OpenNest.Engine.Fill
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var filled = EvaluateCandidate(candidates[i], item.Drawing, workArea);
|
||||
var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea);
|
||||
var score = FillScore.Compute(filled, effectiveWorkArea);
|
||||
|
||||
if (filled != null && filled.Count > 0)
|
||||
if (score > bestScore)
|
||||
{
|
||||
var score = FillScore.Compute(filled, workArea);
|
||||
if (best == null || score > bestScore)
|
||||
{
|
||||
best = filled;
|
||||
bestScore = score;
|
||||
sinceImproved = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
sinceImproved++;
|
||||
}
|
||||
best = filled;
|
||||
bestScore = score;
|
||||
sinceImproved = 0;
|
||||
effectiveWorkArea = TryReduceWorkArea(filled, targetCount, workArea, effectiveWorkArea);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -98,16 +105,64 @@ namespace OpenNest.Engine.Fill
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
|
||||
private static Box TryReduceWorkArea(List<Part> parts, int targetCount, Box workArea, Box effectiveWorkArea)
|
||||
{
|
||||
if (targetCount <= 0 || parts.Count <= targetCount)
|
||||
return effectiveWorkArea;
|
||||
|
||||
var reduced = ReduceWorkArea(parts, targetCount, workArea);
|
||||
if (reduced.Area() >= effectiveWorkArea.Area())
|
||||
return effectiveWorkArea;
|
||||
|
||||
Debug.WriteLine($"[PairFiller] Reduced work area to {reduced.Width:F2}x{reduced.Length:F2} (trimmed to {targetCount + 1} parts)");
|
||||
return reduced;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Given parts that exceed targetCount, sorts by BoundingBox.Top descending,
|
||||
/// removes parts from the top until exactly targetCount remain, then returns
|
||||
/// the Top of the remaining parts as the new work area height to beat.
|
||||
/// </summary>
|
||||
private static Box ReduceWorkArea(List<Part> parts, int targetCount, Box workArea)
|
||||
{
|
||||
if (parts.Count <= targetCount)
|
||||
return workArea;
|
||||
|
||||
var sorted = parts
|
||||
.OrderByDescending(p => p.BoundingBox.Top)
|
||||
.ToList();
|
||||
|
||||
var trimCount = sorted.Count - targetCount;
|
||||
var remaining = sorted.Skip(trimCount).ToList();
|
||||
|
||||
var newTop = remaining.Max(p => p.BoundingBox.Top);
|
||||
|
||||
return new Box(workArea.X, workArea.Y,
|
||||
workArea.Width,
|
||||
System.Math.Min(newTop - workArea.Y, workArea.Length));
|
||||
}
|
||||
|
||||
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing, Box workArea)
|
||||
{
|
||||
var pairParts = candidate.BuildParts(drawing);
|
||||
var engine = new FillLinear(workArea, partSpacing);
|
||||
var angles = BuildTilingAngles(candidate);
|
||||
return FillHelpers.FillPattern(engine, pairParts, angles, workArea);
|
||||
}
|
||||
|
||||
var p0 = FillHelpers.BuildRotatedPattern(pairParts, 0);
|
||||
var p90 = FillHelpers.BuildRotatedPattern(pairParts, Angle.HalfPI);
|
||||
engine.RemainderPatterns = new List<Pattern> { p0, p90 };
|
||||
private static List<double> BuildTilingAngles(BestFitResult candidate)
|
||||
{
|
||||
var angles = new List<double>(candidate.HullAngles);
|
||||
var optAngle = -candidate.OptimalRotation;
|
||||
|
||||
return FillHelpers.FillPattern(engine, pairParts, candidate.HullAngles, workArea);
|
||||
if (!angles.Any(a => a.IsEqualTo(optAngle)))
|
||||
angles.Add(optAngle);
|
||||
|
||||
var optAngle90 = Angle.NormalizeRad(optAngle + Angle.HalfPI);
|
||||
if (!angles.Any(a => a.IsEqualTo(optAngle90)))
|
||||
angles.Add(optAngle90);
|
||||
|
||||
return angles;
|
||||
}
|
||||
|
||||
private List<BestFitResult> SelectPairCandidates(List<BestFitResult> bestFits, Box workArea)
|
||||
@@ -139,7 +194,41 @@ namespace OpenNest.Engine.Fill
|
||||
Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
|
||||
}
|
||||
|
||||
SortByEstimatedCount(top, workArea);
|
||||
|
||||
return top;
|
||||
}
|
||||
|
||||
private void SortByEstimatedCount(List<BestFitResult> candidates, Box workArea)
|
||||
{
|
||||
var w = workArea.Width;
|
||||
var l = workArea.Length;
|
||||
|
||||
candidates.Sort((a, b) =>
|
||||
{
|
||||
var aCount = EstimateTileCount(a, w, l);
|
||||
var bCount = EstimateTileCount(b, w, l);
|
||||
|
||||
if (aCount != bCount)
|
||||
return bCount.CompareTo(aCount);
|
||||
|
||||
return b.Utilization.CompareTo(a.Utilization);
|
||||
});
|
||||
}
|
||||
|
||||
private int EstimateTileCount(BestFitResult r, double areaW, double areaL)
|
||||
{
|
||||
var h = EstimateCount(r.BoundingWidth, r.BoundingHeight, areaW, areaL);
|
||||
var v = EstimateCount(r.BoundingHeight, r.BoundingWidth, areaW, areaL);
|
||||
return System.Math.Max(h, v);
|
||||
}
|
||||
|
||||
private int EstimateCount(double pairW, double pairH, double areaW, double areaL)
|
||||
{
|
||||
if (pairW <= 0 || pairH <= 0) return 0;
|
||||
var cols = (int)((areaW + partSpacing) / (pairW + partSpacing));
|
||||
var rows = (int)((areaL + partSpacing) / (pairH + partSpacing));
|
||||
return cols * rows * 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,76 +37,106 @@ namespace OpenNest.Engine.Fill
|
||||
return new List<Part>();
|
||||
|
||||
var allParts = new List<Part>();
|
||||
var madeProgress = true;
|
||||
|
||||
// Track quantities locally — do not mutate the input NestItem objects.
|
||||
var localQty = new Dictionary<string, int>();
|
||||
foreach (var item in items)
|
||||
localQty[item.Drawing.Name] = item.Quantity;
|
||||
var localQty = BuildLocalQuantities(items);
|
||||
|
||||
while (madeProgress && !token.IsCancellationRequested)
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
madeProgress = false;
|
||||
|
||||
var minRemnantDim = double.MaxValue;
|
||||
foreach (var item in items)
|
||||
{
|
||||
var qty = localQty[item.Drawing.Name];
|
||||
if (qty <= 0)
|
||||
continue;
|
||||
var bb = item.Drawing.Program.BoundingBox();
|
||||
var dim = System.Math.Min(bb.Width, bb.Length);
|
||||
if (dim < minRemnantDim)
|
||||
minRemnantDim = dim;
|
||||
}
|
||||
|
||||
if (minRemnantDim == double.MaxValue)
|
||||
var minDim = FindMinItemDimension(items, localQty);
|
||||
if (minDim == double.MaxValue)
|
||||
break;
|
||||
|
||||
var freeBoxes = finder.FindRemnants(minRemnantDim);
|
||||
|
||||
var freeBoxes = finder.FindRemnants(minDim);
|
||||
if (freeBoxes.Count == 0)
|
||||
break;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
var qty = localQty[item.Drawing.Name];
|
||||
if (qty == 0)
|
||||
continue;
|
||||
|
||||
var itemBbox = item.Drawing.Program.BoundingBox();
|
||||
var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length);
|
||||
|
||||
foreach (var box in freeBoxes)
|
||||
{
|
||||
if (System.Math.Min(box.Width, box.Length) < minItemDim)
|
||||
continue;
|
||||
|
||||
var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty };
|
||||
var remnantParts = fillFunc(fillItem, box);
|
||||
|
||||
if (remnantParts != null && remnantParts.Count > 0)
|
||||
{
|
||||
allParts.AddRange(remnantParts);
|
||||
localQty[item.Drawing.Name] = System.Math.Max(0, qty - remnantParts.Count);
|
||||
|
||||
foreach (var p in remnantParts)
|
||||
finder.AddObstacle(p.BoundingBox.Offset(spacing));
|
||||
|
||||
madeProgress = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (madeProgress)
|
||||
break;
|
||||
}
|
||||
if (!TryFillOneItem(items, freeBoxes, localQty, fillFunc, allParts, token))
|
||||
break;
|
||||
}
|
||||
|
||||
return allParts;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> BuildLocalQuantities(List<NestItem> items)
|
||||
{
|
||||
var localQty = new Dictionary<string, int>(items.Count);
|
||||
foreach (var item in items)
|
||||
localQty[item.Drawing.Name] = item.Quantity;
|
||||
return localQty;
|
||||
}
|
||||
|
||||
private static double FindMinItemDimension(List<NestItem> items, Dictionary<string, int> localQty)
|
||||
{
|
||||
var minDim = double.MaxValue;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (localQty[item.Drawing.Name] <= 0)
|
||||
continue;
|
||||
var bb = item.Drawing.Program.BoundingBox();
|
||||
var dim = System.Math.Min(bb.Width, bb.Length);
|
||||
if (dim < minDim)
|
||||
minDim = dim;
|
||||
}
|
||||
return minDim;
|
||||
}
|
||||
|
||||
private bool TryFillOneItem(
|
||||
List<NestItem> items,
|
||||
List<Box> freeBoxes,
|
||||
Dictionary<string, int> localQty,
|
||||
Func<NestItem, Box, List<Part>> fillFunc,
|
||||
List<Part> allParts,
|
||||
CancellationToken token)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
return false;
|
||||
|
||||
var qty = localQty[item.Drawing.Name];
|
||||
if (qty <= 0)
|
||||
continue;
|
||||
|
||||
var placed = TryFillInRemnants(item, qty, freeBoxes, fillFunc);
|
||||
if (placed == null)
|
||||
continue;
|
||||
|
||||
allParts.AddRange(placed);
|
||||
localQty[item.Drawing.Name] = System.Math.Max(0, qty - placed.Count);
|
||||
|
||||
foreach (var p in placed)
|
||||
finder.AddObstacle(p.BoundingBox.Offset(spacing));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<Part> TryFillInRemnants(
|
||||
NestItem item,
|
||||
int qty,
|
||||
List<Box> freeBoxes,
|
||||
Func<NestItem, Box, List<Part>> fillFunc)
|
||||
{
|
||||
var itemBbox = item.Drawing.Program.BoundingBox();
|
||||
|
||||
foreach (var box in freeBoxes)
|
||||
{
|
||||
var fitsNormal = box.Width >= itemBbox.Width && box.Length >= itemBbox.Length;
|
||||
var fitsRotated = box.Width >= itemBbox.Length && box.Length >= itemBbox.Width;
|
||||
if (!fitsNormal && !fitsRotated)
|
||||
continue;
|
||||
|
||||
var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty };
|
||||
var parts = fillFunc(fillItem, box);
|
||||
|
||||
if (parts != null && parts.Count > 0)
|
||||
return parts;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.RectanglePacking;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -17,7 +18,7 @@ namespace OpenNest.Engine.Fill
|
||||
/// <summary>
|
||||
/// Fills a box then iteratively shrinks one axis by the spacing amount
|
||||
/// until the part count drops. Returns the tightest box that still fits
|
||||
/// the same number of parts.
|
||||
/// the target number of parts.
|
||||
/// </summary>
|
||||
public static class ShrinkFiller
|
||||
{
|
||||
@@ -27,17 +28,43 @@ namespace OpenNest.Engine.Fill
|
||||
double spacing,
|
||||
ShrinkAxis axis,
|
||||
CancellationToken token = default,
|
||||
int maxIterations = 20)
|
||||
int maxIterations = 20,
|
||||
int targetCount = 0,
|
||||
IProgress<NestProgress> progress = null,
|
||||
int plateNumber = 0,
|
||||
List<Part> placedParts = null)
|
||||
{
|
||||
var parts = fillFunc(item, box);
|
||||
// If a target count is specified, estimate a smaller starting box
|
||||
// to avoid an expensive full-area fill.
|
||||
var startBox = box;
|
||||
if (targetCount > 0)
|
||||
startBox = EstimateStartBox(item, box, spacing, axis, targetCount);
|
||||
|
||||
var parts = fillFunc(item, startBox);
|
||||
|
||||
// If estimate was too aggressive and we got fewer than target,
|
||||
// fall back to the full box.
|
||||
if (targetCount > 0 && startBox != box
|
||||
&& (parts == null || parts.Count < targetCount))
|
||||
{
|
||||
parts = fillFunc(item, box);
|
||||
}
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return new ShrinkResult { Parts = parts ?? new List<Part>(), Dimension = 0 };
|
||||
|
||||
var targetCount = parts.Count;
|
||||
// Shrink target: if a target count was given and we got at least that many,
|
||||
// shrink to fit targetCount (not the full count). This produces a tighter box.
|
||||
// If we got fewer than target, shrink to maintain what we have.
|
||||
var shrinkTarget = targetCount > 0
|
||||
? System.Math.Min(targetCount, parts.Count)
|
||||
: parts.Count;
|
||||
|
||||
var bestParts = parts;
|
||||
var bestDim = MeasureDimension(parts, box, axis);
|
||||
|
||||
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, box, axis, bestDim);
|
||||
|
||||
for (var i = 0; i < maxIterations; i++)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
@@ -51,18 +78,83 @@ namespace OpenNest.Engine.Fill
|
||||
? new Box(box.X, box.Y, trialDim, box.Length)
|
||||
: new Box(box.X, box.Y, box.Width, trialDim);
|
||||
|
||||
// Report the trial box before the fill so the UI updates the
|
||||
// work area border immediately rather than after the fill completes.
|
||||
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, trialBox, axis, trialDim);
|
||||
|
||||
var trialParts = fillFunc(item, trialBox);
|
||||
|
||||
if (trialParts == null || trialParts.Count < targetCount)
|
||||
if (trialParts == null || trialParts.Count < shrinkTarget)
|
||||
break;
|
||||
|
||||
bestParts = trialParts;
|
||||
bestDim = MeasureDimension(trialParts, box, axis);
|
||||
|
||||
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, trialBox, axis, bestDim);
|
||||
}
|
||||
|
||||
return new ShrinkResult { Parts = bestParts, Dimension = bestDim };
|
||||
}
|
||||
|
||||
private static void ReportShrinkProgress(
|
||||
IProgress<NestProgress> progress, int plateNumber,
|
||||
List<Part> placedParts, List<Part> bestParts,
|
||||
Box workArea, ShrinkAxis axis, double dim)
|
||||
{
|
||||
if (progress == null)
|
||||
return;
|
||||
|
||||
var allParts = placedParts != null && placedParts.Count > 0
|
||||
? new List<Part>(placedParts.Count + bestParts.Count)
|
||||
: new List<Part>(bestParts.Count);
|
||||
|
||||
if (placedParts != null && placedParts.Count > 0)
|
||||
allParts.AddRange(placedParts);
|
||||
allParts.AddRange(bestParts);
|
||||
|
||||
var desc = $"Shrink {axis}: {bestParts.Count} parts, dim={dim:F1}";
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Custom, plateNumber,
|
||||
allParts, workArea, desc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses FillBestFit (fast rectangle packing) to estimate a starting box
|
||||
/// that fits roughly the target count. Scales the shrink axis proportionally
|
||||
/// from the full-area count down to the target, with margin.
|
||||
/// </summary>
|
||||
private static Box EstimateStartBox(NestItem item, Box box,
|
||||
double spacing, ShrinkAxis axis, int targetCount)
|
||||
{
|
||||
var bbox = item.Drawing.Program.BoundingBox();
|
||||
if (bbox.Width <= 0 || bbox.Length <= 0)
|
||||
return box;
|
||||
|
||||
var maxDim = axis == ShrinkAxis.Height ? box.Length : box.Width;
|
||||
|
||||
// Use FillBestFit for a fast, accurate rectangle count on the full box.
|
||||
var bin = new Bin { Size = new Size(box.Width, box.Length) };
|
||||
var packItem = new Item { Size = new Size(bbox.Width + spacing, bbox.Length + spacing) };
|
||||
var packer = new FillBestFit(bin);
|
||||
packer.Fill(packItem);
|
||||
var fullCount = bin.Items.Count;
|
||||
|
||||
if (fullCount <= 0 || fullCount <= targetCount)
|
||||
return box;
|
||||
|
||||
// Scale dimension proportionally: target/full * maxDim, with margin.
|
||||
var ratio = (double)targetCount / fullCount;
|
||||
var estimate = maxDim * ratio * 1.3;
|
||||
estimate = System.Math.Min(estimate, maxDim);
|
||||
|
||||
if (estimate <= 0 || estimate >= maxDim)
|
||||
return box;
|
||||
|
||||
return axis == ShrinkAxis.Height
|
||||
? new Box(box.X, box.Y, box.Width, estimate)
|
||||
: new Box(box.X, box.Y, estimate, box.Length);
|
||||
}
|
||||
|
||||
private static double MeasureDimension(List<Part> parts, Box box, ShrinkAxis axis)
|
||||
{
|
||||
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace OpenNest.Engine.Nfp
|
||||
/// <summary>
|
||||
/// Result of a nest optimization run.
|
||||
/// </summary>
|
||||
public class NestResult
|
||||
public class OptimizationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The best sequence found: (drawingId, rotation, drawing) tuples in placement order.
|
||||
@@ -32,7 +32,7 @@ namespace OpenNest.Engine.Nfp
|
||||
/// </summary>
|
||||
public interface INestOptimizer
|
||||
{
|
||||
NestResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
|
||||
OptimizationResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
|
||||
Dictionary<int, List<double>> candidateRotations,
|
||||
CancellationToken cancellation = default);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace OpenNest.Engine.Nfp
|
||||
private const double DefaultMinTemperature = 0.1;
|
||||
private const int DefaultMaxNoImprovement = 500;
|
||||
|
||||
public NestResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
|
||||
public OptimizationResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
|
||||
Dictionary<int, List<double>> candidateRotations,
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
@@ -29,7 +29,7 @@ namespace OpenNest.Engine.Nfp
|
||||
var sequence = BuildInitialSequence(items, candidateRotations);
|
||||
|
||||
if (sequence.Count == 0)
|
||||
return new NestResult { Sequence = sequence, Score = default, Iterations = 0 };
|
||||
return new OptimizationResult { Sequence = sequence, Score = default, Iterations = 0 };
|
||||
|
||||
// Evaluate initial solution.
|
||||
var blf = new BottomLeftFill(workArea, cache);
|
||||
@@ -106,7 +106,7 @@ namespace OpenNest.Engine.Nfp
|
||||
|
||||
Debug.WriteLine($"[SA] Done: {iteration} iters, best={bestScore.Count} parts, density={bestScore.Density:P1}");
|
||||
|
||||
return new NestResult
|
||||
return new OptimizationResult
|
||||
{
|
||||
Sequence = bestSequence,
|
||||
Score = bestScore,
|
||||
|
||||
@@ -15,9 +15,9 @@ namespace OpenNest.Engine.Strategies
|
||||
var result = filler.Fill(context.Item, context.WorkArea,
|
||||
context.PlateNumber, context.Token, context.Progress);
|
||||
|
||||
context.SharedState["BestFits"] = filler.BestFits;
|
||||
context.SharedState["BestFits"] = result.BestFits;
|
||||
|
||||
return result;
|
||||
return result.Parts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum StripDirection
|
||||
{
|
||||
Bottom,
|
||||
Left
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,10 @@ namespace OpenNest
|
||||
|
||||
public override string Name => "Strip";
|
||||
|
||||
public override string Description => "Strip-based nesting for mixed-drawing layouts";
|
||||
public override string Description => "Iterative shrink-fill nesting for mixed-drawing layouts";
|
||||
|
||||
/// <summary>
|
||||
/// Single-item fill delegates to DefaultNestEngine.
|
||||
/// The strip strategy adds value for multi-drawing nesting, not single-item fills.
|
||||
/// </summary>
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
@@ -49,66 +48,10 @@ namespace OpenNest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the item that consumes the most plate area (bounding box area x quantity).
|
||||
/// Returns the index into the items list.
|
||||
/// </summary>
|
||||
private static int SelectStripItemIndex(List<NestItem> items, Box workArea)
|
||||
{
|
||||
var bestIndex = 0;
|
||||
var bestArea = 0.0;
|
||||
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var bbox = items[i].Drawing.Program.BoundingBox();
|
||||
var qty = items[i].Quantity > 0
|
||||
? items[i].Quantity
|
||||
: (int)(workArea.Area() / bbox.Area());
|
||||
var totalArea = bbox.Area() * qty;
|
||||
|
||||
if (totalArea > bestArea)
|
||||
{
|
||||
bestArea = totalArea;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates the strip dimension (height for bottom, width for left) needed
|
||||
/// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter.
|
||||
/// This is only an estimate for the shrink loop starting point — the actual fill
|
||||
/// uses DefaultNestEngine.Fill which tries many rotation angles internally.
|
||||
/// </summary>
|
||||
private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension)
|
||||
{
|
||||
var bbox = item.Drawing.Program.BoundingBox();
|
||||
var qty = item.Quantity > 0
|
||||
? item.Quantity
|
||||
: System.Math.Max(1, (int)(stripLength * maxDimension / bbox.Area()));
|
||||
|
||||
// At 0 deg: parts per row along strip length, strip dimension is bbox.Length
|
||||
var perRow0 = (int)(stripLength / bbox.Width);
|
||||
var rows0 = perRow0 > 0 ? (int)System.Math.Ceiling((double)qty / perRow0) : int.MaxValue;
|
||||
var dim0 = rows0 * bbox.Length;
|
||||
|
||||
// At 90 deg: rotated bounding box (Width and Length swap)
|
||||
var perRow90 = (int)(stripLength / bbox.Length);
|
||||
var rows90 = perRow90 > 0 ? (int)System.Math.Ceiling((double)qty / perRow90) : int.MaxValue;
|
||||
var dim90 = rows90 * bbox.Width;
|
||||
|
||||
var estimate = System.Math.Min(dim0, dim90);
|
||||
|
||||
// Clamp to available dimension
|
||||
return System.Math.Min(estimate, maxDimension);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Multi-drawing strip nesting strategy.
|
||||
/// Picks the largest-area drawing for strip treatment, finds the tightest strip
|
||||
/// in both bottom and left orientations, fills remnants with remaining drawings,
|
||||
/// and returns the denser result.
|
||||
/// Multi-drawing iterative shrink-fill strategy.
|
||||
/// Each multi-quantity drawing gets shrink-filled into the tightest
|
||||
/// sub-region using dual-direction selection. Singles and leftovers
|
||||
/// are packed at the end.
|
||||
/// </summary>
|
||||
public override List<Part> Nest(List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
@@ -118,147 +61,83 @@ namespace OpenNest
|
||||
|
||||
var workArea = Plate.WorkArea();
|
||||
|
||||
// Select which item gets the strip treatment.
|
||||
var stripIndex = SelectStripItemIndex(items, workArea);
|
||||
var stripItem = items[stripIndex];
|
||||
var remainderItems = items.Where((_, i) => i != stripIndex).ToList();
|
||||
// Separate multi-quantity from singles.
|
||||
var fillItems = items
|
||||
.Where(i => i.Quantity != 1)
|
||||
.OrderBy(i => i.Priority)
|
||||
.ThenByDescending(i => i.Drawing.Area)
|
||||
.ToList();
|
||||
|
||||
// Try both orientations.
|
||||
var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, progress, token);
|
||||
var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, progress, token);
|
||||
var packItems = items
|
||||
.Where(i => i.Quantity == 1)
|
||||
.ToList();
|
||||
|
||||
// Pick the better result.
|
||||
var winner = bottomResult.Score >= leftResult.Score
|
||||
? bottomResult.Parts
|
||||
: leftResult.Parts;
|
||||
var allParts = new List<Part>();
|
||||
|
||||
// Deduct placed quantities from the original items.
|
||||
// Phase 1: Iterative shrink-fill for multi-quantity items.
|
||||
if (fillItems.Count > 0)
|
||||
{
|
||||
// Pass progress through so the UI shows intermediate results
|
||||
// during the initial BestFitCache computation and fill phases.
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.Fill(ni, b, progress, token);
|
||||
};
|
||||
|
||||
var shrinkResult = IterativeShrinkFiller.Fill(
|
||||
fillItems, workArea, fillFunc, Plate.PartSpacing, token,
|
||||
progress, PlateNumber);
|
||||
|
||||
allParts.AddRange(shrinkResult.Parts);
|
||||
|
||||
// Compact placed parts toward the origin to close gaps.
|
||||
if (allParts.Count > 1)
|
||||
{
|
||||
var noObstacles = new List<Part>();
|
||||
Compactor.Push(allParts, noObstacles, workArea, Plate.PartSpacing, PushDirection.Left);
|
||||
Compactor.Push(allParts, noObstacles, workArea, Plate.PartSpacing, PushDirection.Down);
|
||||
}
|
||||
|
||||
// Add unfilled items to pack list.
|
||||
packItems.AddRange(shrinkResult.Leftovers);
|
||||
}
|
||||
|
||||
// Phase 2: Pack singles + leftovers into remaining space.
|
||||
packItems = packItems.Where(i => i.Quantity > 0).ToList();
|
||||
|
||||
if (packItems.Count > 0 && !token.IsCancellationRequested)
|
||||
{
|
||||
// Reconstruct remaining area from placed parts.
|
||||
var packArea = workArea;
|
||||
if (allParts.Count > 0)
|
||||
{
|
||||
var obstacles = allParts
|
||||
.Select(p => p.BoundingBox.Offset(Plate.PartSpacing))
|
||||
.ToList();
|
||||
var finder = new RemnantFinder(workArea, obstacles);
|
||||
var remnants = finder.FindRemnants();
|
||||
packArea = remnants.Count > 0 ? remnants[0] : new Box(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
if (packArea.Width > 0 && packArea.Length > 0)
|
||||
{
|
||||
var packParts = PackArea(packArea, packItems, progress, token);
|
||||
allParts.AddRange(packParts);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct placed quantities from original items.
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Quantity <= 0)
|
||||
continue;
|
||||
|
||||
var placed = winner.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
|
||||
var placed = allParts.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
}
|
||||
|
||||
return winner;
|
||||
return allParts;
|
||||
}
|
||||
|
||||
private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem,
|
||||
List<NestItem> remainderItems, Box workArea, IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var result = new StripNestResult { Direction = direction };
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
return result;
|
||||
|
||||
// Estimate initial strip dimension.
|
||||
var stripLength = direction == StripDirection.Bottom ? workArea.Width : workArea.Length;
|
||||
var maxDimension = direction == StripDirection.Bottom ? workArea.Length : workArea.Width;
|
||||
var estimatedDim = EstimateStripDimension(stripItem, stripLength, maxDimension);
|
||||
|
||||
// Create the initial strip box.
|
||||
var stripBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim)
|
||||
: new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length);
|
||||
|
||||
// Shrink to tightest strip.
|
||||
var shrinkAxis = direction == StripDirection.Bottom
|
||||
? ShrinkAxis.Height : ShrinkAxis.Width;
|
||||
|
||||
Func<NestItem, Box, List<Part>> stripFill = (ni, b) =>
|
||||
{
|
||||
var trialInner = new DefaultNestEngine(Plate);
|
||||
return trialInner.Fill(ni, b, progress, token);
|
||||
};
|
||||
|
||||
var shrinkResult = ShrinkFiller.Shrink(stripFill,
|
||||
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
|
||||
stripBox, Plate.PartSpacing, shrinkAxis, token);
|
||||
|
||||
if (shrinkResult.Parts == null || shrinkResult.Parts.Count == 0)
|
||||
return result;
|
||||
|
||||
var bestParts = shrinkResult.Parts;
|
||||
var bestDim = shrinkResult.Dimension;
|
||||
|
||||
// Build remnant box with spacing gap.
|
||||
var spacing = Plate.PartSpacing;
|
||||
var remnantBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y + bestDim + spacing,
|
||||
workArea.Width, workArea.Length - bestDim - spacing)
|
||||
: new Box(workArea.X + bestDim + spacing, workArea.Y,
|
||||
workArea.Width - bestDim - spacing, workArea.Length);
|
||||
|
||||
// Collect all parts.
|
||||
var allParts = new List<Part>(bestParts);
|
||||
|
||||
// If strip item was only partially placed, add leftovers to remainder.
|
||||
var placed = bestParts.Count;
|
||||
var leftover = stripItem.Quantity > 0 ? stripItem.Quantity - placed : 0;
|
||||
var effectiveRemainder = new List<NestItem>(remainderItems);
|
||||
|
||||
if (leftover > 0)
|
||||
{
|
||||
effectiveRemainder.Add(new NestItem
|
||||
{
|
||||
Drawing = stripItem.Drawing,
|
||||
Quantity = leftover
|
||||
});
|
||||
}
|
||||
|
||||
// Sort remainder by descending bounding box area x quantity.
|
||||
effectiveRemainder = effectiveRemainder
|
||||
.OrderByDescending(i =>
|
||||
{
|
||||
var bb = i.Drawing.Program.BoundingBox();
|
||||
return bb.Area() * (i.Quantity > 0 ? i.Quantity : 1);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Fill remnants
|
||||
if (remnantBox.Width > 0 && remnantBox.Length > 0)
|
||||
{
|
||||
var remnantProgress = progress != null
|
||||
? new AccumulatingProgress(progress, allParts)
|
||||
: (IProgress<NestProgress>)null;
|
||||
|
||||
var remnantFiller = new RemnantFiller(workArea, spacing);
|
||||
remnantFiller.AddObstacles(allParts);
|
||||
|
||||
Func<NestItem, Box, List<Part>> remnantFillFunc = (ni, b) =>
|
||||
ShrinkFill(ni, b, remnantProgress, token);
|
||||
|
||||
var additional = remnantFiller.FillItems(effectiveRemainder,
|
||||
remnantFillFunc, token, remnantProgress);
|
||||
|
||||
allParts.AddRange(additional);
|
||||
}
|
||||
|
||||
result.Parts = allParts;
|
||||
result.StripBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, bestDim)
|
||||
: new Box(workArea.X, workArea.Y, bestDim, workArea.Length);
|
||||
result.Score = FillScore.Compute(allParts, workArea);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Part> ShrinkFill(NestItem item, Box box,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.Fill(ni, b, null, token);
|
||||
};
|
||||
|
||||
var heightResult = ShrinkFiller.Shrink(fillFunc, item, box,
|
||||
Plate.PartSpacing, ShrinkAxis.Height, token);
|
||||
|
||||
return heightResult.Parts;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
internal class StripNestResult
|
||||
{
|
||||
public List<Part> Parts { get; set; } = new();
|
||||
public Box StripBox { get; set; }
|
||||
public FillScore Score { get; set; }
|
||||
public StripDirection Direction { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ namespace OpenNest.IO
|
||||
{
|
||||
public static class NestFormat
|
||||
{
|
||||
public const string FileExtension = ".opnest";
|
||||
public const string FileFilter = "Nest Files (*.opnest)|*.opnest";
|
||||
public const string FileExtension = ".nest";
|
||||
public const string FileFilter = "Nest Files (*.nest)|*.nest";
|
||||
|
||||
public static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
|
||||
@@ -26,12 +26,17 @@ namespace OpenNest.IO
|
||||
}
|
||||
|
||||
public bool Write(string file)
|
||||
{
|
||||
using var fileStream = new FileStream(file, FileMode.Create);
|
||||
return Write(fileStream);
|
||||
}
|
||||
|
||||
public bool Write(Stream stream)
|
||||
{
|
||||
nest.DateLastModified = DateTime.Now;
|
||||
SetDrawingIds();
|
||||
|
||||
using var fileStream = new FileStream(file, FileMode.Create);
|
||||
using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create);
|
||||
using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
|
||||
|
||||
WriteNestJson(zipArchive);
|
||||
WritePrograms(zipArchive);
|
||||
|
||||
@@ -20,8 +20,8 @@ namespace OpenNest.Mcp.Tools
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "load_nest")]
|
||||
[Description("Load an .opnest file into the session. Returns a summary of plates, parts, and drawings.")]
|
||||
public string LoadNest([Description("Absolute path to the .opnest file")] string path)
|
||||
[Description("Load a .nest file into the session. Returns a summary of plates, parts, and drawings.")]
|
||||
public string LoadNest([Description("Absolute path to the .nest file")] string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return $"Error: file not found: {path}";
|
||||
|
||||
@@ -17,10 +17,10 @@ namespace OpenNest.Mcp.Tools
|
||||
[McpServerTool(Name = "test_engine")]
|
||||
[Description("Build and run the nesting engine against a nest file. Returns fill results and a debug log file path for grepping. Use this to test engine changes without restarting the MCP server.")]
|
||||
public string TestEngine(
|
||||
[Description("Path to the nest .opnest file")] string nestFile = @"C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.opnest",
|
||||
[Description("Path to the nest .nest file")] string nestFile = @"C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.nest",
|
||||
[Description("Drawing name to fill with (default: first drawing)")] string drawingName = null,
|
||||
[Description("Plate index to fill (default: 0)")] int plateIndex = 0,
|
||||
[Description("Output nest file path (default: <input>-result.opnest)")] string outputFile = null)
|
||||
[Description("Output nest file path (default: <input>-result.nest)")] string outputFile = null)
|
||||
{
|
||||
if (!File.Exists(nestFile))
|
||||
return $"Error: nest file not found: {nestFile}";
|
||||
|
||||
35
OpenNest.Tests/Api/CutParametersTests.cs
Normal file
35
OpenNest.Tests/Api/CutParametersTests.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using OpenNest.Api;
|
||||
|
||||
namespace OpenNest.Tests.Api;
|
||||
|
||||
public class CutParametersTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_HasExpectedValues()
|
||||
{
|
||||
var cp = CutParameters.Default;
|
||||
|
||||
Assert.Equal(100, cp.Feedrate);
|
||||
Assert.Equal(300, cp.RapidTravelRate);
|
||||
Assert.Equal(TimeSpan.FromSeconds(0.5), cp.PierceTime);
|
||||
Assert.Equal(Units.Inches, cp.Units);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_AreSettable()
|
||||
{
|
||||
var cp = new CutParameters
|
||||
{
|
||||
Feedrate = 200,
|
||||
RapidTravelRate = 500,
|
||||
PierceTime = TimeSpan.FromSeconds(1.0),
|
||||
LeadInLength = 0.25,
|
||||
PostProcessor = "CL-707",
|
||||
Units = Units.Millimeters
|
||||
};
|
||||
|
||||
Assert.Equal(200, cp.Feedrate);
|
||||
Assert.Equal(0.25, cp.LeadInLength);
|
||||
Assert.Equal("CL-707", cp.PostProcessor);
|
||||
}
|
||||
}
|
||||
45
OpenNest.Tests/Api/NestRequestTests.cs
Normal file
45
OpenNest.Tests/Api/NestRequestTests.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using OpenNest.Api;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Api;
|
||||
|
||||
public class NestRequestTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_Request_HasSensibleDefaults()
|
||||
{
|
||||
var request = new NestRequest();
|
||||
|
||||
Assert.Empty(request.Parts);
|
||||
Assert.Equal(60, request.SheetSize.Width);
|
||||
Assert.Equal(120, request.SheetSize.Length);
|
||||
Assert.Equal("Steel, A1011 HR", request.Material);
|
||||
Assert.Equal(0.06, request.Thickness);
|
||||
Assert.Equal(0.1, request.Spacing);
|
||||
Assert.Equal(NestStrategy.Auto, request.Strategy);
|
||||
Assert.NotNull(request.Cutting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parts_Accessible_AfterConstruction()
|
||||
{
|
||||
var request = new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { DxfPath = "test.dxf", Quantity = 5 }]
|
||||
};
|
||||
|
||||
Assert.Single(request.Parts);
|
||||
Assert.Equal("test.dxf", request.Parts[0].DxfPath);
|
||||
Assert.Equal(5, request.Parts[0].Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestRequestPart_Defaults()
|
||||
{
|
||||
var part = new NestRequestPart { DxfPath = "part.dxf" };
|
||||
|
||||
Assert.Equal(1, part.Quantity);
|
||||
Assert.True(part.AllowRotation);
|
||||
Assert.Equal(0, part.Priority);
|
||||
}
|
||||
}
|
||||
63
OpenNest.Tests/Api/NestResponsePersistenceTests.cs
Normal file
63
OpenNest.Tests/Api/NestResponsePersistenceTests.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Api;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Api;
|
||||
|
||||
public class NestResponsePersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAsync_LoadAsync_RoundTrips()
|
||||
{
|
||||
var nest = new Nest("test-nest");
|
||||
var plate = new Plate(new Size(60, 120));
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
var request = new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { DxfPath = "test.dxf", Quantity = 5 }],
|
||||
SheetSize = new Size(60, 120),
|
||||
Material = "Steel",
|
||||
Thickness = 0.125,
|
||||
Spacing = 0.1
|
||||
};
|
||||
|
||||
var original = new NestResponse
|
||||
{
|
||||
SheetCount = 1,
|
||||
Utilization = 0.75,
|
||||
CutTime = TimeSpan.FromMinutes(12.5),
|
||||
Elapsed = TimeSpan.FromSeconds(3.2),
|
||||
Nest = nest,
|
||||
Request = request
|
||||
};
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.nestquote");
|
||||
|
||||
try
|
||||
{
|
||||
await original.SaveAsync(path);
|
||||
var loaded = await NestResponse.LoadAsync(path);
|
||||
|
||||
Assert.Equal(original.SheetCount, loaded.SheetCount);
|
||||
Assert.Equal(original.Utilization, loaded.Utilization, precision: 4);
|
||||
Assert.Equal(original.CutTime, loaded.CutTime);
|
||||
Assert.Equal(original.Elapsed, loaded.Elapsed);
|
||||
|
||||
Assert.Equal(original.Request.Material, loaded.Request.Material);
|
||||
Assert.Equal(original.Request.Thickness, loaded.Request.Thickness);
|
||||
Assert.Equal(original.Request.Parts.Count, loaded.Request.Parts.Count);
|
||||
Assert.Equal(original.Request.Parts[0].DxfPath, loaded.Request.Parts[0].DxfPath);
|
||||
Assert.Equal(original.Request.Parts[0].Quantity, loaded.Request.Parts[0].Quantity);
|
||||
|
||||
Assert.NotNull(loaded.Nest);
|
||||
Assert.Single(loaded.Nest.Plates);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
OpenNest.Tests/Api/NestRunnerTests.cs
Normal file
78
OpenNest.Tests/Api/NestRunnerTests.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Api;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Tests.Api;
|
||||
|
||||
public class NestRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_SinglePart_ProducesResponse()
|
||||
{
|
||||
var dxfPath = CreateTempSquareDxf(2, 2);
|
||||
|
||||
try
|
||||
{
|
||||
var request = new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { DxfPath = dxfPath, Quantity = 4 }],
|
||||
SheetSize = new Size(10, 10),
|
||||
Spacing = 0.1
|
||||
};
|
||||
|
||||
var response = await NestRunner.RunAsync(request);
|
||||
|
||||
Assert.NotNull(response);
|
||||
Assert.NotNull(response.Nest);
|
||||
Assert.True(response.SheetCount >= 1);
|
||||
Assert.True(response.Utilization > 0);
|
||||
Assert.Equal(request, response.Request);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(dxfPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_BadDxfPath_Throws()
|
||||
{
|
||||
var request = new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { DxfPath = "nonexistent.dxf", Quantity = 1 }]
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => NestRunner.RunAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_EmptyParts_Throws()
|
||||
{
|
||||
var request = new NestRequest { Parts = [] };
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => NestRunner.RunAsync(request));
|
||||
}
|
||||
|
||||
private static string CreateTempSquareDxf(double width, double height)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(width, 0)));
|
||||
shape.Entities.Add(new Line(new Vector(width, 0), new Vector(width, height)));
|
||||
shape.Entities.Add(new Line(new Vector(width, height), new Vector(0, height)));
|
||||
shape.Entities.Add(new Line(new Vector(0, height), new Vector(0, 0)));
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(shape);
|
||||
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.dxf");
|
||||
|
||||
var exporter = new DxfExporter();
|
||||
exporter.ExportProgram(pgm, path);
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
147
OpenNest.Tests/IterativeShrinkFillerTests.cs
Normal file
147
OpenNest.Tests/IterativeShrinkFillerTests.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class IterativeShrinkFillerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Fill_NullItems_ReturnsEmpty()
|
||||
{
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();
|
||||
var result = IterativeShrinkFiller.Fill(null, new Box(0, 0, 100, 100), fillFunc, 1.0);
|
||||
|
||||
Assert.Empty(result.Parts);
|
||||
Assert.Empty(result.Leftovers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_EmptyItems_ReturnsEmpty()
|
||||
{
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();
|
||||
var result = IterativeShrinkFiller.Fill(new List<NestItem>(), new Box(0, 0, 100, 100), fillFunc, 1.0);
|
||||
|
||||
Assert.Empty(result.Parts);
|
||||
Assert.Empty(result.Leftovers);
|
||||
}
|
||||
|
||||
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
|
||||
{
|
||||
var pgm = new OpenNest.CNC.Program();
|
||||
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||
return new Drawing(name, pgm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_SingleItem_PlacesParts()
|
||||
{
|
||||
var drawing = MakeRectDrawing(20, 10);
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = drawing, Quantity = 5 }
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var plate = new Plate(b.Width, b.Length);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
|
||||
|
||||
Assert.True(result.Parts.Count > 0, "Should place parts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_MultipleItems_PlacesFromBoth()
|
||||
{
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = MakeRectDrawing(20, 10, "large"), Quantity = 5 },
|
||||
new NestItem { Drawing = MakeRectDrawing(8, 5, "small"), Quantity = 5 },
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var plate = new Plate(b.Width, b.Length);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
|
||||
|
||||
var largeCount = result.Parts.Count(p => p.BaseDrawing.Name == "large");
|
||||
var smallCount = result.Parts.Count(p => p.BaseDrawing.Name == "small");
|
||||
|
||||
Assert.True(largeCount > 0, "Should place large parts");
|
||||
Assert.True(smallCount > 0, "Should place small parts in remaining space");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_UnfilledQuantity_ReturnsLeftovers()
|
||||
{
|
||||
// Huge quantity that can't all fit on a small plate
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 1000 },
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var plate = new Plate(b.Width, b.Length);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 60, 30), fillFunc, 1.0);
|
||||
|
||||
Assert.True(result.Parts.Count > 0, "Should place some parts");
|
||||
Assert.True(result.Leftovers.Count > 0, "Should have leftovers");
|
||||
Assert.True(result.Leftovers[0].Quantity > 0, "Leftover quantity should be positive");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_UnlimitedQuantity_PlacesParts()
|
||||
{
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 0 }
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var plate = new Plate(b.Width, b.Length);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
|
||||
|
||||
Assert.True(result.Parts.Count > 0, "Unlimited qty items should still be placed");
|
||||
Assert.Empty(result.Leftovers); // unlimited items never produce leftovers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_RespectsCancellation()
|
||||
{
|
||||
var cts = new System.Threading.CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 10 }
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 100, 100), fillFunc, 1.0, cts.Token);
|
||||
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
|
||||
@@ -24,11 +24,10 @@ public class PairFillerTests
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
var workArea = new Box(0, 0, 120, 60);
|
||||
|
||||
var parts = filler.Fill(item, workArea);
|
||||
var result = filler.Fill(item, workArea);
|
||||
|
||||
Assert.NotNull(parts);
|
||||
// Pair filling may or may not find interlocking pairs for rectangles,
|
||||
// but should return a non-null list.
|
||||
Assert.NotNull(result.Parts);
|
||||
Assert.NotNull(result.BestFits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -39,10 +38,10 @@ public class PairFillerTests
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 20) };
|
||||
var workArea = new Box(0, 0, 10, 10);
|
||||
|
||||
var parts = filler.Fill(item, workArea);
|
||||
var result = filler.Fill(item, workArea);
|
||||
|
||||
Assert.NotNull(parts);
|
||||
Assert.Empty(parts);
|
||||
Assert.NotNull(result.Parts);
|
||||
Assert.Empty(result.Parts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -56,9 +55,8 @@ public class PairFillerTests
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
var workArea = new Box(0, 0, 120, 60);
|
||||
|
||||
var parts = filler.Fill(item, workArea, token: cts.Token);
|
||||
var result = filler.Fill(item, workArea, token: cts.Token);
|
||||
|
||||
// Should return empty or partial — not throw
|
||||
Assert.NotNull(parts);
|
||||
Assert.NotNull(result.Parts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ void PrintUsage()
|
||||
Console.Error.WriteLine("Options:");
|
||||
Console.Error.WriteLine(" --spacing <value> Part spacing (default: 0.5)");
|
||||
Console.Error.WriteLine(" --db <path> SQLite database path (default: OpenNestTraining.db)");
|
||||
Console.Error.WriteLine(" --save-nests <dir> Directory to save individual .opnest nests for each winner");
|
||||
Console.Error.WriteLine(" --save-nests <dir> Directory to save individual .nest nests for each winner");
|
||||
Console.Error.WriteLine(" --template <path> Nest template (.nstdot) for plate defaults");
|
||||
Console.Error.WriteLine(" -h, --help Show this help");
|
||||
}
|
||||
|
||||
14
OpenNest.sln
14
OpenNest.sln
@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Training", "OpenNe
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Tests", "OpenNest.Tests\OpenNest.Tests.csproj", "{03539EB7-9DB2-4634-A6FD-F094B9603596}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Api", "OpenNest.Api\OpenNest.Api.csproj", "{44D2810A-16EF-46A4-859C-B897147D8D3C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -139,6 +141,18 @@ Global
|
||||
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x64.Build.0 = Release|Any CPU
|
||||
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.Build.0 = Release|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -506,10 +506,15 @@ namespace OpenNest.Controls
|
||||
part.Draw(g, (i + 1).ToString());
|
||||
}
|
||||
|
||||
// Draw stationary preview parts (overall best — full opacity)
|
||||
for (var i = 0; i < stationaryParts.Count; i++)
|
||||
// Draw preview parts — active (current strategy) takes precedence
|
||||
// over stationary (overall best) to avoid overlapping fills.
|
||||
var previewParts = activeParts.Count > 0 ? activeParts : stationaryParts;
|
||||
var previewBrush = activeParts.Count > 0 ? ColorScheme.ActivePreviewPartBrush : ColorScheme.PreviewPartBrush;
|
||||
var previewPen = activeParts.Count > 0 ? ColorScheme.ActivePreviewPartPen : ColorScheme.PreviewPartPen;
|
||||
|
||||
for (var i = 0; i < previewParts.Count; i++)
|
||||
{
|
||||
var part = stationaryParts[i];
|
||||
var part = previewParts[i];
|
||||
|
||||
if (part.IsDirty)
|
||||
part.Update(this);
|
||||
@@ -518,24 +523,8 @@ namespace OpenNest.Controls
|
||||
if (!path.GetBounds().IntersectsWith(viewBounds))
|
||||
continue;
|
||||
|
||||
g.FillPath(ColorScheme.PreviewPartBrush, path);
|
||||
g.DrawPath(ColorScheme.PreviewPartPen, path);
|
||||
}
|
||||
|
||||
// Draw active preview parts (current strategy — reduced opacity)
|
||||
for (var i = 0; i < activeParts.Count; i++)
|
||||
{
|
||||
var part = activeParts[i];
|
||||
|
||||
if (part.IsDirty)
|
||||
part.Update(this);
|
||||
|
||||
var path = part.Path;
|
||||
if (!path.GetBounds().IntersectsWith(viewBounds))
|
||||
continue;
|
||||
|
||||
g.FillPath(ColorScheme.ActivePreviewPartBrush, path);
|
||||
g.DrawPath(ColorScheme.ActivePreviewPartPen, path);
|
||||
g.FillPath(previewBrush, path);
|
||||
g.DrawPath(previewPen, path);
|
||||
}
|
||||
|
||||
if (DrawOffset && Plate.PartSpacing > 0)
|
||||
@@ -899,6 +888,7 @@ namespace OpenNest.Controls
|
||||
public void SetStationaryParts(List<Part> parts)
|
||||
{
|
||||
stationaryParts.Clear();
|
||||
activeParts.Clear();
|
||||
|
||||
if (parts != null)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Api;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Api;
|
||||
using Timer = System.Timers.Timer;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Api;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<Compile Remove="Controls\LayoutViewGL.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
|
||||
|
||||
620
docs/superpowers/plans/2026-03-19-iterative-shrink-fill.md
Normal file
620
docs/superpowers/plans/2026-03-19-iterative-shrink-fill.md
Normal file
@@ -0,0 +1,620 @@
|
||||
# Iterative Shrink-Fill Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace `StripNestEngine`'s single-strip approach with iterative shrink-fill — every multi-quantity drawing gets shrink-fitted into its tightest sub-region using dual-direction selection, with leftovers packed at the end.
|
||||
|
||||
**Architecture:** New `IterativeShrinkFiller` static class composes existing `RemnantFiller` + `ShrinkFiller` with a dual-direction wrapper closure. `StripNestEngine.Nest` becomes a thin orchestrator calling the new filler then packing leftovers. No changes to `NestEngineBase`, `DefaultNestEngine`, or UI.
|
||||
|
||||
**Tech Stack:** .NET 8, xUnit, OpenNest.Engine
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-19-iterative-shrink-fill-design.md`
|
||||
|
||||
---
|
||||
|
||||
### File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `OpenNest.Engine/Fill/IterativeShrinkFiller.cs` | **New.** Static class + result type. Wraps a raw fill function with dual-direction `ShrinkFiller.Shrink`, passes the wrapper to `RemnantFiller.FillItems`. Returns placed parts + leftover items. |
|
||||
| `OpenNest.Engine/StripNestEngine.cs` | **Modify.** Rewrite `Nest` to separate items, call `IterativeShrinkFiller.Fill`, pack leftovers. Delete `SelectStripItemIndex`, `EstimateStripDimension`, `TryOrientation`, `ShrinkFill`. |
|
||||
| `OpenNest.Engine/StripNestResult.cs` | **Delete.** No longer needed. |
|
||||
| `OpenNest.Engine/StripDirection.cs` | **Delete.** No longer needed. |
|
||||
| `OpenNest.Tests/IterativeShrinkFillerTests.cs` | **New.** Unit tests for the new filler. |
|
||||
| `OpenNest.Tests/EngineRefactorSmokeTests.cs` | **Verify.** Existing `StripEngine_Nest_ProducesResults` must still pass. |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: IterativeShrinkFiller — empty/null input
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Tests/IterativeShrinkFillerTests.cs`
|
||||
- Create: `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for empty/null input**
|
||||
|
||||
```csharp
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class IterativeShrinkFillerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Fill_NullItems_ReturnsEmpty()
|
||||
{
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();
|
||||
var result = IterativeShrinkFiller.Fill(null, new Box(0, 0, 100, 100), fillFunc, 1.0);
|
||||
|
||||
Assert.Empty(result.Parts);
|
||||
Assert.Empty(result.Leftovers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_EmptyItems_ReturnsEmpty()
|
||||
{
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();
|
||||
var result = IterativeShrinkFiller.Fill(new List<NestItem>(), new Box(0, 0, 100, 100), fillFunc, 1.0);
|
||||
|
||||
Assert.Empty(result.Parts);
|
||||
Assert.Empty(result.Leftovers);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests" --no-build 2>&1 || dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
|
||||
Expected: Build error — `IterativeShrinkFiller` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`:
|
||||
|
||||
```csharp
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
public class IterativeShrinkResult
|
||||
{
|
||||
public List<Part> Parts { get; set; } = new();
|
||||
public List<NestItem> Leftovers { get; set; } = new();
|
||||
}
|
||||
|
||||
public static class IterativeShrinkFiller
|
||||
{
|
||||
public static IterativeShrinkResult Fill(
|
||||
List<NestItem> items,
|
||||
Box workArea,
|
||||
Func<NestItem, Box, List<Part>> fillFunc,
|
||||
double spacing,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new IterativeShrinkResult();
|
||||
|
||||
// TODO: dual-direction shrink logic
|
||||
return new IterativeShrinkResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
|
||||
Expected: 2 tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Tests/IterativeShrinkFillerTests.cs OpenNest.Engine/Fill/IterativeShrinkFiller.cs
|
||||
git commit -m "feat(engine): add IterativeShrinkFiller skeleton with empty/null tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: IterativeShrinkFiller — dual-direction shrink core logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`
|
||||
- Modify: `OpenNest.Tests/IterativeShrinkFillerTests.cs`
|
||||
|
||||
**Context:** The core algorithm wraps the caller's `fillFunc` in a closure that calls `ShrinkFiller.Shrink` in both axis directions and picks the better `FillScore`, then passes this wrapper to `RemnantFiller.FillItems`.
|
||||
|
||||
- [ ] **Step 1: Write failing test — single item gets shrink-filled**
|
||||
|
||||
Add to `IterativeShrinkFillerTests.cs`:
|
||||
|
||||
```csharp
|
||||
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
|
||||
{
|
||||
var pgm = new OpenNest.CNC.Program();
|
||||
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||
return new Drawing(name, pgm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_SingleItem_PlacesParts()
|
||||
{
|
||||
var drawing = MakeRectDrawing(20, 10);
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = drawing, Quantity = 5 }
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var plate = new Plate(b.Width, b.Length);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
|
||||
|
||||
Assert.True(result.Parts.Count > 0, "Should place parts");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests.Fill_SingleItem_PlacesParts"`
|
||||
Expected: FAIL — returns 0 parts (skeleton returns empty).
|
||||
|
||||
- [ ] **Step 3: Implement dual-direction shrink logic**
|
||||
|
||||
Replace the TODO in `IterativeShrinkFiller.Fill`:
|
||||
|
||||
```csharp
|
||||
public static IterativeShrinkResult Fill(
|
||||
List<NestItem> items,
|
||||
Box workArea,
|
||||
Func<NestItem, Box, List<Part>> fillFunc,
|
||||
double spacing,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
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 to an estimated
|
||||
// max capacity so they are actually processed.
|
||||
var workItems = new List<NestItem>(items.Count);
|
||||
var unlimitedDrawings = new HashSet<string>();
|
||||
|
||||
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;
|
||||
|
||||
unlimitedDrawings.Add(item.Drawing.Name);
|
||||
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);
|
||||
|
||||
Func<NestItem, Box, List<Part>> shrinkWrapper = (ni, box) =>
|
||||
{
|
||||
var heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token);
|
||||
var widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token);
|
||||
|
||||
var heightScore = FillScore.Compute(heightResult.Parts, box);
|
||||
var widthScore = FillScore.Compute(widthResult.Parts, box);
|
||||
|
||||
return widthScore > heightScore ? widthResult.Parts : heightResult.Parts;
|
||||
};
|
||||
|
||||
var placed = filler.FillItems(workItems, shrinkWrapper, token);
|
||||
|
||||
// Build leftovers: compare placed count to original quantities.
|
||||
// RemnantFiller.FillItems does NOT mutate NestItem.Quantity.
|
||||
var leftovers = new List<NestItem>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var placedCount = placed.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
|
||||
|
||||
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 };
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- `RemnantFiller.FillItems` skips items with `Quantity <= 0` (its `localQty` check treats them as done). To work around this without modifying `RemnantFiller`, unlimited items are converted to an estimated max capacity (`workArea / bboxArea * 2`) before being passed in.
|
||||
- `RemnantFiller.FillItems` does NOT mutate `NestItem.Quantity` — it tracks quantities internally via `localQty` dictionary (verified in `RemnantFillerTests2.FillItems_DoesNotMutateItemQuantities`).
|
||||
- The leftover calculation iterates the *original* items list (not `workItems`), so unlimited items are correctly skipped.
|
||||
- `FillScore` comparison: `widthScore > heightScore` uses the operator overload which is lexicographic (count first, then density).
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
|
||||
Expected: 3 tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/Fill/IterativeShrinkFiller.cs OpenNest.Tests/IterativeShrinkFillerTests.cs
|
||||
git commit -m "feat(engine): implement dual-direction shrink logic in IterativeShrinkFiller"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: IterativeShrinkFiller — multiple items and leftovers
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Tests/IterativeShrinkFillerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for multi-item and leftover scenarios**
|
||||
|
||||
Add to `IterativeShrinkFillerTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Fill_MultipleItems_PlacesFromBoth()
|
||||
{
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = MakeRectDrawing(20, 10, "large"), Quantity = 5 },
|
||||
new NestItem { Drawing = MakeRectDrawing(8, 5, "small"), Quantity = 5 },
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var plate = new Plate(b.Width, b.Length);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
|
||||
|
||||
var largeCount = result.Parts.Count(p => p.BaseDrawing.Name == "large");
|
||||
var smallCount = result.Parts.Count(p => p.BaseDrawing.Name == "small");
|
||||
|
||||
Assert.True(largeCount > 0, "Should place large parts");
|
||||
Assert.True(smallCount > 0, "Should place small parts in remaining space");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_UnfilledQuantity_ReturnsLeftovers()
|
||||
{
|
||||
// Huge quantity that can't all fit on a small plate
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 1000 },
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var plate = new Plate(b.Width, b.Length);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 60, 30), fillFunc, 1.0);
|
||||
|
||||
Assert.True(result.Parts.Count > 0, "Should place some parts");
|
||||
Assert.True(result.Leftovers.Count > 0, "Should have leftovers");
|
||||
Assert.True(result.Leftovers[0].Quantity > 0, "Leftover quantity should be positive");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_UnlimitedQuantity_PlacesParts()
|
||||
{
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 0 }
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var plate = new Plate(b.Width, b.Length);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
|
||||
|
||||
Assert.True(result.Parts.Count > 0, "Unlimited qty items should still be placed");
|
||||
Assert.Empty(result.Leftovers); // unlimited items never produce leftovers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_RespectsCancellation()
|
||||
{
|
||||
var cts = new System.Threading.CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 10 }
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 100, 100), fillFunc, 1.0, cts.Token);
|
||||
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
|
||||
Expected: All 7 tests pass (these test existing behavior, no new code needed — they verify the implementation from Task 2 handles these cases).
|
||||
|
||||
If any fail, fix the implementation in `IterativeShrinkFiller.Fill` and re-run.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Tests/IterativeShrinkFillerTests.cs
|
||||
git commit -m "test(engine): add multi-item, leftover, unlimited qty, and cancellation tests for IterativeShrinkFiller"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Rewrite StripNestEngine.Nest
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/StripNestEngine.cs`
|
||||
|
||||
**Context:** Replace the current `Nest` implementation that does single-strip + remnant fill with the new iterative approach. Keep `Fill`, `Fill(groupParts)`, and `PackArea` overrides unchanged — they still delegate to `DefaultNestEngine`.
|
||||
|
||||
- [ ] **Step 1: Run existing smoke test to establish baseline**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~StripEngine_Nest_ProducesResults"`
|
||||
Expected: PASS (current implementation works).
|
||||
|
||||
- [ ] **Step 2: Rewrite StripNestEngine.Nest**
|
||||
|
||||
Replace the `Nest` override and delete `SelectStripItemIndex`, `EstimateStripDimension`, `TryOrientation`, and `ShrinkFill` methods. The full file should become:
|
||||
|
||||
```csharp
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class StripNestEngine : NestEngineBase
|
||||
{
|
||||
public StripNestEngine(Plate plate) : base(plate)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "Strip";
|
||||
|
||||
public override string Description => "Iterative shrink-fill nesting for mixed-drawing layouts";
|
||||
|
||||
/// <summary>
|
||||
/// Single-item fill delegates to DefaultNestEngine.
|
||||
/// </summary>
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.Fill(item, workArea, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Group-parts fill delegates to DefaultNestEngine.
|
||||
/// </summary>
|
||||
public override List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.Fill(groupParts, workArea, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack delegates to DefaultNestEngine.
|
||||
/// </summary>
|
||||
public override List<Part> PackArea(Box box, List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.PackArea(box, items, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Multi-drawing iterative shrink-fill strategy.
|
||||
/// Each multi-quantity drawing gets shrink-filled into the tightest
|
||||
/// sub-region using dual-direction selection. Singles and leftovers
|
||||
/// are packed at the end.
|
||||
/// </summary>
|
||||
public override List<Part> Nest(List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
var workArea = Plate.WorkArea();
|
||||
|
||||
// Separate multi-quantity from singles.
|
||||
var fillItems = items
|
||||
.Where(i => i.Quantity != 1)
|
||||
.OrderBy(i => i.Priority)
|
||||
.ThenByDescending(i => i.Drawing.Area)
|
||||
.ToList();
|
||||
|
||||
var packItems = items
|
||||
.Where(i => i.Quantity == 1)
|
||||
.ToList();
|
||||
|
||||
var allParts = new List<Part>();
|
||||
|
||||
// Phase 1: Iterative shrink-fill for multi-quantity items.
|
||||
if (fillItems.Count > 0)
|
||||
{
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.Fill(ni, b, progress, token);
|
||||
};
|
||||
|
||||
var shrinkResult = IterativeShrinkFiller.Fill(
|
||||
fillItems, workArea, fillFunc, Plate.PartSpacing, token);
|
||||
|
||||
allParts.AddRange(shrinkResult.Parts);
|
||||
|
||||
// Add unfilled items to pack list.
|
||||
packItems.AddRange(shrinkResult.Leftovers);
|
||||
}
|
||||
|
||||
// Phase 2: Pack singles + leftovers into remaining space.
|
||||
packItems = packItems.Where(i => i.Quantity > 0).ToList();
|
||||
|
||||
if (packItems.Count > 0 && !token.IsCancellationRequested)
|
||||
{
|
||||
// Reconstruct remaining area from placed parts.
|
||||
var packArea = workArea;
|
||||
if (allParts.Count > 0)
|
||||
{
|
||||
var obstacles = allParts
|
||||
.Select(p => p.BoundingBox.Offset(Plate.PartSpacing))
|
||||
.ToList();
|
||||
var finder = new RemnantFinder(workArea, obstacles);
|
||||
var remnants = finder.FindRemnants();
|
||||
packArea = remnants.Count > 0 ? remnants[0] : new Box(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
if (packArea.Width > 0 && packArea.Length > 0)
|
||||
{
|
||||
var packParts = PackArea(packArea, packItems, progress, token);
|
||||
allParts.AddRange(packParts);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct placed quantities from original items.
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Quantity <= 0)
|
||||
continue;
|
||||
|
||||
var placed = allParts.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
}
|
||||
|
||||
return allParts;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run smoke test**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~StripEngine_Nest_ProducesResults"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run all engine tests to check for regressions**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~EngineRefactorSmokeTests|FullyQualifiedName~IterativeShrinkFillerTests|FullyQualifiedName~ShrinkFillerTests|FullyQualifiedName~RemnantFillerTests"`
|
||||
Expected: All pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripNestEngine.cs
|
||||
git commit -m "feat(engine): rewrite StripNestEngine.Nest with iterative shrink-fill"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Delete obsolete files
|
||||
|
||||
**Files:**
|
||||
- Delete: `OpenNest.Engine/StripNestResult.cs`
|
||||
- Delete: `OpenNest.Engine/StripDirection.cs`
|
||||
|
||||
- [ ] **Step 1: Verify no remaining references**
|
||||
|
||||
Run: `grep -r "StripNestResult\|StripDirection" --include="*.cs" . | grep -v "\.md"`
|
||||
|
||||
Expected: No matches (all references were in the old `StripNestEngine.TryOrientation` which was deleted in Task 4).
|
||||
|
||||
- [ ] **Step 2: Delete the files**
|
||||
|
||||
```bash
|
||||
rm OpenNest.Engine/StripNestResult.cs OpenNest.Engine/StripDirection.cs
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify no breakage**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 4: Run full test suite**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -u OpenNest.Engine/StripNestResult.cs OpenNest.Engine/StripDirection.cs
|
||||
git commit -m "refactor(engine): delete obsolete StripNestResult and StripDirection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update spec and docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-03-19-iterative-shrink-fill-design.md`
|
||||
|
||||
- [ ] **Step 1: Update spec with "rotating calipers already included" note**
|
||||
|
||||
The spec was already updated during planning. Verify it reflects the final state — no `AngleCandidateBuilder` or `NestItem.CaliperAngle` changes listed.
|
||||
|
||||
- [ ] **Step 2: Commit if any changes**
|
||||
|
||||
```bash
|
||||
git add docs/
|
||||
git commit -m "docs: finalize iterative shrink-fill spec"
|
||||
```
|
||||
1039
docs/superpowers/plans/2026-03-19-nest-api.md
Normal file
1039
docs/superpowers/plans/2026-03-19-nest-api.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
# Iterative Shrink-Fill Design
|
||||
|
||||
## Problem
|
||||
|
||||
`StripNestEngine` currently picks a single "strip" drawing (the highest-area item), shrink-fills it into the tightest sub-region, then fills remnants with remaining drawings. This wastes potential density — every drawing benefits from shrink-filling into its tightest sub-region, not just the first one.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. IterativeShrinkFiller
|
||||
|
||||
New static class in `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`.
|
||||
|
||||
**Responsibility:** Given an ordered list of multi-quantity `NestItem` and a work area, iteratively shrink-fill each item into the tightest sub-region using `RemnantFiller` + `ShrinkFiller`, returning placed parts and leftovers.
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
1. Create a `RemnantFiller` with the work area and spacing.
|
||||
2. Build a single fill function (closure) that wraps the caller-provided raw fill function with dual-direction shrink logic:
|
||||
- Calls `ShrinkFiller.Shrink` with `ShrinkAxis.Height` (bottom strip direction).
|
||||
- Calls `ShrinkFiller.Shrink` with `ShrinkAxis.Width` (left strip direction).
|
||||
- Compares results using `FillScore.Compute(parts, box)` where `box` is the remnant box passed by `RemnantFiller`. Since `FillScore` density is derived from placed parts' bounding box (not the work area parameter), the comparison is valid regardless of which box is used.
|
||||
- Returns the parts from whichever direction scores better.
|
||||
3. Pass this wrapper function and all items to `RemnantFiller.FillItems`, which drives the iteration — discovering free rectangles, iterating over items and boxes, and managing obstacle tracking.
|
||||
4. After `RemnantFiller.FillItems` returns, collect any unfilled quantities (including `Quantity <= 0` items which mean "unlimited") into a leftovers list.
|
||||
5. Return placed parts and leftovers. Remaining free space for the pack pass is reconstructed from placed parts by the caller (existing pattern), not by returning `RemnantFinder` state.
|
||||
|
||||
**Data flow:** Caller provides a raw single-item fill function (e.g., `DefaultNestEngine.Fill`) → `IterativeShrinkFiller` wraps it in a dual-direction shrink closure → passes the wrapper to `RemnantFiller.FillItems` which drives the loop.
|
||||
|
||||
**Note on quantities:** `Quantity <= 0` means "fill as many as possible" (unlimited). These items are included in the fill bucket (qty != 1), not the pack bucket.
|
||||
|
||||
**Interface:**
|
||||
|
||||
```csharp
|
||||
public class IterativeShrinkResult
|
||||
{
|
||||
public List<Part> Parts { get; set; }
|
||||
public List<NestItem> Leftovers { get; set; }
|
||||
}
|
||||
|
||||
public static class IterativeShrinkFiller
|
||||
{
|
||||
public static IterativeShrinkResult Fill(
|
||||
List<NestItem> items,
|
||||
Box workArea,
|
||||
Func<NestItem, Box, List<Part>> fillFunc,
|
||||
double spacing,
|
||||
CancellationToken token);
|
||||
}
|
||||
```
|
||||
|
||||
The class composes `RemnantFiller` and `ShrinkFiller` — it does not duplicate their logic.
|
||||
|
||||
### 2. Revised StripNestEngine.Nest
|
||||
|
||||
**Note:** The rotating calipers angle is already included via `RotationAnalysis.FindBestRotation`, which calls `RotatingCalipers.MinimumBoundingRectangle` and feeds the result as `bestRotation` into `AngleCandidateBuilder.Build`. No changes needed to the angle pipeline.
|
||||
|
||||
The `Nest` override becomes a thin orchestrator:
|
||||
|
||||
1. Separate items into multi-quantity (qty != 1) and singles (qty == 1).
|
||||
2. Sort multi-quantity items by `Priority` ascending, then `Drawing.Area` descending.
|
||||
3. Call `IterativeShrinkFiller.Fill` with the sorted multi-quantity items.
|
||||
4. Collect leftovers: unfilled multi-quantity remainders + all singles.
|
||||
5. If leftovers exist and free space remains, run `PackArea` into the remaining area.
|
||||
6. Deduct placed quantities from the original items. Return all parts.
|
||||
|
||||
**Deleted code:**
|
||||
- `SelectStripItemIndex` method
|
||||
- `EstimateStripDimension` method
|
||||
- `TryOrientation` method
|
||||
- `ShrinkFill` method
|
||||
|
||||
**Deleted files:**
|
||||
- `StripNestResult.cs`
|
||||
- `StripDirection.cs`
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `OpenNest.Engine/Fill/IterativeShrinkFiller.cs` | New — orchestrates RemnantFiller + ShrinkFiller with dual-direction selection |
|
||||
| `OpenNest.Engine/StripNestEngine.cs` | Rewrite Nest to use IterativeShrinkFiller + pack leftovers |
|
||||
| `OpenNest.Engine/StripNestResult.cs` | Delete |
|
||||
| `OpenNest.Engine/StripDirection.cs` | Delete |
|
||||
|
||||
## Not In Scope
|
||||
|
||||
- Trying multiple item orderings and picking the best overall `FillScore` — future follow-up once we confirm the iterative approach is fast enough.
|
||||
- Changes to `NestEngineBase`, `DefaultNestEngine`, `RemnantFiller`, `ShrinkFiller`, `RemnantFinder`, `AngleCandidateBuilder`, `NestItem`, or UI code.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Lead Item Rotation for Strip Nesting
|
||||
|
||||
## Problem
|
||||
|
||||
`StripNestEngine.Nest()` sorts multi-quantity items by priority then area descending, always placing the largest-area drawing first. This fixed ordering can produce suboptimal layouts — a different starting drawing may create a tighter shrink region that leaves more usable remnant space for subsequent items.
|
||||
|
||||
## Solution
|
||||
|
||||
Try multiple candidate orderings by promoting each of the top N largest drawings to the front of the fill list. Run the full pipeline for each ordering, score the results, and keep the best.
|
||||
|
||||
## Candidate Generation
|
||||
|
||||
- Take the multi-quantity fill items (already filtered from singles)
|
||||
- Identify the top `MaxLeadCandidates` (default 3) unique drawings by `Drawing.Area`, deduplicated by `Drawing` reference equality
|
||||
- If there is only one unique drawing, skip the multi-ordering loop entirely (no-op — only one possible ordering)
|
||||
- For each candidate drawing, create a reordered copy of the fill list where that drawing's items move to the front, preserving the original relative order for the remaining items
|
||||
- The default ordering (largest area first) is always the first candidate, so the feature never regresses
|
||||
- Lead promotion intentionally overrides the existing priority-then-area sort — the purpose is to explore whether a different lead item produces a better overall layout regardless of the default priority ordering
|
||||
|
||||
## Scoring
|
||||
|
||||
Use `FillScore` semantics for cross-ordering comparison: total placed part count as the primary metric, plate utilization (`sum(part.BaseDrawing.Area) / plate.WorkArea().Area()`) as tiebreaker. This is consistent with how `FillScore` works elsewhere in the codebase (count > density). Keep the first (default) result unless a later candidate is strictly better, so ties preserve the default ordering.
|
||||
|
||||
## Execution
|
||||
|
||||
- Run each candidate ordering sequentially through the existing pipeline: `IterativeShrinkFiller` → compaction → packing
|
||||
- No added parallelism — each run already uses `Parallel.Invoke` internally for shrink axes
|
||||
- `IterativeShrinkFiller.Fill` is a static method that creates fresh internal state (`RemnantFiller`, `placedSoFar` list) on each call, so the same input item list can be passed to multiple runs without interference. Neither `IterativeShrinkFiller` nor `RemnantFiller` mutate `NestItem.Quantity`. Each run also produces independent `Part` instances (created by `DefaultNestEngine.Fill`), so compaction mutations on one run's parts don't affect another.
|
||||
- Only the winning result gets applied to the quantity deduction at the end of `Nest()`
|
||||
|
||||
## Progress Reporting
|
||||
|
||||
- Each candidate run reports progress normally (user sees live updates during shrink iterations)
|
||||
- Between candidates, report a status message like "Lead item 2/3: [drawing name]"
|
||||
- Only the final winning result is reported with `isOverallBest: true` to avoid the UI flashing between intermediate results
|
||||
|
||||
## Early Exit
|
||||
|
||||
- If a candidate meets all requested quantities **and** plate utilization exceeds 50%, skip remaining candidates
|
||||
- Unlimited-quantity items (`Quantity <= 0`) never satisfy the quantity condition, so all candidates are always tried
|
||||
- Cancellation token is respected — if cancelled mid-run, return the best result across all completed candidates
|
||||
- The 50% threshold is a constant (`MinEarlyExitUtilization`) that can be tuned if typical nesting utilization proves higher or lower
|
||||
|
||||
## Scope
|
||||
|
||||
Changes are confined to `StripNestEngine.Nest()`. No modifications to `IterativeShrinkFiller`, `ShrinkFiller`, `DefaultNestEngine`, fill strategies, or the UI.
|
||||
|
||||
## Files
|
||||
|
||||
- Modify: `OpenNest.Engine/StripNestEngine.cs`
|
||||
- Add test: `OpenNest.Tests/StripNestEngineTests.cs` (verify multiple orderings are tried, early exit works)
|
||||
238
docs/superpowers/specs/2026-03-19-nest-api-design.md
Normal file
238
docs/superpowers/specs/2026-03-19-nest-api-design.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Nest API Design
|
||||
|
||||
## Overview
|
||||
|
||||
A new `OpenNest.Api` project providing a clean programmatic facade for nesting operations. A single `NestRequest` goes in, a self-contained `NestResponse` comes out. Designed for external callers (MCP server, console app, LaserQuote, future web API) that don't want to manually wire engine + timing + IO.
|
||||
|
||||
## Motivation
|
||||
|
||||
Today, running a nest programmatically requires manually coordinating:
|
||||
1. DXF import (IO layer)
|
||||
2. Plate/NestItem setup (Core)
|
||||
3. Engine selection and execution (Engine layer)
|
||||
4. Timing calculation (Core's `Timing` class)
|
||||
5. File persistence (IO layer)
|
||||
|
||||
This design wraps all five steps behind a single stateless call. The response captures the original request, making nests reproducible and re-priceable months later.
|
||||
|
||||
## New Project: `OpenNest.Api`
|
||||
|
||||
Class library targeting `net8.0-windows`. References Core, Engine, and IO.
|
||||
|
||||
```
|
||||
OpenNest.Api/
|
||||
CutParameters.cs
|
||||
NestRequest.cs
|
||||
NestRequestPart.cs
|
||||
NestStrategy.cs
|
||||
NestResponse.cs
|
||||
NestRunner.cs
|
||||
```
|
||||
|
||||
All types live in the `OpenNest.Api` namespace.
|
||||
|
||||
## Types
|
||||
|
||||
### CutParameters
|
||||
|
||||
Unified timing and quoting parameters. Replaces the existing `OpenNest.CutParameters` in Core.
|
||||
|
||||
```csharp
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public class CutParameters
|
||||
{
|
||||
public double Feedrate { get; init; } // in/min or mm/sec depending on Units
|
||||
public double RapidTravelRate { get; init; } // in/min or mm/sec
|
||||
public TimeSpan PierceTime { get; init; }
|
||||
public double LeadInLength { get; init; } // forward-looking: unused until Timing rework
|
||||
public string PostProcessor { get; init; } // forward-looking: unused until Timing rework
|
||||
public Units Units { get; init; }
|
||||
|
||||
public static CutParameters Default => new()
|
||||
{
|
||||
Feedrate = 100,
|
||||
RapidTravelRate = 300,
|
||||
PierceTime = TimeSpan.FromSeconds(0.5),
|
||||
Units = Units.Inches
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`LeadInLength` and `PostProcessor` are included for forward compatibility but will not be wired into `Timing.CalculateTime` until the Timing rework. Implementers should not attempt to use them in the initial implementation.
|
||||
|
||||
### NestRequestPart
|
||||
|
||||
A part to nest, identified by DXF file path. No `Drawing` reference — keeps the request fully serializable for persistence.
|
||||
|
||||
```csharp
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public class NestRequestPart
|
||||
{
|
||||
public string DxfPath { get; init; }
|
||||
public int Quantity { get; init; } = 1;
|
||||
public bool AllowRotation { get; init; } = true;
|
||||
public int Priority { get; init; } = 0;
|
||||
}
|
||||
```
|
||||
|
||||
### NestStrategy
|
||||
|
||||
```csharp
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public enum NestStrategy { Auto }
|
||||
```
|
||||
|
||||
- `Auto` maps to `DefaultNestEngine` (multi-phase fill).
|
||||
- Additional strategies (`Linear`, `BestFit`, `Pack`) will be added later, driven by ML-based auto-detection of part type during the training work. The intelligence for selecting the best strategy for a given part will live inside `DefaultNestEngine`.
|
||||
|
||||
### NestRequest
|
||||
|
||||
Immutable input capturing everything needed to run and reproduce a nest.
|
||||
|
||||
```csharp
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public class NestRequest
|
||||
{
|
||||
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
|
||||
public Size SheetSize { get; init; } = new(60, 120); // OpenNest.Geometry.Size(width, length)
|
||||
public string Material { get; init; } = "Steel, A1011 HR";
|
||||
public double Thickness { get; init; } = 0.06;
|
||||
public double Spacing { get; init; } = 0.1; // part-to-part spacing; edge spacing defaults to zero
|
||||
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
|
||||
public CutParameters Cutting { get; init; } = CutParameters.Default;
|
||||
}
|
||||
```
|
||||
|
||||
- `Parts` uses `IReadOnlyList<T>` to prevent mutation after construction, preserving reproducibility when the request is stored in the response.
|
||||
- `Spacing` maps to `Plate.PartSpacing`. `Plate.EdgeSpacing` defaults to zero on all sides.
|
||||
- `SheetSize` is `OpenNest.Geometry.Size` (not `System.Drawing.Size`).
|
||||
|
||||
### NestResponse
|
||||
|
||||
Immutable output containing computed metrics, the resulting `Nest`, and the original request for reproducibility.
|
||||
|
||||
```csharp
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public class NestResponse
|
||||
{
|
||||
public int SheetCount { get; init; }
|
||||
public double Utilization { get; init; }
|
||||
public TimeSpan CutTime { get; init; }
|
||||
public TimeSpan Elapsed { get; init; }
|
||||
public Nest Nest { get; init; }
|
||||
public NestRequest Request { get; init; }
|
||||
|
||||
public Task SaveAsync(string path) => ...;
|
||||
public static Task<NestResponse> LoadAsync(string path) => ...;
|
||||
}
|
||||
```
|
||||
|
||||
`SaveAsync`/`LoadAsync` live on the data class for API simplicity — a pragmatic choice over a separate IO helper class.
|
||||
|
||||
### NestRunner
|
||||
|
||||
Stateless orchestrator. Single public method.
|
||||
|
||||
```csharp
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public static class NestRunner
|
||||
{
|
||||
public static async Task<NestResponse> RunAsync(
|
||||
NestRequest request,
|
||||
IProgress<NestProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
// 1. Validate request (non-empty parts list, all DXF paths exist)
|
||||
// 2. Import DXFs → Drawings via DxfImporter + ConvertGeometry.ToProgram
|
||||
// 3. Create Plate from request.SheetSize / Thickness / Spacing
|
||||
// 4. Convert NestRequestParts → NestItems
|
||||
// 5. Multi-plate loop:
|
||||
// a. Create engine via NestEngineRegistry
|
||||
// b. Fill plate
|
||||
// c. Deduct placed quantities
|
||||
// d. If remaining quantities > 0, create next plate and repeat
|
||||
// 6. Compute TimingInfo → CutTime using request.Cutting (placeholder for Timing rework)
|
||||
// 7. Build and return NestResponse (with stopwatch for Elapsed)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Static class, no state, no DI. If we need dependency injection later, we add an instance-based overload.
|
||||
- `Timing.CalculateTime` is called with the new `CutParameters` (placeholder integration — `Timing` will be reworked later).
|
||||
|
||||
#### Multi-plate Loop
|
||||
|
||||
`NestRunner` handles multi-plate nesting: it fills a plate, deducts placed quantities from the remaining request, creates a new plate, and repeats until all quantities are met or no progress is made (a part doesn't fit on a fresh sheet). This is new logic — `AutoNester` and `NestEngineBase` are single-plate only.
|
||||
|
||||
#### Error Handling
|
||||
|
||||
- If any DXF file path does not exist or fails to import (empty geometry, conversion failure), `RunAsync` throws `FileNotFoundException` or `InvalidOperationException` with a message identifying the failing file. Fail-fast on first bad DXF — no partial results.
|
||||
- If cancellation is requested, the method throws `OperationCanceledException` per standard .NET patterns.
|
||||
|
||||
## Renames
|
||||
|
||||
| Current | New | Reason |
|
||||
|---------|-----|--------|
|
||||
| `OpenNest.NestResult` (Engine) | `OpenNest.OptimizationResult` | Frees the "result" name for the public API; this type is engine-internal (sequence/score/iterations) |
|
||||
| `OpenNest.CutParameters` (Core) | Deleted | Replaced by `OpenNest.Api.CutParameters` |
|
||||
| `.opnest` file extension | `.nest` | Standardize file extensions |
|
||||
|
||||
All references to the renamed types and extensions must be updated across the solution: Engine, Core, IO, MCP, Console, Training, Tests, and WinForms.
|
||||
|
||||
The WinForms project gains a reference to `OpenNest.Api` to use the new `CutParameters` type (it already references Core and Engine, so no circular dependency).
|
||||
|
||||
## Persistence
|
||||
|
||||
### File Extensions
|
||||
|
||||
- **`.nest`** — nest files (renamed from `.opnest`)
|
||||
- **`.nestquote`** — quote files (new)
|
||||
|
||||
### `.nestquote` Format
|
||||
|
||||
ZIP archive containing:
|
||||
|
||||
```
|
||||
quote.nestquote (ZIP)
|
||||
├── request.json ← serialized NestRequest
|
||||
├── response.json ← computed metrics (SheetCount, Utilization, CutTime, Elapsed)
|
||||
└── nest.nest ← embedded .nest file (existing format, produced by NestWriter)
|
||||
```
|
||||
|
||||
- `NestResponse.SaveAsync(path)` writes this ZIP. The embedded `nest.nest` is written to a `MemoryStream` via `NestWriter`, then added as a ZIP entry alongside the JSON files.
|
||||
- `NestResponse.LoadAsync(path)` reads it back using `NestReader` for the `.nest` payload and JSON deserialization for the metadata.
|
||||
- Source DXF files are **not** embedded — they are referenced by path in `request.json`. The actual geometry is captured in the `.nest`. Paths exist so the request can be re-run with different parameters if the DXFs are still available.
|
||||
|
||||
## Consumer Integration
|
||||
|
||||
### MCP Server
|
||||
|
||||
The MCP server can expose a single `nest_and_quote` tool that takes request parameters and calls `NestRunner.RunAsync()` internally, replacing the current multi-tool orchestration for batch nesting workflows.
|
||||
|
||||
### Console App
|
||||
|
||||
The console app gains a one-liner for batch nesting:
|
||||
|
||||
```csharp
|
||||
var response = await NestRunner.RunAsync(request, progress, token);
|
||||
await response.SaveAsync(outputPath);
|
||||
```
|
||||
|
||||
### WinForms
|
||||
|
||||
The WinForms app continues using the engine directly for its interactive workflow. It gains a reference to `OpenNest.Api` only for the shared `CutParameters` type used by `TimingForm` and `CutParametersForm`.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- ML-based auto-strategy detection in `DefaultNestEngine` (future, part of training work)
|
||||
- `Timing` rework (will happen separately; placeholder integration for now)
|
||||
- Embedding source DXFs in `.nestquote` files
|
||||
- Builder pattern for `NestRequest` (C# `init` properties suffice)
|
||||
- DI/instance-based `NestRunner` (add later if needed)
|
||||
- Additional `NestStrategy` enum values beyond `Auto` (added with ML work)
|
||||
Reference in New Issue
Block a user