Compare commits
27 Commits
| 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 |
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace OpenNest.Api;
|
||||||
|
|
||||||
|
public enum NestStrategy { Auto }
|
||||||
@@ -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.
|
// DXF-only mode: create a fresh nest.
|
||||||
if (dxfFiles.Count == 0)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,12 +387,12 @@ static class NestConsole
|
|||||||
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
||||||
Console.Error.WriteLine();
|
Console.Error.WriteLine();
|
||||||
Console.Error.WriteLine("Arguments:");
|
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();
|
||||||
Console.Error.WriteLine("Modes:");
|
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(" <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();
|
||||||
Console.Error.WriteLine("Options:");
|
Console.Error.WriteLine("Options:");
|
||||||
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)");
|
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(" --quantity <n> Max parts to place (default: 0 = unlimited)");
|
||||||
Console.Error.WriteLine(" --spacing <value> Override part spacing");
|
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(" --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(" --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(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
|
||||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
|
namespace OpenNest.Api;
|
||||||
|
|
||||||
namespace OpenNest
|
|
||||||
{
|
|
||||||
public class CutParameters
|
public class CutParameters
|
||||||
{
|
{
|
||||||
public double Feedrate { get; set; }
|
public double Feedrate { get; set; }
|
||||||
|
|
||||||
public double RapidTravelRate { get; set; }
|
public double RapidTravelRate { get; set; }
|
||||||
|
|
||||||
public TimeSpan PierceTime { get; set; }
|
public TimeSpan PierceTime { get; set; }
|
||||||
|
public double LeadInLength { get; set; }
|
||||||
|
public string PostProcessor { get; set; }
|
||||||
public Units Units { get; set; }
|
public Units Units { get; set; }
|
||||||
}
|
|
||||||
|
public static CutParameters Default => new()
|
||||||
|
{
|
||||||
|
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.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using System;
|
using System;
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ namespace OpenNest.Engine.Fill
|
|||||||
public List<Part> Fill(Drawing drawing, double rotationAngle = 0,
|
public List<Part> Fill(Drawing drawing, double rotationAngle = 0,
|
||||||
int plateNumber = 0,
|
int plateNumber = 0,
|
||||||
CancellationToken token = default,
|
CancellationToken token = default,
|
||||||
IProgress<NestProgress> progress = null)
|
IProgress<NestProgress> progress = null,
|
||||||
|
List<Engine.BestFit.BestFitResult> bestFits = null)
|
||||||
{
|
{
|
||||||
var pair = BuildPair(drawing, rotationAngle);
|
var pair = BuildPair(drawing, rotationAngle);
|
||||||
if (pair == null)
|
if (pair == null)
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ namespace OpenNest.Engine.Fill
|
|||||||
|
|
||||||
public double HalfSpacing => PartSpacing / 2;
|
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)
|
private static Vector MakeOffset(NestDirection direction, double distance)
|
||||||
{
|
{
|
||||||
return direction == NestDirection.Horizontal
|
return direction == NestDirection.Horizontal
|
||||||
@@ -346,215 +341,9 @@ namespace OpenNest.Engine.Fill
|
|||||||
var gridResult = new List<Part>(rowPattern.Parts);
|
var gridResult = new List<Part>(rowPattern.Parts);
|
||||||
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
|
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;
|
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>
|
/// <summary>
|
||||||
/// Fills a single row of identical parts along one axis using geometry-aware spacing.
|
/// Fills a single row of identical parts along one axis using geometry-aware spacing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -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
|
namespace OpenNest.Engine.Fill
|
||||||
{
|
{
|
||||||
|
public class PairFillResult
|
||||||
|
{
|
||||||
|
public List<Part> Parts { get; set; } = new List<Part>();
|
||||||
|
public List<BestFitResult> BestFits { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fills a work area using interlocking part pairs from BestFitCache.
|
/// Fills a work area using interlocking part pairs from BestFitCache.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -24,33 +30,40 @@ namespace OpenNest.Engine.Fill
|
|||||||
private readonly Size plateSize;
|
private readonly Size plateSize;
|
||||||
private readonly double partSpacing;
|
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)
|
public PairFiller(Size plateSize, double partSpacing)
|
||||||
{
|
{
|
||||||
this.plateSize = plateSize;
|
this.plateSize = plateSize;
|
||||||
this.partSpacing = partSpacing;
|
this.partSpacing = partSpacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Part> Fill(NestItem item, Box workArea,
|
public PairFillResult Fill(NestItem item, Box workArea,
|
||||||
int plateNumber = 0,
|
int plateNumber = 0,
|
||||||
CancellationToken token = default,
|
CancellationToken token = default,
|
||||||
IProgress<NestProgress> progress = null)
|
IProgress<NestProgress> progress = null)
|
||||||
{
|
{
|
||||||
BestFits = BestFitCache.GetOrCompute(
|
var bestFits = BestFitCache.GetOrCompute(
|
||||||
item.Drawing, plateSize.Length, plateSize.Width, partSpacing);
|
item.Drawing, plateSize.Length, plateSize.Width, partSpacing);
|
||||||
|
|
||||||
var candidates = SelectPairCandidates(BestFits, workArea);
|
var candidates = SelectPairCandidates(bestFits, workArea);
|
||||||
Debug.WriteLine($"[PairFiller] Total: {BestFits.Count}, Kept: {BestFits.Count(r => r.Keep)}, Trying: {candidates.Count}");
|
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}");
|
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;
|
List<Part> best = null;
|
||||||
var bestScore = default(FillScore);
|
var bestScore = default(FillScore);
|
||||||
var sinceImproved = 0;
|
var sinceImproved = 0;
|
||||||
|
var effectiveWorkArea = workArea;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -58,21 +71,15 @@ namespace OpenNest.Engine.Fill
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
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;
|
best = filled;
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
sinceImproved = 0;
|
sinceImproved = 0;
|
||||||
}
|
effectiveWorkArea = TryReduceWorkArea(filled, targetCount, workArea, effectiveWorkArea);
|
||||||
else
|
|
||||||
{
|
|
||||||
sinceImproved++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -98,16 +105,64 @@ namespace OpenNest.Engine.Fill
|
|||||||
return best ?? new List<Part>();
|
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)
|
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing, Box workArea)
|
||||||
{
|
{
|
||||||
var pairParts = candidate.BuildParts(drawing);
|
var pairParts = candidate.BuildParts(drawing);
|
||||||
var engine = new FillLinear(workArea, partSpacing);
|
var engine = new FillLinear(workArea, partSpacing);
|
||||||
|
var angles = BuildTilingAngles(candidate);
|
||||||
|
return FillHelpers.FillPattern(engine, pairParts, angles, workArea);
|
||||||
|
}
|
||||||
|
|
||||||
var p0 = FillHelpers.BuildRotatedPattern(pairParts, 0);
|
private static List<double> BuildTilingAngles(BestFitResult candidate)
|
||||||
var p90 = FillHelpers.BuildRotatedPattern(pairParts, Angle.HalfPI);
|
{
|
||||||
engine.RemainderPatterns = new List<Pattern> { p0, p90 };
|
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)
|
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})");
|
Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SortByEstimatedCount(top, workArea);
|
||||||
|
|
||||||
return top;
|
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>();
|
return new List<Part>();
|
||||||
|
|
||||||
var allParts = new List<Part>();
|
var allParts = new List<Part>();
|
||||||
var madeProgress = true;
|
|
||||||
|
|
||||||
// Track quantities locally — do not mutate the input NestItem objects.
|
// Track quantities locally — do not mutate the input NestItem objects.
|
||||||
var localQty = new Dictionary<string, int>();
|
var localQty = BuildLocalQuantities(items);
|
||||||
foreach (var item in items)
|
|
||||||
localQty[item.Drawing.Name] = item.Quantity;
|
|
||||||
|
|
||||||
while (madeProgress && !token.IsCancellationRequested)
|
while (!token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
madeProgress = false;
|
var minDim = FindMinItemDimension(items, localQty);
|
||||||
|
if (minDim == double.MaxValue)
|
||||||
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)
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
var freeBoxes = finder.FindRemnants(minRemnantDim);
|
var freeBoxes = finder.FindRemnants(minDim);
|
||||||
|
|
||||||
if (freeBoxes.Count == 0)
|
if (freeBoxes.Count == 0)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
foreach (var item in items)
|
if (!TryFillOneItem(items, freeBoxes, localQty, fillFunc, allParts, token))
|
||||||
{
|
|
||||||
if (token.IsCancellationRequested)
|
|
||||||
break;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return allParts;
|
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.Geometry;
|
||||||
|
using OpenNest.RectanglePacking;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -17,7 +18,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fills a box then iteratively shrinks one axis by the spacing amount
|
/// Fills a box then iteratively shrinks one axis by the spacing amount
|
||||||
/// until the part count drops. Returns the tightest box that still fits
|
/// until the part count drops. Returns the tightest box that still fits
|
||||||
/// the same number of parts.
|
/// the target number of parts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ShrinkFiller
|
public static class ShrinkFiller
|
||||||
{
|
{
|
||||||
@@ -27,17 +28,43 @@ namespace OpenNest.Engine.Fill
|
|||||||
double spacing,
|
double spacing,
|
||||||
ShrinkAxis axis,
|
ShrinkAxis axis,
|
||||||
CancellationToken token = default,
|
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)
|
if (parts == null || parts.Count == 0)
|
||||||
return new ShrinkResult { Parts = parts ?? new List<Part>(), Dimension = 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 bestParts = parts;
|
||||||
var bestDim = MeasureDimension(parts, box, axis);
|
var bestDim = MeasureDimension(parts, box, axis);
|
||||||
|
|
||||||
|
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, box, axis, bestDim);
|
||||||
|
|
||||||
for (var i = 0; i < maxIterations; i++)
|
for (var i = 0; i < maxIterations; i++)
|
||||||
{
|
{
|
||||||
if (token.IsCancellationRequested)
|
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, trialDim, box.Length)
|
||||||
: new Box(box.X, box.Y, box.Width, trialDim);
|
: 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);
|
var trialParts = fillFunc(item, trialBox);
|
||||||
|
|
||||||
if (trialParts == null || trialParts.Count < targetCount)
|
if (trialParts == null || trialParts.Count < shrinkTarget)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
bestParts = trialParts;
|
bestParts = trialParts;
|
||||||
bestDim = MeasureDimension(trialParts, box, axis);
|
bestDim = MeasureDimension(trialParts, box, axis);
|
||||||
|
|
||||||
|
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, trialBox, axis, bestDim);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ShrinkResult { Parts = bestParts, Dimension = 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)
|
private static double MeasureDimension(List<Part> parts, Box box, ShrinkAxis axis)
|
||||||
{
|
{
|
||||||
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
|
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace OpenNest.Engine.Nfp
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result of a nest optimization run.
|
/// Result of a nest optimization run.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NestResult
|
public class OptimizationResult
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The best sequence found: (drawingId, rotation, drawing) tuples in placement order.
|
/// The best sequence found: (drawingId, rotation, drawing) tuples in placement order.
|
||||||
@@ -32,7 +32,7 @@ namespace OpenNest.Engine.Nfp
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface INestOptimizer
|
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,
|
Dictionary<int, List<double>> candidateRotations,
|
||||||
CancellationToken cancellation = default);
|
CancellationToken cancellation = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace OpenNest.Engine.Nfp
|
|||||||
private const double DefaultMinTemperature = 0.1;
|
private const double DefaultMinTemperature = 0.1;
|
||||||
private const int DefaultMaxNoImprovement = 500;
|
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,
|
Dictionary<int, List<double>> candidateRotations,
|
||||||
CancellationToken cancellation = default)
|
CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
@@ -29,7 +29,7 @@ namespace OpenNest.Engine.Nfp
|
|||||||
var sequence = BuildInitialSequence(items, candidateRotations);
|
var sequence = BuildInitialSequence(items, candidateRotations);
|
||||||
|
|
||||||
if (sequence.Count == 0)
|
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.
|
// Evaluate initial solution.
|
||||||
var blf = new BottomLeftFill(workArea, cache);
|
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}");
|
Debug.WriteLine($"[SA] Done: {iteration} iters, best={bestScore.Count} parts, density={bestScore.Density:P1}");
|
||||||
|
|
||||||
return new NestResult
|
return new OptimizationResult
|
||||||
{
|
{
|
||||||
Sequence = bestSequence,
|
Sequence = bestSequence,
|
||||||
Score = bestScore,
|
Score = bestScore,
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ namespace OpenNest.Engine.Strategies
|
|||||||
var result = filler.Fill(context.Item, context.WorkArea,
|
var result = filler.Fill(context.Item, context.WorkArea,
|
||||||
context.PlateNumber, context.Token, context.Progress);
|
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 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>
|
/// <summary>
|
||||||
/// Single-item fill delegates to DefaultNestEngine.
|
/// Single-item fill delegates to DefaultNestEngine.
|
||||||
/// The strip strategy adds value for multi-drawing nesting, not single-item fills.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public override List<Part> Fill(NestItem item, Box workArea,
|
public override List<Part> Fill(NestItem item, Box workArea,
|
||||||
IProgress<NestProgress> progress, CancellationToken token)
|
IProgress<NestProgress> progress, CancellationToken token)
|
||||||
@@ -49,66 +48,10 @@ namespace OpenNest
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Selects the item that consumes the most plate area (bounding box area x quantity).
|
/// Multi-drawing iterative shrink-fill strategy.
|
||||||
/// Returns the index into the items list.
|
/// Each multi-quantity drawing gets shrink-filled into the tightest
|
||||||
/// </summary>
|
/// sub-region using dual-direction selection. Singles and leftovers
|
||||||
private static int SelectStripItemIndex(List<NestItem> items, Box workArea)
|
/// are packed at the end.
|
||||||
{
|
|
||||||
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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public override List<Part> Nest(List<NestItem> items,
|
public override List<Part> Nest(List<NestItem> items,
|
||||||
IProgress<NestProgress> progress, CancellationToken token)
|
IProgress<NestProgress> progress, CancellationToken token)
|
||||||
@@ -118,147 +61,83 @@ namespace OpenNest
|
|||||||
|
|
||||||
var workArea = Plate.WorkArea();
|
var workArea = Plate.WorkArea();
|
||||||
|
|
||||||
// Select which item gets the strip treatment.
|
// Separate multi-quantity from singles.
|
||||||
var stripIndex = SelectStripItemIndex(items, workArea);
|
var fillItems = items
|
||||||
var stripItem = items[stripIndex];
|
.Where(i => i.Quantity != 1)
|
||||||
var remainderItems = items.Where((_, i) => i != stripIndex).ToList();
|
.OrderBy(i => i.Priority)
|
||||||
|
.ThenByDescending(i => i.Drawing.Area)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
// Try both orientations.
|
var packItems = items
|
||||||
var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, progress, token);
|
.Where(i => i.Quantity == 1)
|
||||||
var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, progress, token);
|
.ToList();
|
||||||
|
|
||||||
// Pick the better result.
|
var allParts = new List<Part>();
|
||||||
var winner = bottomResult.Score >= leftResult.Score
|
|
||||||
? bottomResult.Parts
|
|
||||||
: leftResult.Parts;
|
|
||||||
|
|
||||||
// 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)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
if (item.Quantity <= 0)
|
if (item.Quantity <= 0)
|
||||||
continue;
|
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);
|
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 static class NestFormat
|
||||||
{
|
{
|
||||||
public const string FileExtension = ".opnest";
|
public const string FileExtension = ".nest";
|
||||||
public const string FileFilter = "Nest Files (*.opnest)|*.opnest";
|
public const string FileFilter = "Nest Files (*.nest)|*.nest";
|
||||||
|
|
||||||
public static readonly JsonSerializerOptions JsonOptions = new()
|
public static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,12 +26,17 @@ namespace OpenNest.IO
|
|||||||
}
|
}
|
||||||
|
|
||||||
public bool Write(string file)
|
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;
|
nest.DateLastModified = DateTime.Now;
|
||||||
SetDrawingIds();
|
SetDrawingIds();
|
||||||
|
|
||||||
using var fileStream = new FileStream(file, FileMode.Create);
|
using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
|
||||||
using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create);
|
|
||||||
|
|
||||||
WriteNestJson(zipArchive);
|
WriteNestJson(zipArchive);
|
||||||
WritePrograms(zipArchive);
|
WritePrograms(zipArchive);
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ namespace OpenNest.Mcp.Tools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "load_nest")]
|
[McpServerTool(Name = "load_nest")]
|
||||||
[Description("Load an .opnest file into the session. Returns a summary of plates, parts, and drawings.")]
|
[Description("Load a .nest file into the session. Returns a summary of plates, parts, and drawings.")]
|
||||||
public string LoadNest([Description("Absolute path to the .opnest file")] string path)
|
public string LoadNest([Description("Absolute path to the .nest file")] string path)
|
||||||
{
|
{
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
return $"Error: file not found: {path}";
|
return $"Error: file not found: {path}";
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ namespace OpenNest.Mcp.Tools
|
|||||||
[McpServerTool(Name = "test_engine")]
|
[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.")]
|
[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(
|
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("Drawing name to fill with (default: first drawing)")] string drawingName = null,
|
||||||
[Description("Plate index to fill (default: 0)")] int plateIndex = 0,
|
[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))
|
if (!File.Exists(nestFile))
|
||||||
return $"Error: nest file not found: {nestFile}";
|
return $"Error: nest file not found: {nestFile}";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||||
|
|||||||
@@ -24,11 +24,10 @@ public class PairFillerTests
|
|||||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
var workArea = new Box(0, 0, 120, 60);
|
var workArea = new Box(0, 0, 120, 60);
|
||||||
|
|
||||||
var parts = filler.Fill(item, workArea);
|
var result = filler.Fill(item, workArea);
|
||||||
|
|
||||||
Assert.NotNull(parts);
|
Assert.NotNull(result.Parts);
|
||||||
// Pair filling may or may not find interlocking pairs for rectangles,
|
Assert.NotNull(result.BestFits);
|
||||||
// but should return a non-null list.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -39,10 +38,10 @@ public class PairFillerTests
|
|||||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 20) };
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 20) };
|
||||||
var workArea = new Box(0, 0, 10, 10);
|
var workArea = new Box(0, 0, 10, 10);
|
||||||
|
|
||||||
var parts = filler.Fill(item, workArea);
|
var result = filler.Fill(item, workArea);
|
||||||
|
|
||||||
Assert.NotNull(parts);
|
Assert.NotNull(result.Parts);
|
||||||
Assert.Empty(parts);
|
Assert.Empty(result.Parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -56,9 +55,8 @@ public class PairFillerTests
|
|||||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
var workArea = new Box(0, 0, 120, 60);
|
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(result.Parts);
|
||||||
Assert.NotNull(parts);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ void PrintUsage()
|
|||||||
Console.Error.WriteLine("Options:");
|
Console.Error.WriteLine("Options:");
|
||||||
Console.Error.WriteLine(" --spacing <value> Part spacing (default: 0.5)");
|
Console.Error.WriteLine(" --spacing <value> Part spacing (default: 0.5)");
|
||||||
Console.Error.WriteLine(" --db <path> SQLite database path (default: OpenNestTraining.db)");
|
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(" --template <path> Nest template (.nstdot) for plate defaults");
|
||||||
Console.Error.WriteLine(" -h, --help Show this help");
|
Console.Error.WriteLine(" -h, --help Show this help");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Training", "OpenNe
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Tests", "OpenNest.Tests\OpenNest.Tests.csproj", "{03539EB7-9DB2-4634-A6FD-F094B9603596}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Tests", "OpenNest.Tests\OpenNest.Tests.csproj", "{03539EB7-9DB2-4634-A6FD-F094B9603596}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Api", "OpenNest.Api\OpenNest.Api.csproj", "{44D2810A-16EF-46A4-859C-B897147D8D3C}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -506,10 +506,15 @@ namespace OpenNest.Controls
|
|||||||
part.Draw(g, (i + 1).ToString());
|
part.Draw(g, (i + 1).ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw stationary preview parts (overall best — full opacity)
|
// Draw preview parts — active (current strategy) takes precedence
|
||||||
for (var i = 0; i < stationaryParts.Count; i++)
|
// 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)
|
if (part.IsDirty)
|
||||||
part.Update(this);
|
part.Update(this);
|
||||||
@@ -518,24 +523,8 @@ namespace OpenNest.Controls
|
|||||||
if (!path.GetBounds().IntersectsWith(viewBounds))
|
if (!path.GetBounds().IntersectsWith(viewBounds))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
g.FillPath(ColorScheme.PreviewPartBrush, path);
|
g.FillPath(previewBrush, path);
|
||||||
g.DrawPath(ColorScheme.PreviewPartPen, path);
|
g.DrawPath(previewPen, 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DrawOffset && Plate.PartSpacing > 0)
|
if (DrawOffset && Plate.PartSpacing > 0)
|
||||||
@@ -899,6 +888,7 @@ namespace OpenNest.Controls
|
|||||||
public void SetStationaryParts(List<Part> parts)
|
public void SetStationaryParts(List<Part> parts)
|
||||||
{
|
{
|
||||||
stationaryParts.Clear();
|
stationaryParts.Clear();
|
||||||
|
activeParts.Clear();
|
||||||
|
|
||||||
if (parts != null)
|
if (parts != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
using OpenNest.Api;
|
||||||
|
|
||||||
namespace OpenNest.Forms
|
namespace OpenNest.Forms
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using System.Drawing;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
using OpenNest.Api;
|
||||||
using Timer = System.Timers.Timer;
|
using Timer = System.Timers.Timer;
|
||||||
|
|
||||||
namespace OpenNest.Forms
|
namespace OpenNest.Forms
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
using OpenNest.Api;
|
||||||
|
|
||||||
namespace OpenNest.Forms
|
namespace OpenNest.Forms
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<Compile Remove="Controls\LayoutViewGL.cs" />
|
<Compile Remove="Controls\LayoutViewGL.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
|
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
```
|
||||||
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)
|
||||||
@@ -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