Compare commits

...

27 Commits

Author SHA1 Message Date
2cb2808c79 docs: add lead item rotation design spec for strip nesting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:15:50 -04:00
e969260f3d refactor(engine): introduce PairFillResult and remove FillRemainingStrip
PairFiller now returns PairFillResult (Parts + BestFits) instead of
using a mutable BestFits property. Extracted EvaluateCandidates,
TryReduceWorkArea, and BuildTilingAngles for clarity. Simplified the
candidate loop by leveraging FillScore comparison semantics.

Removed FillRemainingStrip and all its helpers (FindPlacedEdge,
BuildRemainingStrip, BuildRotationSet, FindBestFill, TryFewerRows,
RemainderPatterns) from FillLinear — these were a major bottleneck in
strip nesting, running expensive fills on undersized remnant strips.
ShrinkFiller + RemnantFiller already handle space optimization, making
the remainder strip fill redundant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:53:23 -04:00
8bfc13d529 fix(engine): move progress reporting from inner fills to ShrinkFiller
StripNestEngine was passing progress directly to DefaultNestEngine.Fill
inside the ShrinkFiller loop, causing every per-angle/per-strategy report
to update the UI with overlapping layouts in the same work area.

Now inner fills are silent (null progress) and ShrinkFiller reports its
own progress when the best layout improves. IterativeShrinkFiller tracks
placed parts across items and includes them in reports. The trial box is
reported before the fill starts so the work area border updates immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:43:35 -04:00
ca35945c13 fix(ui): show active or stationary preview parts, not both overlapping
Draw only one set of preview parts at a time — active (current strategy)
takes precedence over stationary (overall best). Also clears active
parts when setting new stationary parts to prevent stale previews.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:50:56 -04:00
fab2214149 perf(engine): reduce PairFiller work area when count exceeds target
When the first pair candidate places more parts than needed (e.g., 17
when target is 10), sort by BoundingBox.Top, trim from the top until
exactly targetCount remain, and use that Top as the new work area
height. All subsequent candidates fill this smaller area, dramatically
reducing fill time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:35:15 -04:00
e3b89f2660 perf(engine): add target count to ShrinkFiller with FillBestFit estimate
When a target count is known, ShrinkFiller now uses FillBestFit (fast
rectangle packing) to estimate how many parts fit on the full area,
then scales the shrink axis proportionally to avoid an expensive
full-area fill. Falls back to full box if estimate is too aggressive.

Also shrinks to targetCount (not full count) to produce tighter boxes
when fewer parts are needed than the area can hold.

IterativeShrinkFiller passes NestItem.Quantity as the target count.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:55:01 -04:00
1e9640d4fc feat(engine): include rotating calipers angle in pair nesting tiling
PairEvaluator already computes OptimalRotation via RotatingCalipers on
the pair's convex hull, but PairFiller.EvaluateCandidate only passed
hull edge angles to FillPattern. Now includes the optimal rotation
angle (and +90°) so tiling can use the mathematically tightest fit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:42:29 -04:00
116a386152 refactor(engine): delete obsolete StripNestResult and StripDirection
Both types were only used internally by the old StripNestEngine.Nest
strip-orientation logic, which has been replaced by IterativeShrinkFiller.
No references remain outside of these files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:38:12 -04:00
8957b20bac feat(engine): rewrite StripNestEngine.Nest with iterative shrink-fill
Replaces the old orientation-based strip nesting (TryOrientation,
SelectStripItemIndex, EstimateStripDimension, ShrinkFill helpers) with
a call to IterativeShrinkFiller.Fill for multi-quantity items, plus a
RemnantFinder-based PackArea pass for singles and leftovers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:37:21 -04:00
c31ef9f80c test(engine): add multi-item, leftover, unlimited qty, and cancellation tests for IterativeShrinkFiller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:34:12 -04:00
3b6e4bdd3a fix(engine): remove dead unlimitedDrawings set, fix comment accuracy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:32:46 -04:00
ef737ffa6d feat(engine): add IterativeShrinkFiller with dual-direction shrink selection
Introduces IterativeShrinkFiller.Fill, which composes RemnantFiller and
ShrinkFiller by wrapping the caller's fill function in a closure that tries
both ShrinkAxis.Height and ShrinkAxis.Width and picks the better FillScore.
Adds IterativeShrinkResult (Parts + Leftovers). Covers null/empty inputs and
single-item placement with three passing xUnit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:30:10 -04:00
1bc635acde docs: add iterative shrink-fill implementation plan
Includes fix for unlimited qty items (Quantity <= 0) that
RemnantFiller.FillItems silently skips. Workaround: convert
to estimated max capacity before passing in.

Also removes caliper angle sections from spec — RotationAnalysis
already feeds the caliper angle via FindBestRotation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:26:08 -04:00
ed555ba56a docs: clarify data flow, FillScore context, and quantity semantics in spec
Addresses spec review feedback: clarify fillFunc wrapping data flow,
specify FillScore comparison context, note Quantity <= 0 means unlimited,
annotate CaliperAngle as radians, remove RemnantFinder return claim.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:17:15 -04:00
20aa172f46 docs: add iterative shrink-fill design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:13:45 -04:00
9a58782c46 merge: resolve conflicts from remote nesting progress changes
Kept using OpenNest.Api in Timing.cs and EditNestForm.cs alongside
remote's reorganized usings and namespace changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 09:35:25 -04:00
e656956c1c fix(api): set plate Material from request, add null guards in LoadAsync
- NestRunner now assigns Material to plates from request.Material
- NestResponse.LoadAsync uses descriptive exceptions instead of null-forgiving operators
- Fix pre-existing FillExtents.Fill signature mismatch (add bestFits parameter)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 08:48:30 -04:00
f13443b6b3 feat(api): add NestRunner with multi-plate loop
Stateless orchestrator that takes a NestRequest and returns a NestResponse.
Imports DXFs, builds NestItems, runs the engine in a multi-plate loop until
all parts are placed, computes timing, and returns utilization metrics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 08:37:44 -04:00
a7688f4c9d feat(api): add NestResponse with SaveAsync/LoadAsync
Adds NestResponse type to OpenNest.Api with SaveAsync/LoadAsync for .nestquote format — a ZIP containing request.json, response.json (metrics), and an embedded nest.nest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:33:35 -04:00
e324e15fc0 feat(io): add NestWriter.Write(Stream) overload
Adds a Write(Stream) overload that writes the ZIP archive to any stream
with leaveOpen: true so the caller can read back a MemoryStream after
the ZipArchive is disposed. Refactors Write(string) to delegate to the
new overload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:30:59 -04:00
d7cc08dff7 refactor: rename .opnest file extension to .nest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:29:46 -04:00
1c8b35bcfb refactor(engine): rename NestResult to OptimizationResult
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:28:12 -04:00
84679b40ce feat(api): add NestStrategy, NestRequestPart, NestRequest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:26:51 -04:00
b6bd7eda6e refactor: move CutParameters to OpenNest.Api namespace with new properties
Relocates CutParameters from OpenNest namespace to OpenNest.Api, adds
LeadInLength and PostProcessor properties, and provides a typed Default
factory. Updates Timing.cs, the WinForms project reference, and the three
consuming forms to resolve the type from the new namespace.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:25:32 -04:00
cfe8a38620 chore: add OpenNest.Api project skeleton
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:23:07 -04:00
4be0b0db09 docs: add Nest API implementation plan
9-task plan covering: project skeleton, CutParameters migration, request/response
types, NestResult rename, .opnest→.nest rename, NestWriter Stream overload,
NestResponse persistence, NestRunner with multi-plate loop, and verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:59:23 -04:00
2f5d20f972 docs: add Nest API design spec
Design for OpenNest.Api project providing a stateless NestRequest/NestResponse
facade over the engine, IO, and timing layers. Includes CutParameters unification,
multi-plate loop, .nestquote persistence format, and .opnest → .nest rename.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:46:33 -04:00
44 changed files with 3465 additions and 580 deletions

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Api;
public class NestRequest
{
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
public Size SheetSize { get; init; } = new(60, 120);
public string Material { get; init; } = "Steel, A1011 HR";
public double Thickness { get; init; } = 0.06;
public double Spacing { get; init; } = 0.1;
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
public CutParameters Cutting { get; init; } = CutParameters.Default;
}

View File

@@ -0,0 +1,9 @@
namespace OpenNest.Api;
public class NestRequestPart
{
public string DxfPath { get; init; }
public int Quantity { get; init; } = 1;
public bool AllowRotation { get; init; } = true;
public int Priority { get; init; } = 0;
}

View File

@@ -0,0 +1,112 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text.Json;
using System.Threading.Tasks;
using OpenNest.IO;
namespace OpenNest.Api;
public class NestResponse
{
public int SheetCount { get; init; }
public double Utilization { get; init; }
public TimeSpan CutTime { get; init; }
public TimeSpan Elapsed { get; init; }
public Nest Nest { get; init; }
public NestRequest Request { get; init; }
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
IncludeFields = true // Required for OpenNest.Geometry.Size (public fields)
};
public async Task SaveAsync(string path)
{
using var fs = new FileStream(path, FileMode.Create);
using var zip = new ZipArchive(fs, ZipArchiveMode.Create);
// Write request.json
var requestEntry = zip.CreateEntry("request.json");
await using (var stream = requestEntry.Open())
{
await JsonSerializer.SerializeAsync(stream, Request, JsonOptions);
}
// Write response.json (metrics only)
var metrics = new
{
SheetCount,
Utilization,
CutTimeTicks = CutTime.Ticks,
ElapsedTicks = Elapsed.Ticks
};
var responseEntry = zip.CreateEntry("response.json");
await using (var stream = responseEntry.Open())
{
await JsonSerializer.SerializeAsync(stream, metrics, JsonOptions);
}
// Write embedded nest.nest via NestWriter → MemoryStream → ZIP entry
var nestEntry = zip.CreateEntry("nest.nest");
using var nestMs = new MemoryStream();
var writer = new NestWriter(Nest);
writer.Write(nestMs);
nestMs.Position = 0;
await using (var stream = nestEntry.Open())
{
await nestMs.CopyToAsync(stream);
}
}
public static async Task<NestResponse> LoadAsync(string path)
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
// Read request.json
var requestEntry = zip.GetEntry("request.json")
?? throw new InvalidOperationException("Missing request.json in .nestquote file");
NestRequest request;
await using (var stream = requestEntry.Open())
{
request = await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions);
}
// Read response.json
var responseEntry = zip.GetEntry("response.json")
?? throw new InvalidOperationException("Missing response.json in .nestquote file");
JsonElement metricsJson;
await using (var stream = responseEntry.Open())
{
metricsJson = await JsonSerializer.DeserializeAsync<JsonElement>(stream, JsonOptions);
}
// Read embedded nest.nest via NestReader(Stream)
var nestEntry = zip.GetEntry("nest.nest")
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
Nest nest;
using (var nestMs = new MemoryStream())
{
await using (var stream = nestEntry.Open())
{
await stream.CopyToAsync(nestMs);
}
nestMs.Position = 0;
var reader = new NestReader(nestMs);
nest = reader.Read();
}
return new NestResponse
{
SheetCount = metricsJson.GetProperty("sheetCount").GetInt32(),
Utilization = metricsJson.GetProperty("utilization").GetDouble(),
CutTime = TimeSpan.FromTicks(metricsJson.GetProperty("cutTimeTicks").GetInt64()),
Elapsed = TimeSpan.FromTicks(metricsJson.GetProperty("elapsedTicks").GetInt64()),
Nest = nest,
Request = request
};
}
}

131
OpenNest.Api/NestRunner.cs Normal file
View File

@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Api;
public static class NestRunner
{
public static Task<NestResponse> RunAsync(
NestRequest request,
IProgress<NestProgress> progress = null,
CancellationToken token = default)
{
if (request.Parts.Count == 0)
throw new ArgumentException("Request must contain at least one part.", nameof(request));
var sw = Stopwatch.StartNew();
// 1. Import DXFs → Drawings
var drawings = new List<Drawing>();
var importer = new DxfImporter();
foreach (var part in request.Parts)
{
if (!File.Exists(part.DxfPath))
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
if (!importer.GetGeometry(part.DxfPath, out var geometry) || geometry.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
var pgm = ConvertGeometry.ToProgram(geometry);
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
var drawing = new Drawing(name);
drawing.Program = pgm;
drawings.Add(drawing);
}
// 2. Build NestItems
var items = new List<NestItem>();
for (var i = 0; i < request.Parts.Count; i++)
{
var part = request.Parts[i];
items.Add(new NestItem
{
Drawing = drawings[i],
Quantity = part.Quantity,
Priority = part.Priority,
StepAngle = part.AllowRotation ? 0 : OpenNest.Math.Angle.TwoPI,
});
}
// 3. Multi-plate loop
var nest = new Nest();
var remaining = items.Select(item => item.Quantity).ToList();
while (remaining.Any(q => q > 0))
{
token.ThrowIfCancellationRequested();
var plate = new Plate(request.SheetSize)
{
Thickness = request.Thickness,
PartSpacing = request.Spacing,
Material = new Material(request.Material)
};
// Build items for this pass with remaining quantities
var passItems = new List<NestItem>();
for (var i = 0; i < items.Count; i++)
{
if (remaining[i] <= 0) continue;
passItems.Add(new NestItem
{
Drawing = items[i].Drawing,
Quantity = remaining[i],
Priority = items[i].Priority,
StepAngle = items[i].StepAngle,
});
}
// Run engine
var engine = NestEngineRegistry.Create(plate);
var parts = engine.Nest(passItems, progress, token);
if (parts.Count == 0)
break; // No progress — part doesn't fit on fresh sheet
// Add parts to plate and nest
foreach (var p in parts)
plate.Parts.Add(p);
nest.Plates.Add(plate);
// Deduct placed quantities
foreach (var p in parts)
{
var idx = drawings.IndexOf(p.BaseDrawing);
if (idx >= 0)
remaining[idx]--;
}
}
// 4. Compute timing
var timingInfo = Timing.GetTimingInfo(nest);
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
sw.Stop();
// 5. Build response
var response = new NestResponse
{
SheetCount = nest.Plates.Count,
Utilization = nest.Plates.Count > 0
? nest.Plates.Average(p => p.Utilization())
: 0,
CutTime = cutTime,
Elapsed = sw.Elapsed,
Nest = nest,
Request = request
};
return Task.FromResult(response);
}
}

View File

@@ -0,0 +1,3 @@
namespace OpenNest.Api;
public enum NestStrategy { Auto }

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.Api</RootNamespace>
<AssemblyName>OpenNest.Api</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>

View File

@@ -191,7 +191,7 @@ static class NestConsole
// DXF-only mode: create a fresh nest.
if (dxfFiles.Count == 0)
{
Console.Error.WriteLine("Error: no nest (.opnest) or DXF (.dxf) files specified");
Console.Error.WriteLine("Error: no nest (.nest) or DXF (.dxf) files specified");
return null;
}
@@ -387,12 +387,12 @@ static class NestConsole
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
Console.Error.WriteLine();
Console.Error.WriteLine("Arguments:");
Console.Error.WriteLine(" input-files One or more .opnest nest files or .dxf drawing files");
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf drawing files");
Console.Error.WriteLine();
Console.Error.WriteLine("Modes:");
Console.Error.WriteLine(" <nest.opnest> Load nest and fill (existing behavior)");
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
Console.Error.WriteLine(" <part.dxf> --size WxL Import DXF, create plate, and fill");
Console.Error.WriteLine(" <nest.opnest> <part.dxf> Load nest and add imported DXF drawings");
Console.Error.WriteLine(" <nest.nest> <part.dxf> Load nest and add imported DXF drawings");
Console.Error.WriteLine();
Console.Error.WriteLine("Options:");
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)");
@@ -400,7 +400,7 @@ static class NestConsole
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
Console.Error.WriteLine(" --spacing <value> Override part spacing");
Console.Error.WriteLine(" --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode");
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.opnest)");
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.nest)");
Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)");
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");

View File

@@ -1,15 +1,21 @@
using System;
using System;
namespace OpenNest
namespace OpenNest.Api;
public class CutParameters
{
public class CutParameters
public double Feedrate { get; set; }
public double RapidTravelRate { get; set; }
public TimeSpan PierceTime { get; set; }
public double LeadInLength { get; set; }
public string PostProcessor { get; set; }
public Units Units { get; set; }
public static CutParameters Default => new()
{
public double Feedrate { get; set; }
public double RapidTravelRate { get; set; }
public TimeSpan PierceTime { get; set; }
public Units Units { get; set; }
}
Feedrate = 100,
RapidTravelRate = 300,
PierceTime = TimeSpan.FromSeconds(0.5),
Units = OpenNest.Units.Inches
};
}

View File

@@ -1,4 +1,5 @@
using OpenNest.CNC;
using OpenNest.Api;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using System;

View File

@@ -25,7 +25,8 @@ namespace OpenNest.Engine.Fill
public List<Part> Fill(Drawing drawing, double rotationAngle = 0,
int plateNumber = 0,
CancellationToken token = default,
IProgress<NestProgress> progress = null)
IProgress<NestProgress> progress = null,
List<Engine.BestFit.BestFitResult> bestFits = null)
{
var pair = BuildPair(drawing, rotationAngle);
if (pair == null)

View File

@@ -19,11 +19,6 @@ namespace OpenNest.Engine.Fill
public double HalfSpacing => PartSpacing / 2;
/// <summary>
/// Optional multi-part patterns (e.g. interlocking pairs) to try in remainder strips.
/// </summary>
public List<Pattern> RemainderPatterns { get; set; }
private static Vector MakeOffset(NestDirection direction, double distance)
{
return direction == NestDirection.Horizontal
@@ -346,215 +341,9 @@ namespace OpenNest.Engine.Fill
var gridResult = new List<Part>(rowPattern.Parts);
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
// Step 3: Fill remaining strip
var remaining = FillRemainingStrip(gridResult, pattern, perpAxis, direction);
if (remaining.Count > 0)
gridResult.AddRange(remaining);
// Step 4: Try fewer rows optimization
var fewerResult = TryFewerRows(gridResult, rowPattern, pattern, perpAxis, direction);
if (fewerResult != null && fewerResult.Count > gridResult.Count)
return fewerResult;
return gridResult;
}
/// <summary>
/// Tries removing the last row/column from the grid and re-filling the
/// larger remainder strip. Returns null if this doesn't improve the total.
/// </summary>
private List<Part> TryFewerRows(
List<Part> fullResult, Pattern rowPattern, Pattern seedPattern,
NestDirection tiledAxis, NestDirection primaryAxis)
{
var rowPartCount = rowPattern.Parts.Count;
if (fullResult.Count < rowPartCount * 2)
return null;
var fewerParts = new List<Part>(fullResult.Count - rowPartCount);
for (var i = 0; i < fullResult.Count - rowPartCount; i++)
fewerParts.Add(fullResult[i]);
var remaining = FillRemainingStrip(fewerParts, seedPattern, tiledAxis, primaryAxis);
if (remaining.Count <= rowPartCount)
return null;
fewerParts.AddRange(remaining);
return fewerParts;
}
/// <summary>
/// After tiling full rows/columns, fills the remaining strip with individual
/// parts. The strip is the leftover space along the tiled axis between the
/// last full row/column and the work area boundary. Each unique drawing and
/// rotation from the seed pattern is tried in both directions.
/// </summary>
private List<Part> FillRemainingStrip(
List<Part> placedParts, Pattern seedPattern,
NestDirection tiledAxis, NestDirection primaryAxis)
{
var placedEdge = FindPlacedEdge(placedParts, tiledAxis);
var remainingStrip = BuildRemainingStrip(placedEdge, tiledAxis);
if (remainingStrip == null)
return new List<Part>();
var rotations = BuildRotationSet(seedPattern);
var best = FindBestFill(rotations, remainingStrip);
if (RemainderPatterns != null)
{
System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Strip: {remainingStrip.Width:F1}x{remainingStrip.Length:F1}, individual best={best?.Count ?? 0}, trying {RemainderPatterns.Count} patterns");
foreach (var pattern in RemainderPatterns)
{
var filler = new FillLinear(remainingStrip, PartSpacing);
var h = filler.Fill(pattern, NestDirection.Horizontal);
var v = filler.Fill(pattern, NestDirection.Vertical);
System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Pattern ({pattern.Parts.Count} parts, bbox={pattern.BoundingBox.Width:F1}x{pattern.BoundingBox.Length:F1}): H={h?.Count ?? 0}, V={v?.Count ?? 0}");
if (h != null && h.Count > (best?.Count ?? 0))
best = h;
if (v != null && v.Count > (best?.Count ?? 0))
best = v;
}
System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Final best={best?.Count ?? 0}");
}
return best ?? new List<Part>();
}
private static double FindPlacedEdge(List<Part> placedParts, NestDirection tiledAxis)
{
var placedEdge = double.MinValue;
foreach (var part in placedParts)
{
var edge = tiledAxis == NestDirection.Vertical
? part.BoundingBox.Top
: part.BoundingBox.Right;
if (edge > placedEdge)
placedEdge = edge;
}
return placedEdge;
}
private Box BuildRemainingStrip(double placedEdge, NestDirection tiledAxis)
{
if (tiledAxis == NestDirection.Vertical)
{
var bottom = placedEdge + PartSpacing;
var height = WorkArea.Top - bottom;
if (height <= Tolerance.Epsilon)
return null;
return new Box(WorkArea.X, bottom, WorkArea.Width, height);
}
else
{
var left = placedEdge + PartSpacing;
var width = WorkArea.Right - left;
if (width <= Tolerance.Epsilon)
return null;
return new Box(left, WorkArea.Y, width, WorkArea.Length);
}
}
/// <summary>
/// Builds a set of (drawing, rotation) candidates: cardinal orientations
/// (0° and 90°) for each unique drawing, plus any seed pattern rotations
/// not already covered.
/// </summary>
private static List<(Drawing drawing, double rotation)> BuildRotationSet(Pattern seedPattern)
{
var rotations = new List<(Drawing drawing, double rotation)>();
var drawings = new List<Drawing>();
foreach (var seedPart in seedPattern.Parts)
{
var found = false;
foreach (var d in drawings)
{
if (d == seedPart.BaseDrawing)
{
found = true;
break;
}
}
if (!found)
drawings.Add(seedPart.BaseDrawing);
}
foreach (var drawing in drawings)
{
rotations.Add((drawing, 0));
rotations.Add((drawing, Angle.HalfPI));
}
foreach (var seedPart in seedPattern.Parts)
{
var skip = false;
foreach (var (d, r) in rotations)
{
if (d == seedPart.BaseDrawing && r.IsEqualTo(seedPart.Rotation))
{
skip = true;
break;
}
}
if (!skip)
rotations.Add((seedPart.BaseDrawing, seedPart.Rotation));
}
return rotations;
}
/// <summary>
/// Tries all rotation candidates in both directions in parallel, returns the
/// fill with the most parts.
/// </summary>
private List<Part> FindBestFill(List<(Drawing drawing, double rotation)> rotations, Box strip)
{
var bag = new System.Collections.Concurrent.ConcurrentBag<List<Part>>();
Parallel.ForEach(rotations, entry =>
{
var filler = new FillLinear(strip, PartSpacing);
var h = filler.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal);
var v = filler.Fill(entry.drawing, entry.rotation, NestDirection.Vertical);
if (h != null && h.Count > 0)
bag.Add(h);
if (v != null && v.Count > 0)
bag.Add(v);
});
List<Part> best = null;
foreach (var candidate in bag)
{
if (best == null || candidate.Count > best.Count)
best = candidate;
}
return best ?? new List<Part>();
}
/// <summary>
/// Fills a single row of identical parts along one axis using geometry-aware spacing.
/// </summary>

View File

@@ -0,0 +1,322 @@
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace OpenNest.Engine.Fill
{
/// <summary>
/// Result returned by <see cref="IterativeShrinkFiller.Fill"/>.
/// </summary>
public class IterativeShrinkResult
{
public List<Part> Parts { get; set; } = new List<Part>();
public List<NestItem> Leftovers { get; set; } = new List<NestItem>();
}
/// <summary>
/// Composes <see cref="RemnantFiller"/> and <see cref="ShrinkFiller"/> with
/// dual-direction shrink selection. Wraps the caller's fill function in a
/// closure that tries both <see cref="ShrinkAxis.Height"/> and
/// <see cref="ShrinkAxis.Width"/>, picks the better <see cref="FillScore"/>,
/// and passes the wrapper to <see cref="RemnantFiller.FillItems"/>.
/// </summary>
public static class IterativeShrinkFiller
{
public static IterativeShrinkResult Fill(
List<NestItem> items,
Box workArea,
Func<NestItem, Box, List<Part>> fillFunc,
double spacing,
CancellationToken token = default,
IProgress<NestProgress> progress = null,
int plateNumber = 0)
{
if (items == null || items.Count == 0)
return new IterativeShrinkResult();
// RemnantFiller.FillItems skips items with Quantity == 0 (its localQty
// check treats them as done). Convert unlimited items (Quantity <= 0)
// to an estimated max capacity so they are actually processed.
var workItems = new List<NestItem>(items.Count);
foreach (var item in items)
{
if (item.Quantity <= 0)
{
var bbox = item.Drawing.Program.BoundingBox();
var estimatedMax = bbox.Area() > 0
? (int)(workArea.Area() / bbox.Area()) * 2
: 1000;
workItems.Add(new NestItem
{
Drawing = item.Drawing,
Quantity = System.Math.Max(1, estimatedMax),
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd
});
}
else
{
workItems.Add(item);
}
}
var filler = new RemnantFiller(workArea, spacing);
// Track parts placed by previous items so ShrinkFiller can
// include them in progress reports.
var placedSoFar = new List<Part>();
Func<NestItem, Box, List<Part>> shrinkWrapper = (ni, box) =>
{
var target = ni.Quantity > 0 ? ni.Quantity : 0;
// Run height and width shrinks in parallel — they are independent
// (same inputs, no shared mutable state).
ShrinkResult heightResult = null;
ShrinkResult widthResult = null;
Parallel.Invoke(
() => heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token,
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar),
() => widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token,
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar)
);
var heightScore = FillScore.Compute(heightResult.Parts, box);
var widthScore = FillScore.Compute(widthResult.Parts, box);
var best = widthScore > heightScore ? widthResult.Parts : heightResult.Parts;
// Sort pair groups so shorter/narrower groups are closer to the origin,
// creating a staircase profile that maximizes remnant area.
// Height shrink → columns vary in height → sort columns.
// Width shrink → rows vary in width → sort rows.
if (widthScore > heightScore)
SortRowsByWidth(best, spacing);
else
SortColumnsByHeight(best, spacing);
// Report the winner as overall best so the UI shows it as settled.
if (progress != null && best != null && best.Count > 0)
{
var allParts = new List<Part>(placedSoFar.Count + best.Count);
allParts.AddRange(placedSoFar);
allParts.AddRange(best);
NestEngineBase.ReportProgress(progress, NestPhase.Custom, plateNumber,
allParts, box, $"Shrink: {best.Count} parts placed", isOverallBest: true);
}
// Accumulate for the next item's progress reports.
placedSoFar.AddRange(best);
return best;
};
var placed = filler.FillItems(workItems, shrinkWrapper, token);
// Build leftovers: compare placed count to original quantities.
// RemnantFiller.FillItems does NOT mutate NestItem.Quantity.
var leftovers = new List<NestItem>();
foreach (var item in items)
{
var placedCount = 0;
foreach (var p in placed)
{
if (p.BaseDrawing.Name == item.Drawing.Name)
placedCount++;
}
if (item.Quantity <= 0)
continue; // unlimited items are always "satisfied" — no leftover
var remaining = item.Quantity - placedCount;
if (remaining > 0)
{
leftovers.Add(new NestItem
{
Drawing = item.Drawing,
Quantity = remaining,
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd
});
}
}
return new IterativeShrinkResult { Parts = placed, Leftovers = leftovers };
}
/// <summary>
/// Sorts pair columns by height (shortest first on the left) to create
/// a staircase profile that maximizes usable remnant area.
/// </summary>
internal static void SortColumnsByHeight(List<Part> parts, double spacing)
{
if (parts == null || parts.Count <= 1)
return;
// Sort parts by Left edge for grouping.
parts.Sort((a, b) => a.BoundingBox.Left.CompareTo(b.BoundingBox.Left));
// Group parts into columns by X overlap.
var columns = new List<List<Part>>();
var column = new List<Part> { parts[0] };
var columnRight = parts[0].BoundingBox.Right;
for (var i = 1; i < parts.Count; i++)
{
if (parts[i].BoundingBox.Left > columnRight + spacing / 2)
{
columns.Add(column);
column = new List<Part> { parts[i] };
columnRight = parts[i].BoundingBox.Right;
}
else
{
column.Add(parts[i]);
if (parts[i].BoundingBox.Right > columnRight)
columnRight = parts[i].BoundingBox.Right;
}
}
columns.Add(column);
if (columns.Count <= 1)
return;
// Measure inter-column gap from original layout.
var gap = MinLeft(columns[1]) - MaxRight(columns[0]);
// Sort columns by height ascending (shortest first).
columns.Sort((a, b) => MaxTop(a).CompareTo(MaxTop(b)));
// Reposition columns left-to-right.
var x = parts[0].BoundingBox.Left; // parts already sorted by Left
foreach (var col in columns)
{
var colLeft = MinLeft(col);
var dx = x - colLeft;
if (System.Math.Abs(dx) > OpenNest.Math.Tolerance.Epsilon)
{
var offset = new Vector(dx, 0);
foreach (var part in col)
part.Offset(offset);
}
x = MaxRight(col) + gap;
}
// Rebuild the parts list in column order.
parts.Clear();
foreach (var col in columns)
parts.AddRange(col);
}
/// <summary>
/// Sorts pair rows by width (narrowest first on the bottom) to create
/// a staircase profile on the right side that maximizes usable remnant area.
/// </summary>
internal static void SortRowsByWidth(List<Part> parts, double spacing)
{
if (parts == null || parts.Count <= 1)
return;
// Sort parts by Bottom edge for grouping.
parts.Sort((a, b) => a.BoundingBox.Bottom.CompareTo(b.BoundingBox.Bottom));
// Group parts into rows by Y overlap.
var rows = new List<List<Part>>();
var row = new List<Part> { parts[0] };
var rowTop = parts[0].BoundingBox.Top;
for (var i = 1; i < parts.Count; i++)
{
if (parts[i].BoundingBox.Bottom > rowTop + spacing / 2)
{
rows.Add(row);
row = new List<Part> { parts[i] };
rowTop = parts[i].BoundingBox.Top;
}
else
{
row.Add(parts[i]);
if (parts[i].BoundingBox.Top > rowTop)
rowTop = parts[i].BoundingBox.Top;
}
}
rows.Add(row);
if (rows.Count <= 1)
return;
// Measure inter-row gap from original layout.
var gap = MinBottom(rows[1]) - MaxTop(rows[0]);
// Sort rows by width ascending (narrowest first).
rows.Sort((a, b) => MaxRight(a).CompareTo(MaxRight(b)));
// Reposition rows bottom-to-top.
var y = parts[0].BoundingBox.Bottom; // parts already sorted by Bottom
foreach (var r in rows)
{
var rowBottom = MinBottom(r);
var dy = y - rowBottom;
if (System.Math.Abs(dy) > OpenNest.Math.Tolerance.Epsilon)
{
var offset = new Vector(0, dy);
foreach (var part in r)
part.Offset(offset);
}
y = MaxTop(r) + gap;
}
// Rebuild the parts list in row order.
parts.Clear();
foreach (var r in rows)
parts.AddRange(r);
}
private static double MaxTop(List<Part> col)
{
var max = double.MinValue;
foreach (var p in col)
if (p.BoundingBox.Top > max) max = p.BoundingBox.Top;
return max;
}
private static double MaxRight(List<Part> col)
{
var max = double.MinValue;
foreach (var p in col)
if (p.BoundingBox.Right > max) max = p.BoundingBox.Right;
return max;
}
private static double MinLeft(List<Part> col)
{
var min = double.MaxValue;
foreach (var p in col)
if (p.BoundingBox.Left < min) min = p.BoundingBox.Left;
return min;
}
private static double MinBottom(List<Part> row)
{
var min = double.MaxValue;
foreach (var p in row)
if (p.BoundingBox.Bottom < min) min = p.BoundingBox.Bottom;
return min;
}
}
}

View File

@@ -10,6 +10,12 @@ using System.Threading;
namespace OpenNest.Engine.Fill
{
public class PairFillResult
{
public List<Part> Parts { get; set; } = new List<Part>();
public List<BestFitResult> BestFits { get; set; }
}
/// <summary>
/// Fills a work area using interlocking part pairs from BestFitCache.
/// </summary>
@@ -24,33 +30,40 @@ namespace OpenNest.Engine.Fill
private readonly Size plateSize;
private readonly double partSpacing;
/// <summary>
/// The best-fit results computed during the last Fill call.
/// Available after Fill returns so callers can reuse without recomputing.
/// </summary>
public List<BestFitResult> BestFits { get; private set; }
public PairFiller(Size plateSize, double partSpacing)
{
this.plateSize = plateSize;
this.partSpacing = partSpacing;
}
public List<Part> Fill(NestItem item, Box workArea,
public PairFillResult Fill(NestItem item, Box workArea,
int plateNumber = 0,
CancellationToken token = default,
IProgress<NestProgress> progress = null)
{
BestFits = BestFitCache.GetOrCompute(
var bestFits = BestFitCache.GetOrCompute(
item.Drawing, plateSize.Length, plateSize.Width, partSpacing);
var candidates = SelectPairCandidates(BestFits, workArea);
Debug.WriteLine($"[PairFiller] Total: {BestFits.Count}, Kept: {BestFits.Count(r => r.Keep)}, Trying: {candidates.Count}");
var candidates = SelectPairCandidates(bestFits, workArea);
Debug.WriteLine($"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}");
Debug.WriteLine($"[PairFiller] Plate: {plateSize.Length:F2}x{plateSize.Width:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}");
var targetCount = item.Quantity > 0 ? item.Quantity : 0;
var parts = EvaluateCandidates(candidates, item.Drawing, workArea, targetCount,
plateNumber, token, progress);
return new PairFillResult { Parts = parts, BestFits = bestFits };
}
private List<Part> EvaluateCandidates(
List<BestFitResult> candidates, Drawing drawing,
Box workArea, int targetCount,
int plateNumber, CancellationToken token, IProgress<NestProgress> progress)
{
List<Part> best = null;
var bestScore = default(FillScore);
var sinceImproved = 0;
var effectiveWorkArea = workArea;
try
{
@@ -58,21 +71,15 @@ namespace OpenNest.Engine.Fill
{
token.ThrowIfCancellationRequested();
var filled = EvaluateCandidate(candidates[i], item.Drawing, workArea);
var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea);
var score = FillScore.Compute(filled, effectiveWorkArea);
if (filled != null && filled.Count > 0)
if (score > bestScore)
{
var score = FillScore.Compute(filled, workArea);
if (best == null || score > bestScore)
{
best = filled;
bestScore = score;
sinceImproved = 0;
}
else
{
sinceImproved++;
}
best = filled;
bestScore = score;
sinceImproved = 0;
effectiveWorkArea = TryReduceWorkArea(filled, targetCount, workArea, effectiveWorkArea);
}
else
{
@@ -98,16 +105,64 @@ namespace OpenNest.Engine.Fill
return best ?? new List<Part>();
}
private static Box TryReduceWorkArea(List<Part> parts, int targetCount, Box workArea, Box effectiveWorkArea)
{
if (targetCount <= 0 || parts.Count <= targetCount)
return effectiveWorkArea;
var reduced = ReduceWorkArea(parts, targetCount, workArea);
if (reduced.Area() >= effectiveWorkArea.Area())
return effectiveWorkArea;
Debug.WriteLine($"[PairFiller] Reduced work area to {reduced.Width:F2}x{reduced.Length:F2} (trimmed to {targetCount + 1} parts)");
return reduced;
}
/// <summary>
/// Given parts that exceed targetCount, sorts by BoundingBox.Top descending,
/// removes parts from the top until exactly targetCount remain, then returns
/// the Top of the remaining parts as the new work area height to beat.
/// </summary>
private static Box ReduceWorkArea(List<Part> parts, int targetCount, Box workArea)
{
if (parts.Count <= targetCount)
return workArea;
var sorted = parts
.OrderByDescending(p => p.BoundingBox.Top)
.ToList();
var trimCount = sorted.Count - targetCount;
var remaining = sorted.Skip(trimCount).ToList();
var newTop = remaining.Max(p => p.BoundingBox.Top);
return new Box(workArea.X, workArea.Y,
workArea.Width,
System.Math.Min(newTop - workArea.Y, workArea.Length));
}
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing, Box workArea)
{
var pairParts = candidate.BuildParts(drawing);
var engine = new FillLinear(workArea, partSpacing);
var angles = BuildTilingAngles(candidate);
return FillHelpers.FillPattern(engine, pairParts, angles, workArea);
}
var p0 = FillHelpers.BuildRotatedPattern(pairParts, 0);
var p90 = FillHelpers.BuildRotatedPattern(pairParts, Angle.HalfPI);
engine.RemainderPatterns = new List<Pattern> { p0, p90 };
private static List<double> BuildTilingAngles(BestFitResult candidate)
{
var angles = new List<double>(candidate.HullAngles);
var optAngle = -candidate.OptimalRotation;
return FillHelpers.FillPattern(engine, pairParts, candidate.HullAngles, workArea);
if (!angles.Any(a => a.IsEqualTo(optAngle)))
angles.Add(optAngle);
var optAngle90 = Angle.NormalizeRad(optAngle + Angle.HalfPI);
if (!angles.Any(a => a.IsEqualTo(optAngle90)))
angles.Add(optAngle90);
return angles;
}
private List<BestFitResult> SelectPairCandidates(List<BestFitResult> bestFits, Box workArea)
@@ -139,7 +194,41 @@ namespace OpenNest.Engine.Fill
Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
}
SortByEstimatedCount(top, workArea);
return top;
}
private void SortByEstimatedCount(List<BestFitResult> candidates, Box workArea)
{
var w = workArea.Width;
var l = workArea.Length;
candidates.Sort((a, b) =>
{
var aCount = EstimateTileCount(a, w, l);
var bCount = EstimateTileCount(b, w, l);
if (aCount != bCount)
return bCount.CompareTo(aCount);
return b.Utilization.CompareTo(a.Utilization);
});
}
private int EstimateTileCount(BestFitResult r, double areaW, double areaL)
{
var h = EstimateCount(r.BoundingWidth, r.BoundingHeight, areaW, areaL);
var v = EstimateCount(r.BoundingHeight, r.BoundingWidth, areaW, areaL);
return System.Math.Max(h, v);
}
private int EstimateCount(double pairW, double pairH, double areaW, double areaL)
{
if (pairW <= 0 || pairH <= 0) return 0;
var cols = (int)((areaW + partSpacing) / (pairW + partSpacing));
var rows = (int)((areaL + partSpacing) / (pairH + partSpacing));
return cols * rows * 2;
}
}
}

View File

@@ -37,76 +37,106 @@ namespace OpenNest.Engine.Fill
return new List<Part>();
var allParts = new List<Part>();
var madeProgress = true;
// Track quantities locally — do not mutate the input NestItem objects.
var localQty = new Dictionary<string, int>();
foreach (var item in items)
localQty[item.Drawing.Name] = item.Quantity;
var localQty = BuildLocalQuantities(items);
while (madeProgress && !token.IsCancellationRequested)
while (!token.IsCancellationRequested)
{
madeProgress = false;
var minRemnantDim = double.MaxValue;
foreach (var item in items)
{
var qty = localQty[item.Drawing.Name];
if (qty <= 0)
continue;
var bb = item.Drawing.Program.BoundingBox();
var dim = System.Math.Min(bb.Width, bb.Length);
if (dim < minRemnantDim)
minRemnantDim = dim;
}
if (minRemnantDim == double.MaxValue)
var minDim = FindMinItemDimension(items, localQty);
if (minDim == double.MaxValue)
break;
var freeBoxes = finder.FindRemnants(minRemnantDim);
var freeBoxes = finder.FindRemnants(minDim);
if (freeBoxes.Count == 0)
break;
foreach (var item in items)
{
if (token.IsCancellationRequested)
break;
var qty = localQty[item.Drawing.Name];
if (qty == 0)
continue;
var itemBbox = item.Drawing.Program.BoundingBox();
var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length);
foreach (var box in freeBoxes)
{
if (System.Math.Min(box.Width, box.Length) < minItemDim)
continue;
var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty };
var remnantParts = fillFunc(fillItem, box);
if (remnantParts != null && remnantParts.Count > 0)
{
allParts.AddRange(remnantParts);
localQty[item.Drawing.Name] = System.Math.Max(0, qty - remnantParts.Count);
foreach (var p in remnantParts)
finder.AddObstacle(p.BoundingBox.Offset(spacing));
madeProgress = true;
break;
}
}
if (madeProgress)
break;
}
if (!TryFillOneItem(items, freeBoxes, localQty, fillFunc, allParts, token))
break;
}
return allParts;
}
private static Dictionary<string, int> BuildLocalQuantities(List<NestItem> items)
{
var localQty = new Dictionary<string, int>(items.Count);
foreach (var item in items)
localQty[item.Drawing.Name] = item.Quantity;
return localQty;
}
private static double FindMinItemDimension(List<NestItem> items, Dictionary<string, int> localQty)
{
var minDim = double.MaxValue;
foreach (var item in items)
{
if (localQty[item.Drawing.Name] <= 0)
continue;
var bb = item.Drawing.Program.BoundingBox();
var dim = System.Math.Min(bb.Width, bb.Length);
if (dim < minDim)
minDim = dim;
}
return minDim;
}
private bool TryFillOneItem(
List<NestItem> items,
List<Box> freeBoxes,
Dictionary<string, int> localQty,
Func<NestItem, Box, List<Part>> fillFunc,
List<Part> allParts,
CancellationToken token)
{
foreach (var item in items)
{
if (token.IsCancellationRequested)
return false;
var qty = localQty[item.Drawing.Name];
if (qty <= 0)
continue;
var placed = TryFillInRemnants(item, qty, freeBoxes, fillFunc);
if (placed == null)
continue;
allParts.AddRange(placed);
localQty[item.Drawing.Name] = System.Math.Max(0, qty - placed.Count);
foreach (var p in placed)
finder.AddObstacle(p.BoundingBox.Offset(spacing));
return true;
}
return false;
}
private static List<Part> TryFillInRemnants(
NestItem item,
int qty,
List<Box> freeBoxes,
Func<NestItem, Box, List<Part>> fillFunc)
{
var itemBbox = item.Drawing.Program.BoundingBox();
foreach (var box in freeBoxes)
{
var fitsNormal = box.Width >= itemBbox.Width && box.Length >= itemBbox.Length;
var fitsRotated = box.Width >= itemBbox.Length && box.Length >= itemBbox.Width;
if (!fitsNormal && !fitsRotated)
continue;
var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty };
var parts = fillFunc(fillItem, box);
if (parts != null && parts.Count > 0)
return parts;
}
return null;
}
}
}

View File

@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using OpenNest.RectanglePacking;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -17,7 +18,7 @@ namespace OpenNest.Engine.Fill
/// <summary>
/// Fills a box then iteratively shrinks one axis by the spacing amount
/// until the part count drops. Returns the tightest box that still fits
/// the same number of parts.
/// the target number of parts.
/// </summary>
public static class ShrinkFiller
{
@@ -27,17 +28,43 @@ namespace OpenNest.Engine.Fill
double spacing,
ShrinkAxis axis,
CancellationToken token = default,
int maxIterations = 20)
int maxIterations = 20,
int targetCount = 0,
IProgress<NestProgress> progress = null,
int plateNumber = 0,
List<Part> placedParts = null)
{
var parts = fillFunc(item, box);
// If a target count is specified, estimate a smaller starting box
// to avoid an expensive full-area fill.
var startBox = box;
if (targetCount > 0)
startBox = EstimateStartBox(item, box, spacing, axis, targetCount);
var parts = fillFunc(item, startBox);
// If estimate was too aggressive and we got fewer than target,
// fall back to the full box.
if (targetCount > 0 && startBox != box
&& (parts == null || parts.Count < targetCount))
{
parts = fillFunc(item, box);
}
if (parts == null || parts.Count == 0)
return new ShrinkResult { Parts = parts ?? new List<Part>(), Dimension = 0 };
var targetCount = parts.Count;
// Shrink target: if a target count was given and we got at least that many,
// shrink to fit targetCount (not the full count). This produces a tighter box.
// If we got fewer than target, shrink to maintain what we have.
var shrinkTarget = targetCount > 0
? System.Math.Min(targetCount, parts.Count)
: parts.Count;
var bestParts = parts;
var bestDim = MeasureDimension(parts, box, axis);
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, box, axis, bestDim);
for (var i = 0; i < maxIterations; i++)
{
if (token.IsCancellationRequested)
@@ -51,18 +78,83 @@ namespace OpenNest.Engine.Fill
? new Box(box.X, box.Y, trialDim, box.Length)
: new Box(box.X, box.Y, box.Width, trialDim);
// Report the trial box before the fill so the UI updates the
// work area border immediately rather than after the fill completes.
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, trialBox, axis, trialDim);
var trialParts = fillFunc(item, trialBox);
if (trialParts == null || trialParts.Count < targetCount)
if (trialParts == null || trialParts.Count < shrinkTarget)
break;
bestParts = trialParts;
bestDim = MeasureDimension(trialParts, box, axis);
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, trialBox, axis, bestDim);
}
return new ShrinkResult { Parts = bestParts, Dimension = bestDim };
}
private static void ReportShrinkProgress(
IProgress<NestProgress> progress, int plateNumber,
List<Part> placedParts, List<Part> bestParts,
Box workArea, ShrinkAxis axis, double dim)
{
if (progress == null)
return;
var allParts = placedParts != null && placedParts.Count > 0
? new List<Part>(placedParts.Count + bestParts.Count)
: new List<Part>(bestParts.Count);
if (placedParts != null && placedParts.Count > 0)
allParts.AddRange(placedParts);
allParts.AddRange(bestParts);
var desc = $"Shrink {axis}: {bestParts.Count} parts, dim={dim:F1}";
NestEngineBase.ReportProgress(progress, NestPhase.Custom, plateNumber,
allParts, workArea, desc);
}
/// <summary>
/// Uses FillBestFit (fast rectangle packing) to estimate a starting box
/// that fits roughly the target count. Scales the shrink axis proportionally
/// from the full-area count down to the target, with margin.
/// </summary>
private static Box EstimateStartBox(NestItem item, Box box,
double spacing, ShrinkAxis axis, int targetCount)
{
var bbox = item.Drawing.Program.BoundingBox();
if (bbox.Width <= 0 || bbox.Length <= 0)
return box;
var maxDim = axis == ShrinkAxis.Height ? box.Length : box.Width;
// Use FillBestFit for a fast, accurate rectangle count on the full box.
var bin = new Bin { Size = new Size(box.Width, box.Length) };
var packItem = new Item { Size = new Size(bbox.Width + spacing, bbox.Length + spacing) };
var packer = new FillBestFit(bin);
packer.Fill(packItem);
var fullCount = bin.Items.Count;
if (fullCount <= 0 || fullCount <= targetCount)
return box;
// Scale dimension proportionally: target/full * maxDim, with margin.
var ratio = (double)targetCount / fullCount;
var estimate = maxDim * ratio * 1.3;
estimate = System.Math.Min(estimate, maxDim);
if (estimate <= 0 || estimate >= maxDim)
return box;
return axis == ShrinkAxis.Height
? new Box(box.X, box.Y, box.Width, estimate)
: new Box(box.X, box.Y, estimate, box.Length);
}
private static double MeasureDimension(List<Part> parts, Box box, ShrinkAxis axis)
{
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();

View File

@@ -8,7 +8,7 @@ namespace OpenNest.Engine.Nfp
/// <summary>
/// Result of a nest optimization run.
/// </summary>
public class NestResult
public class OptimizationResult
{
/// <summary>
/// The best sequence found: (drawingId, rotation, drawing) tuples in placement order.
@@ -32,7 +32,7 @@ namespace OpenNest.Engine.Nfp
/// </summary>
public interface INestOptimizer
{
NestResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
OptimizationResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
Dictionary<int, List<double>> candidateRotations,
CancellationToken cancellation = default);
}

View File

@@ -18,7 +18,7 @@ namespace OpenNest.Engine.Nfp
private const double DefaultMinTemperature = 0.1;
private const int DefaultMaxNoImprovement = 500;
public NestResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
public OptimizationResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
Dictionary<int, List<double>> candidateRotations,
CancellationToken cancellation = default)
{
@@ -29,7 +29,7 @@ namespace OpenNest.Engine.Nfp
var sequence = BuildInitialSequence(items, candidateRotations);
if (sequence.Count == 0)
return new NestResult { Sequence = sequence, Score = default, Iterations = 0 };
return new OptimizationResult { Sequence = sequence, Score = default, Iterations = 0 };
// Evaluate initial solution.
var blf = new BottomLeftFill(workArea, cache);
@@ -106,7 +106,7 @@ namespace OpenNest.Engine.Nfp
Debug.WriteLine($"[SA] Done: {iteration} iters, best={bestScore.Count} parts, density={bestScore.Density:P1}");
return new NestResult
return new OptimizationResult
{
Sequence = bestSequence,
Score = bestScore,

View File

@@ -15,9 +15,9 @@ namespace OpenNest.Engine.Strategies
var result = filler.Fill(context.Item, context.WorkArea,
context.PlateNumber, context.Token, context.Progress);
context.SharedState["BestFits"] = filler.BestFits;
context.SharedState["BestFits"] = result.BestFits;
return result;
return result.Parts;
}
}
}

View File

@@ -1,8 +0,0 @@
namespace OpenNest
{
public enum StripDirection
{
Bottom,
Left
}
}

View File

@@ -15,11 +15,10 @@ namespace OpenNest
public override string Name => "Strip";
public override string Description => "Strip-based nesting for mixed-drawing layouts";
public override string Description => "Iterative shrink-fill nesting for mixed-drawing layouts";
/// <summary>
/// Single-item fill delegates to DefaultNestEngine.
/// The strip strategy adds value for multi-drawing nesting, not single-item fills.
/// </summary>
public override List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
@@ -49,66 +48,10 @@ namespace OpenNest
}
/// <summary>
/// Selects the item that consumes the most plate area (bounding box area x quantity).
/// Returns the index into the items list.
/// </summary>
private static int SelectStripItemIndex(List<NestItem> items, Box workArea)
{
var bestIndex = 0;
var bestArea = 0.0;
for (var i = 0; i < items.Count; i++)
{
var bbox = items[i].Drawing.Program.BoundingBox();
var qty = items[i].Quantity > 0
? items[i].Quantity
: (int)(workArea.Area() / bbox.Area());
var totalArea = bbox.Area() * qty;
if (totalArea > bestArea)
{
bestArea = totalArea;
bestIndex = i;
}
}
return bestIndex;
}
/// <summary>
/// Estimates the strip dimension (height for bottom, width for left) needed
/// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter.
/// This is only an estimate for the shrink loop starting point — the actual fill
/// uses DefaultNestEngine.Fill which tries many rotation angles internally.
/// </summary>
private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension)
{
var bbox = item.Drawing.Program.BoundingBox();
var qty = item.Quantity > 0
? item.Quantity
: System.Math.Max(1, (int)(stripLength * maxDimension / bbox.Area()));
// At 0 deg: parts per row along strip length, strip dimension is bbox.Length
var perRow0 = (int)(stripLength / bbox.Width);
var rows0 = perRow0 > 0 ? (int)System.Math.Ceiling((double)qty / perRow0) : int.MaxValue;
var dim0 = rows0 * bbox.Length;
// At 90 deg: rotated bounding box (Width and Length swap)
var perRow90 = (int)(stripLength / bbox.Length);
var rows90 = perRow90 > 0 ? (int)System.Math.Ceiling((double)qty / perRow90) : int.MaxValue;
var dim90 = rows90 * bbox.Width;
var estimate = System.Math.Min(dim0, dim90);
// Clamp to available dimension
return System.Math.Min(estimate, maxDimension);
}
/// <summary>
/// Multi-drawing strip nesting strategy.
/// Picks the largest-area drawing for strip treatment, finds the tightest strip
/// in both bottom and left orientations, fills remnants with remaining drawings,
/// and returns the denser result.
/// Multi-drawing iterative shrink-fill strategy.
/// Each multi-quantity drawing gets shrink-filled into the tightest
/// sub-region using dual-direction selection. Singles and leftovers
/// are packed at the end.
/// </summary>
public override List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
@@ -118,147 +61,83 @@ namespace OpenNest
var workArea = Plate.WorkArea();
// Select which item gets the strip treatment.
var stripIndex = SelectStripItemIndex(items, workArea);
var stripItem = items[stripIndex];
var remainderItems = items.Where((_, i) => i != stripIndex).ToList();
// Separate multi-quantity from singles.
var fillItems = items
.Where(i => i.Quantity != 1)
.OrderBy(i => i.Priority)
.ThenByDescending(i => i.Drawing.Area)
.ToList();
// Try both orientations.
var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, progress, token);
var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, progress, token);
var packItems = items
.Where(i => i.Quantity == 1)
.ToList();
// Pick the better result.
var winner = bottomResult.Score >= leftResult.Score
? bottomResult.Parts
: leftResult.Parts;
var allParts = new List<Part>();
// Deduct placed quantities from the original items.
// Phase 1: Iterative shrink-fill for multi-quantity items.
if (fillItems.Count > 0)
{
// Pass progress through so the UI shows intermediate results
// during the initial BestFitCache computation and fill phases.
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(ni, b, progress, token);
};
var shrinkResult = IterativeShrinkFiller.Fill(
fillItems, workArea, fillFunc, Plate.PartSpacing, token,
progress, PlateNumber);
allParts.AddRange(shrinkResult.Parts);
// Compact placed parts toward the origin to close gaps.
if (allParts.Count > 1)
{
var noObstacles = new List<Part>();
Compactor.Push(allParts, noObstacles, workArea, Plate.PartSpacing, PushDirection.Left);
Compactor.Push(allParts, noObstacles, workArea, Plate.PartSpacing, PushDirection.Down);
}
// Add unfilled items to pack list.
packItems.AddRange(shrinkResult.Leftovers);
}
// Phase 2: Pack singles + leftovers into remaining space.
packItems = packItems.Where(i => i.Quantity > 0).ToList();
if (packItems.Count > 0 && !token.IsCancellationRequested)
{
// Reconstruct remaining area from placed parts.
var packArea = workArea;
if (allParts.Count > 0)
{
var obstacles = allParts
.Select(p => p.BoundingBox.Offset(Plate.PartSpacing))
.ToList();
var finder = new RemnantFinder(workArea, obstacles);
var remnants = finder.FindRemnants();
packArea = remnants.Count > 0 ? remnants[0] : new Box(0, 0, 0, 0);
}
if (packArea.Width > 0 && packArea.Length > 0)
{
var packParts = PackArea(packArea, packItems, progress, token);
allParts.AddRange(packParts);
}
}
// Deduct placed quantities from original items.
foreach (var item in items)
{
if (item.Quantity <= 0)
continue;
var placed = winner.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
var placed = allParts.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
return winner;
return allParts;
}
private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem,
List<NestItem> remainderItems, Box workArea, IProgress<NestProgress> progress, CancellationToken token)
{
var result = new StripNestResult { Direction = direction };
if (token.IsCancellationRequested)
return result;
// Estimate initial strip dimension.
var stripLength = direction == StripDirection.Bottom ? workArea.Width : workArea.Length;
var maxDimension = direction == StripDirection.Bottom ? workArea.Length : workArea.Width;
var estimatedDim = EstimateStripDimension(stripItem, stripLength, maxDimension);
// Create the initial strip box.
var stripBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim)
: new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length);
// Shrink to tightest strip.
var shrinkAxis = direction == StripDirection.Bottom
? ShrinkAxis.Height : ShrinkAxis.Width;
Func<NestItem, Box, List<Part>> stripFill = (ni, b) =>
{
var trialInner = new DefaultNestEngine(Plate);
return trialInner.Fill(ni, b, progress, token);
};
var shrinkResult = ShrinkFiller.Shrink(stripFill,
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
stripBox, Plate.PartSpacing, shrinkAxis, token);
if (shrinkResult.Parts == null || shrinkResult.Parts.Count == 0)
return result;
var bestParts = shrinkResult.Parts;
var bestDim = shrinkResult.Dimension;
// Build remnant box with spacing gap.
var spacing = Plate.PartSpacing;
var remnantBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y + bestDim + spacing,
workArea.Width, workArea.Length - bestDim - spacing)
: new Box(workArea.X + bestDim + spacing, workArea.Y,
workArea.Width - bestDim - spacing, workArea.Length);
// Collect all parts.
var allParts = new List<Part>(bestParts);
// If strip item was only partially placed, add leftovers to remainder.
var placed = bestParts.Count;
var leftover = stripItem.Quantity > 0 ? stripItem.Quantity - placed : 0;
var effectiveRemainder = new List<NestItem>(remainderItems);
if (leftover > 0)
{
effectiveRemainder.Add(new NestItem
{
Drawing = stripItem.Drawing,
Quantity = leftover
});
}
// Sort remainder by descending bounding box area x quantity.
effectiveRemainder = effectiveRemainder
.OrderByDescending(i =>
{
var bb = i.Drawing.Program.BoundingBox();
return bb.Area() * (i.Quantity > 0 ? i.Quantity : 1);
})
.ToList();
// Fill remnants
if (remnantBox.Width > 0 && remnantBox.Length > 0)
{
var remnantProgress = progress != null
? new AccumulatingProgress(progress, allParts)
: (IProgress<NestProgress>)null;
var remnantFiller = new RemnantFiller(workArea, spacing);
remnantFiller.AddObstacles(allParts);
Func<NestItem, Box, List<Part>> remnantFillFunc = (ni, b) =>
ShrinkFill(ni, b, remnantProgress, token);
var additional = remnantFiller.FillItems(effectiveRemainder,
remnantFillFunc, token, remnantProgress);
allParts.AddRange(additional);
}
result.Parts = allParts;
result.StripBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, bestDim)
: new Box(workArea.X, workArea.Y, bestDim, workArea.Length);
result.Score = FillScore.Compute(allParts, workArea);
return result;
}
private List<Part> ShrinkFill(NestItem item, Box box,
IProgress<NestProgress> progress, CancellationToken token)
{
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(ni, b, null, token);
};
var heightResult = ShrinkFiller.Shrink(fillFunc, item, box,
Plate.PartSpacing, ShrinkAxis.Height, token);
return heightResult.Parts;
}
}
}

View File

@@ -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; }
}
}

View File

@@ -5,8 +5,8 @@ namespace OpenNest.IO
{
public static class NestFormat
{
public const string FileExtension = ".opnest";
public const string FileFilter = "Nest Files (*.opnest)|*.opnest";
public const string FileExtension = ".nest";
public const string FileFilter = "Nest Files (*.nest)|*.nest";
public static readonly JsonSerializerOptions JsonOptions = new()
{

View File

@@ -26,12 +26,17 @@ namespace OpenNest.IO
}
public bool Write(string file)
{
using var fileStream = new FileStream(file, FileMode.Create);
return Write(fileStream);
}
public bool Write(Stream stream)
{
nest.DateLastModified = DateTime.Now;
SetDrawingIds();
using var fileStream = new FileStream(file, FileMode.Create);
using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create);
using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
WriteNestJson(zipArchive);
WritePrograms(zipArchive);

View File

@@ -20,8 +20,8 @@ namespace OpenNest.Mcp.Tools
}
[McpServerTool(Name = "load_nest")]
[Description("Load an .opnest file into the session. Returns a summary of plates, parts, and drawings.")]
public string LoadNest([Description("Absolute path to the .opnest file")] string path)
[Description("Load a .nest file into the session. Returns a summary of plates, parts, and drawings.")]
public string LoadNest([Description("Absolute path to the .nest file")] string path)
{
if (!File.Exists(path))
return $"Error: file not found: {path}";

View File

@@ -17,10 +17,10 @@ namespace OpenNest.Mcp.Tools
[McpServerTool(Name = "test_engine")]
[Description("Build and run the nesting engine against a nest file. Returns fill results and a debug log file path for grepping. Use this to test engine changes without restarting the MCP server.")]
public string TestEngine(
[Description("Path to the nest .opnest file")] string nestFile = @"C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.opnest",
[Description("Path to the nest .nest file")] string nestFile = @"C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.nest",
[Description("Drawing name to fill with (default: first drawing)")] string drawingName = null,
[Description("Plate index to fill (default: 0)")] int plateIndex = 0,
[Description("Output nest file path (default: <input>-result.opnest)")] string outputFile = null)
[Description("Output nest file path (default: <input>-result.nest)")] string outputFile = null)
{
if (!File.Exists(nestFile))
return $"Error: nest file not found: {nestFile}";

View File

@@ -0,0 +1,35 @@
using OpenNest.Api;
namespace OpenNest.Tests.Api;
public class CutParametersTests
{
[Fact]
public void Default_HasExpectedValues()
{
var cp = CutParameters.Default;
Assert.Equal(100, cp.Feedrate);
Assert.Equal(300, cp.RapidTravelRate);
Assert.Equal(TimeSpan.FromSeconds(0.5), cp.PierceTime);
Assert.Equal(Units.Inches, cp.Units);
}
[Fact]
public void Properties_AreSettable()
{
var cp = new CutParameters
{
Feedrate = 200,
RapidTravelRate = 500,
PierceTime = TimeSpan.FromSeconds(1.0),
LeadInLength = 0.25,
PostProcessor = "CL-707",
Units = Units.Millimeters
};
Assert.Equal(200, cp.Feedrate);
Assert.Equal(0.25, cp.LeadInLength);
Assert.Equal("CL-707", cp.PostProcessor);
}
}

View File

@@ -0,0 +1,45 @@
using OpenNest.Api;
using OpenNest.Geometry;
namespace OpenNest.Tests.Api;
public class NestRequestTests
{
[Fact]
public void Default_Request_HasSensibleDefaults()
{
var request = new NestRequest();
Assert.Empty(request.Parts);
Assert.Equal(60, request.SheetSize.Width);
Assert.Equal(120, request.SheetSize.Length);
Assert.Equal("Steel, A1011 HR", request.Material);
Assert.Equal(0.06, request.Thickness);
Assert.Equal(0.1, request.Spacing);
Assert.Equal(NestStrategy.Auto, request.Strategy);
Assert.NotNull(request.Cutting);
}
[Fact]
public void Parts_Accessible_AfterConstruction()
{
var request = new NestRequest
{
Parts = [new NestRequestPart { DxfPath = "test.dxf", Quantity = 5 }]
};
Assert.Single(request.Parts);
Assert.Equal("test.dxf", request.Parts[0].DxfPath);
Assert.Equal(5, request.Parts[0].Quantity);
}
[Fact]
public void NestRequestPart_Defaults()
{
var part = new NestRequestPart { DxfPath = "part.dxf" };
Assert.Equal(1, part.Quantity);
Assert.True(part.AllowRotation);
Assert.Equal(0, part.Priority);
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.IO;
using System.Threading.Tasks;
using OpenNest.Api;
using OpenNest.Geometry;
namespace OpenNest.Tests.Api;
public class NestResponsePersistenceTests
{
[Fact]
public async Task SaveAsync_LoadAsync_RoundTrips()
{
var nest = new Nest("test-nest");
var plate = new Plate(new Size(60, 120));
nest.Plates.Add(plate);
var request = new NestRequest
{
Parts = [new NestRequestPart { DxfPath = "test.dxf", Quantity = 5 }],
SheetSize = new Size(60, 120),
Material = "Steel",
Thickness = 0.125,
Spacing = 0.1
};
var original = new NestResponse
{
SheetCount = 1,
Utilization = 0.75,
CutTime = TimeSpan.FromMinutes(12.5),
Elapsed = TimeSpan.FromSeconds(3.2),
Nest = nest,
Request = request
};
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.nestquote");
try
{
await original.SaveAsync(path);
var loaded = await NestResponse.LoadAsync(path);
Assert.Equal(original.SheetCount, loaded.SheetCount);
Assert.Equal(original.Utilization, loaded.Utilization, precision: 4);
Assert.Equal(original.CutTime, loaded.CutTime);
Assert.Equal(original.Elapsed, loaded.Elapsed);
Assert.Equal(original.Request.Material, loaded.Request.Material);
Assert.Equal(original.Request.Thickness, loaded.Request.Thickness);
Assert.Equal(original.Request.Parts.Count, loaded.Request.Parts.Count);
Assert.Equal(original.Request.Parts[0].DxfPath, loaded.Request.Parts[0].DxfPath);
Assert.Equal(original.Request.Parts[0].Quantity, loaded.Request.Parts[0].Quantity);
Assert.NotNull(loaded.Nest);
Assert.Single(loaded.Nest.Plates);
}
finally
{
File.Delete(path);
}
}
}

View File

@@ -0,0 +1,78 @@
using System;
using System.IO;
using System.Threading.Tasks;
using OpenNest.Api;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.Api;
public class NestRunnerTests
{
[Fact]
public async Task RunAsync_SinglePart_ProducesResponse()
{
var dxfPath = CreateTempSquareDxf(2, 2);
try
{
var request = new NestRequest
{
Parts = [new NestRequestPart { DxfPath = dxfPath, Quantity = 4 }],
SheetSize = new Size(10, 10),
Spacing = 0.1
};
var response = await NestRunner.RunAsync(request);
Assert.NotNull(response);
Assert.NotNull(response.Nest);
Assert.True(response.SheetCount >= 1);
Assert.True(response.Utilization > 0);
Assert.Equal(request, response.Request);
}
finally
{
File.Delete(dxfPath);
}
}
[Fact]
public async Task RunAsync_BadDxfPath_Throws()
{
var request = new NestRequest
{
Parts = [new NestRequestPart { DxfPath = "nonexistent.dxf", Quantity = 1 }]
};
await Assert.ThrowsAsync<FileNotFoundException>(
() => NestRunner.RunAsync(request));
}
[Fact]
public async Task RunAsync_EmptyParts_Throws()
{
var request = new NestRequest { Parts = [] };
await Assert.ThrowsAsync<ArgumentException>(
() => NestRunner.RunAsync(request));
}
private static string CreateTempSquareDxf(double width, double height)
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(width, 0)));
shape.Entities.Add(new Line(new Vector(width, 0), new Vector(width, height)));
shape.Entities.Add(new Line(new Vector(width, height), new Vector(0, height)));
shape.Entities.Add(new Line(new Vector(0, height), new Vector(0, 0)));
var pgm = ConvertGeometry.ToProgram(shape);
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.dxf");
var exporter = new DxfExporter();
exporter.ExportProgram(pgm, path);
return path;
}
}

View File

@@ -0,0 +1,147 @@
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class IterativeShrinkFillerTests
{
[Fact]
public void Fill_NullItems_ReturnsEmpty()
{
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();
var result = IterativeShrinkFiller.Fill(null, new Box(0, 0, 100, 100), fillFunc, 1.0);
Assert.Empty(result.Parts);
Assert.Empty(result.Leftovers);
}
[Fact]
public void Fill_EmptyItems_ReturnsEmpty()
{
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();
var result = IterativeShrinkFiller.Fill(new List<NestItem>(), new Box(0, 0, 100, 100), fillFunc, 1.0);
Assert.Empty(result.Parts);
Assert.Empty(result.Leftovers);
}
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
return new Drawing(name, pgm);
}
[Fact]
public void Fill_SingleItem_PlacesParts()
{
var drawing = MakeRectDrawing(20, 10);
var items = new List<NestItem>
{
new NestItem { Drawing = drawing, Quantity = 5 }
};
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var plate = new Plate(b.Width, b.Length);
var engine = new DefaultNestEngine(plate);
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
};
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
Assert.True(result.Parts.Count > 0, "Should place parts");
}
[Fact]
public void Fill_MultipleItems_PlacesFromBoth()
{
var items = new List<NestItem>
{
new NestItem { Drawing = MakeRectDrawing(20, 10, "large"), Quantity = 5 },
new NestItem { Drawing = MakeRectDrawing(8, 5, "small"), Quantity = 5 },
};
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var plate = new Plate(b.Width, b.Length);
var engine = new DefaultNestEngine(plate);
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
};
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
var largeCount = result.Parts.Count(p => p.BaseDrawing.Name == "large");
var smallCount = result.Parts.Count(p => p.BaseDrawing.Name == "small");
Assert.True(largeCount > 0, "Should place large parts");
Assert.True(smallCount > 0, "Should place small parts in remaining space");
}
[Fact]
public void Fill_UnfilledQuantity_ReturnsLeftovers()
{
// Huge quantity that can't all fit on a small plate
var items = new List<NestItem>
{
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 1000 },
};
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var plate = new Plate(b.Width, b.Length);
var engine = new DefaultNestEngine(plate);
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
};
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 60, 30), fillFunc, 1.0);
Assert.True(result.Parts.Count > 0, "Should place some parts");
Assert.True(result.Leftovers.Count > 0, "Should have leftovers");
Assert.True(result.Leftovers[0].Quantity > 0, "Leftover quantity should be positive");
}
[Fact]
public void Fill_UnlimitedQuantity_PlacesParts()
{
var items = new List<NestItem>
{
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 0 }
};
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var plate = new Plate(b.Width, b.Length);
var engine = new DefaultNestEngine(plate);
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
};
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
Assert.True(result.Parts.Count > 0, "Unlimited qty items should still be placed");
Assert.Empty(result.Leftovers); // unlimited items never produce leftovers
}
[Fact]
public void Fill_RespectsCancellation()
{
var cts = new System.Threading.CancellationTokenSource();
cts.Cancel();
var items = new List<NestItem>
{
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 10 }
};
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 100, 100), fillFunc, 1.0, cts.Token);
Assert.NotNull(result);
}
}

View File

@@ -21,6 +21,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />

View File

@@ -24,11 +24,10 @@ public class PairFillerTests
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var workArea = new Box(0, 0, 120, 60);
var parts = filler.Fill(item, workArea);
var result = filler.Fill(item, workArea);
Assert.NotNull(parts);
// Pair filling may or may not find interlocking pairs for rectangles,
// but should return a non-null list.
Assert.NotNull(result.Parts);
Assert.NotNull(result.BestFits);
}
[Fact]
@@ -39,10 +38,10 @@ public class PairFillerTests
var item = new NestItem { Drawing = MakeRectDrawing(20, 20) };
var workArea = new Box(0, 0, 10, 10);
var parts = filler.Fill(item, workArea);
var result = filler.Fill(item, workArea);
Assert.NotNull(parts);
Assert.Empty(parts);
Assert.NotNull(result.Parts);
Assert.Empty(result.Parts);
}
[Fact]
@@ -56,9 +55,8 @@ public class PairFillerTests
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var workArea = new Box(0, 0, 120, 60);
var parts = filler.Fill(item, workArea, token: cts.Token);
var result = filler.Fill(item, workArea, token: cts.Token);
// Should return empty or partial — not throw
Assert.NotNull(parts);
Assert.NotNull(result.Parts);
}
}

View File

@@ -299,7 +299,7 @@ void PrintUsage()
Console.Error.WriteLine("Options:");
Console.Error.WriteLine(" --spacing <value> Part spacing (default: 0.5)");
Console.Error.WriteLine(" --db <path> SQLite database path (default: OpenNestTraining.db)");
Console.Error.WriteLine(" --save-nests <dir> Directory to save individual .opnest nests for each winner");
Console.Error.WriteLine(" --save-nests <dir> Directory to save individual .nest nests for each winner");
Console.Error.WriteLine(" --template <path> Nest template (.nstdot) for plate defaults");
Console.Error.WriteLine(" -h, --help Show this help");
}

View File

@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Training", "OpenNe
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Tests", "OpenNest.Tests\OpenNest.Tests.csproj", "{03539EB7-9DB2-4634-A6FD-F094B9603596}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Api", "OpenNest.Api\OpenNest.Api.csproj", "{44D2810A-16EF-46A4-859C-B897147D8D3C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -139,6 +141,18 @@ Global
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x64.Build.0 = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.ActiveCfg = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.Build.0 = Release|Any CPU
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Debug|x64.ActiveCfg = Debug|Any CPU
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Debug|x64.Build.0 = Debug|Any CPU
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Debug|x86.ActiveCfg = Debug|Any CPU
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Debug|x86.Build.0 = Debug|Any CPU
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|Any CPU.Build.0 = Release|Any CPU
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|x64.ActiveCfg = Release|Any CPU
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|x64.Build.0 = Release|Any CPU
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|x86.ActiveCfg = Release|Any CPU
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -506,10 +506,15 @@ namespace OpenNest.Controls
part.Draw(g, (i + 1).ToString());
}
// Draw stationary preview parts (overall best — full opacity)
for (var i = 0; i < stationaryParts.Count; i++)
// Draw preview parts — active (current strategy) takes precedence
// over stationary (overall best) to avoid overlapping fills.
var previewParts = activeParts.Count > 0 ? activeParts : stationaryParts;
var previewBrush = activeParts.Count > 0 ? ColorScheme.ActivePreviewPartBrush : ColorScheme.PreviewPartBrush;
var previewPen = activeParts.Count > 0 ? ColorScheme.ActivePreviewPartPen : ColorScheme.PreviewPartPen;
for (var i = 0; i < previewParts.Count; i++)
{
var part = stationaryParts[i];
var part = previewParts[i];
if (part.IsDirty)
part.Update(this);
@@ -518,24 +523,8 @@ namespace OpenNest.Controls
if (!path.GetBounds().IntersectsWith(viewBounds))
continue;
g.FillPath(ColorScheme.PreviewPartBrush, path);
g.DrawPath(ColorScheme.PreviewPartPen, path);
}
// Draw active preview parts (current strategy — reduced opacity)
for (var i = 0; i < activeParts.Count; i++)
{
var part = activeParts[i];
if (part.IsDirty)
part.Update(this);
var path = part.Path;
if (!path.GetBounds().IntersectsWith(viewBounds))
continue;
g.FillPath(ColorScheme.ActivePreviewPartBrush, path);
g.DrawPath(ColorScheme.ActivePreviewPartPen, path);
g.FillPath(previewBrush, path);
g.DrawPath(previewPen, path);
}
if (DrawOffset && Plate.PartSpacing > 0)
@@ -899,6 +888,7 @@ namespace OpenNest.Controls
public void SetStationaryParts(List<Part> parts)
{
stationaryParts.Clear();
activeParts.Clear();
if (parts != null)
{

View File

@@ -1,5 +1,6 @@
using System;
using System.Windows.Forms;
using OpenNest.Api;
namespace OpenNest.Forms
{

View File

@@ -13,6 +13,7 @@ using System.Drawing;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Api;
using Timer = System.Timers.Timer;
namespace OpenNest.Forms

View File

@@ -1,5 +1,6 @@
using System;
using System.Windows.Forms;
using OpenNest.Api;
namespace OpenNest.Forms
{

View File

@@ -11,6 +11,7 @@
<Compile Remove="Controls\LayoutViewGL.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />

View File

@@ -0,0 +1,620 @@
# Iterative Shrink-Fill Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace `StripNestEngine`'s single-strip approach with iterative shrink-fill — every multi-quantity drawing gets shrink-fitted into its tightest sub-region using dual-direction selection, with leftovers packed at the end.
**Architecture:** New `IterativeShrinkFiller` static class composes existing `RemnantFiller` + `ShrinkFiller` with a dual-direction wrapper closure. `StripNestEngine.Nest` becomes a thin orchestrator calling the new filler then packing leftovers. No changes to `NestEngineBase`, `DefaultNestEngine`, or UI.
**Tech Stack:** .NET 8, xUnit, OpenNest.Engine
**Spec:** `docs/superpowers/specs/2026-03-19-iterative-shrink-fill-design.md`
---
### File Structure
| File | Responsibility |
|------|---------------|
| `OpenNest.Engine/Fill/IterativeShrinkFiller.cs` | **New.** Static class + result type. Wraps a raw fill function with dual-direction `ShrinkFiller.Shrink`, passes the wrapper to `RemnantFiller.FillItems`. Returns placed parts + leftover items. |
| `OpenNest.Engine/StripNestEngine.cs` | **Modify.** Rewrite `Nest` to separate items, call `IterativeShrinkFiller.Fill`, pack leftovers. Delete `SelectStripItemIndex`, `EstimateStripDimension`, `TryOrientation`, `ShrinkFill`. |
| `OpenNest.Engine/StripNestResult.cs` | **Delete.** No longer needed. |
| `OpenNest.Engine/StripDirection.cs` | **Delete.** No longer needed. |
| `OpenNest.Tests/IterativeShrinkFillerTests.cs` | **New.** Unit tests for the new filler. |
| `OpenNest.Tests/EngineRefactorSmokeTests.cs` | **Verify.** Existing `StripEngine_Nest_ProducesResults` must still pass. |
---
### Task 1: IterativeShrinkFiller — empty/null input
**Files:**
- Create: `OpenNest.Tests/IterativeShrinkFillerTests.cs`
- Create: `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`
- [ ] **Step 1: Write failing tests for empty/null input**
```csharp
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class IterativeShrinkFillerTests
{
[Fact]
public void Fill_NullItems_ReturnsEmpty()
{
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();
var result = IterativeShrinkFiller.Fill(null, new Box(0, 0, 100, 100), fillFunc, 1.0);
Assert.Empty(result.Parts);
Assert.Empty(result.Leftovers);
}
[Fact]
public void Fill_EmptyItems_ReturnsEmpty()
{
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();
var result = IterativeShrinkFiller.Fill(new List<NestItem>(), new Box(0, 0, 100, 100), fillFunc, 1.0);
Assert.Empty(result.Parts);
Assert.Empty(result.Leftovers);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests" --no-build 2>&1 || dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
Expected: Build error — `IterativeShrinkFiller` does not exist yet.
- [ ] **Step 3: Write minimal implementation**
Create `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`:
```csharp
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest.Engine.Fill
{
public class IterativeShrinkResult
{
public List<Part> Parts { get; set; } = new();
public List<NestItem> Leftovers { get; set; } = new();
}
public static class IterativeShrinkFiller
{
public static IterativeShrinkResult Fill(
List<NestItem> items,
Box workArea,
Func<NestItem, Box, List<Part>> fillFunc,
double spacing,
CancellationToken token = default)
{
if (items == null || items.Count == 0)
return new IterativeShrinkResult();
// TODO: dual-direction shrink logic
return new IterativeShrinkResult();
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
Expected: 2 tests pass.
- [ ] **Step 5: Commit**
```bash
git add OpenNest.Tests/IterativeShrinkFillerTests.cs OpenNest.Engine/Fill/IterativeShrinkFiller.cs
git commit -m "feat(engine): add IterativeShrinkFiller skeleton with empty/null tests"
```
---
### Task 2: IterativeShrinkFiller — dual-direction shrink core logic
**Files:**
- Modify: `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`
- Modify: `OpenNest.Tests/IterativeShrinkFillerTests.cs`
**Context:** The core algorithm wraps the caller's `fillFunc` in a closure that calls `ShrinkFiller.Shrink` in both axis directions and picks the better `FillScore`, then passes this wrapper to `RemnantFiller.FillItems`.
- [ ] **Step 1: Write failing test — single item gets shrink-filled**
Add to `IterativeShrinkFillerTests.cs`:
```csharp
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
return new Drawing(name, pgm);
}
[Fact]
public void Fill_SingleItem_PlacesParts()
{
var drawing = MakeRectDrawing(20, 10);
var items = new List<NestItem>
{
new NestItem { Drawing = drawing, Quantity = 5 }
};
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var plate = new Plate(b.Width, b.Length);
var engine = new DefaultNestEngine(plate);
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
};
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
Assert.True(result.Parts.Count > 0, "Should place parts");
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests.Fill_SingleItem_PlacesParts"`
Expected: FAIL — returns 0 parts (skeleton returns empty).
- [ ] **Step 3: Implement dual-direction shrink logic**
Replace the TODO in `IterativeShrinkFiller.Fill`:
```csharp
public static IterativeShrinkResult Fill(
List<NestItem> items,
Box workArea,
Func<NestItem, Box, List<Part>> fillFunc,
double spacing,
CancellationToken token = default)
{
if (items == null || items.Count == 0)
return new IterativeShrinkResult();
// RemnantFiller.FillItems skips items with Quantity <= 0 (its localQty
// check treats them as "done"). Convert unlimited items to an estimated
// max capacity so they are actually processed.
var workItems = new List<NestItem>(items.Count);
var unlimitedDrawings = new HashSet<string>();
foreach (var item in items)
{
if (item.Quantity <= 0)
{
var bbox = item.Drawing.Program.BoundingBox();
var estimatedMax = bbox.Area() > 0
? (int)(workArea.Area() / bbox.Area()) * 2
: 1000;
unlimitedDrawings.Add(item.Drawing.Name);
workItems.Add(new NestItem
{
Drawing = item.Drawing,
Quantity = System.Math.Max(1, estimatedMax),
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd
});
}
else
{
workItems.Add(item);
}
}
var filler = new RemnantFiller(workArea, spacing);
Func<NestItem, Box, List<Part>> shrinkWrapper = (ni, box) =>
{
var heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token);
var widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token);
var heightScore = FillScore.Compute(heightResult.Parts, box);
var widthScore = FillScore.Compute(widthResult.Parts, box);
return widthScore > heightScore ? widthResult.Parts : heightResult.Parts;
};
var placed = filler.FillItems(workItems, shrinkWrapper, token);
// Build leftovers: compare placed count to original quantities.
// RemnantFiller.FillItems does NOT mutate NestItem.Quantity.
var leftovers = new List<NestItem>();
foreach (var item in items)
{
var placedCount = placed.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
if (item.Quantity <= 0)
continue; // unlimited items are always "satisfied" — no leftover
var remaining = item.Quantity - placedCount;
if (remaining > 0)
{
leftovers.Add(new NestItem
{
Drawing = item.Drawing,
Quantity = remaining,
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd
});
}
}
return new IterativeShrinkResult { Parts = placed, Leftovers = leftovers };
}
```
**Key points:**
- `RemnantFiller.FillItems` skips items with `Quantity <= 0` (its `localQty` check treats them as done). To work around this without modifying `RemnantFiller`, unlimited items are converted to an estimated max capacity (`workArea / bboxArea * 2`) before being passed in.
- `RemnantFiller.FillItems` does NOT mutate `NestItem.Quantity` — it tracks quantities internally via `localQty` dictionary (verified in `RemnantFillerTests2.FillItems_DoesNotMutateItemQuantities`).
- The leftover calculation iterates the *original* items list (not `workItems`), so unlimited items are correctly skipped.
- `FillScore` comparison: `widthScore > heightScore` uses the operator overload which is lexicographic (count first, then density).
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
Expected: 3 tests pass.
- [ ] **Step 5: Commit**
```bash
git add OpenNest.Engine/Fill/IterativeShrinkFiller.cs OpenNest.Tests/IterativeShrinkFillerTests.cs
git commit -m "feat(engine): implement dual-direction shrink logic in IterativeShrinkFiller"
```
---
### Task 3: IterativeShrinkFiller — multiple items and leftovers
**Files:**
- Modify: `OpenNest.Tests/IterativeShrinkFillerTests.cs`
- [ ] **Step 1: Write failing tests for multi-item and leftover scenarios**
Add to `IterativeShrinkFillerTests.cs`:
```csharp
[Fact]
public void Fill_MultipleItems_PlacesFromBoth()
{
var items = new List<NestItem>
{
new NestItem { Drawing = MakeRectDrawing(20, 10, "large"), Quantity = 5 },
new NestItem { Drawing = MakeRectDrawing(8, 5, "small"), Quantity = 5 },
};
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var plate = new Plate(b.Width, b.Length);
var engine = new DefaultNestEngine(plate);
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
};
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
var largeCount = result.Parts.Count(p => p.BaseDrawing.Name == "large");
var smallCount = result.Parts.Count(p => p.BaseDrawing.Name == "small");
Assert.True(largeCount > 0, "Should place large parts");
Assert.True(smallCount > 0, "Should place small parts in remaining space");
}
[Fact]
public void Fill_UnfilledQuantity_ReturnsLeftovers()
{
// Huge quantity that can't all fit on a small plate
var items = new List<NestItem>
{
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 1000 },
};
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var plate = new Plate(b.Width, b.Length);
var engine = new DefaultNestEngine(plate);
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
};
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 60, 30), fillFunc, 1.0);
Assert.True(result.Parts.Count > 0, "Should place some parts");
Assert.True(result.Leftovers.Count > 0, "Should have leftovers");
Assert.True(result.Leftovers[0].Quantity > 0, "Leftover quantity should be positive");
}
[Fact]
public void Fill_UnlimitedQuantity_PlacesParts()
{
var items = new List<NestItem>
{
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 0 }
};
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var plate = new Plate(b.Width, b.Length);
var engine = new DefaultNestEngine(plate);
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
};
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
Assert.True(result.Parts.Count > 0, "Unlimited qty items should still be placed");
Assert.Empty(result.Leftovers); // unlimited items never produce leftovers
}
[Fact]
public void Fill_RespectsCancellation()
{
var cts = new System.Threading.CancellationTokenSource();
cts.Cancel();
var items = new List<NestItem>
{
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 10 }
};
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 100, 100), fillFunc, 1.0, cts.Token);
Assert.NotNull(result);
}
```
- [ ] **Step 2: Run tests**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
Expected: All 7 tests pass (these test existing behavior, no new code needed — they verify the implementation from Task 2 handles these cases).
If any fail, fix the implementation in `IterativeShrinkFiller.Fill` and re-run.
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Tests/IterativeShrinkFillerTests.cs
git commit -m "test(engine): add multi-item, leftover, unlimited qty, and cancellation tests for IterativeShrinkFiller"
```
---
### Task 4: Rewrite StripNestEngine.Nest
**Files:**
- Modify: `OpenNest.Engine/StripNestEngine.cs`
**Context:** Replace the current `Nest` implementation that does single-strip + remnant fill with the new iterative approach. Keep `Fill`, `Fill(groupParts)`, and `PackArea` overrides unchanged — they still delegate to `DefaultNestEngine`.
- [ ] **Step 1: Run existing smoke test to establish baseline**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~StripEngine_Nest_ProducesResults"`
Expected: PASS (current implementation works).
- [ ] **Step 2: Rewrite StripNestEngine.Nest**
Replace the `Nest` override and delete `SelectStripItemIndex`, `EstimateStripDimension`, `TryOrientation`, and `ShrinkFill` methods. The full file should become:
```csharp
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace OpenNest
{
public class StripNestEngine : NestEngineBase
{
public StripNestEngine(Plate plate) : base(plate)
{
}
public override string Name => "Strip";
public override string Description => "Iterative shrink-fill nesting for mixed-drawing layouts";
/// <summary>
/// Single-item fill delegates to DefaultNestEngine.
/// </summary>
public override List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(item, workArea, progress, token);
}
/// <summary>
/// Group-parts fill delegates to DefaultNestEngine.
/// </summary>
public override List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(groupParts, workArea, progress, token);
}
/// <summary>
/// Pack delegates to DefaultNestEngine.
/// </summary>
public override List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.PackArea(box, items, progress, token);
}
/// <summary>
/// Multi-drawing iterative shrink-fill strategy.
/// Each multi-quantity drawing gets shrink-filled into the tightest
/// sub-region using dual-direction selection. Singles and leftovers
/// are packed at the end.
/// </summary>
public override List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
if (items == null || items.Count == 0)
return new List<Part>();
var workArea = Plate.WorkArea();
// Separate multi-quantity from singles.
var fillItems = items
.Where(i => i.Quantity != 1)
.OrderBy(i => i.Priority)
.ThenByDescending(i => i.Drawing.Area)
.ToList();
var packItems = items
.Where(i => i.Quantity == 1)
.ToList();
var allParts = new List<Part>();
// Phase 1: Iterative shrink-fill for multi-quantity items.
if (fillItems.Count > 0)
{
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(ni, b, progress, token);
};
var shrinkResult = IterativeShrinkFiller.Fill(
fillItems, workArea, fillFunc, Plate.PartSpacing, token);
allParts.AddRange(shrinkResult.Parts);
// Add unfilled items to pack list.
packItems.AddRange(shrinkResult.Leftovers);
}
// Phase 2: Pack singles + leftovers into remaining space.
packItems = packItems.Where(i => i.Quantity > 0).ToList();
if (packItems.Count > 0 && !token.IsCancellationRequested)
{
// Reconstruct remaining area from placed parts.
var packArea = workArea;
if (allParts.Count > 0)
{
var obstacles = allParts
.Select(p => p.BoundingBox.Offset(Plate.PartSpacing))
.ToList();
var finder = new RemnantFinder(workArea, obstacles);
var remnants = finder.FindRemnants();
packArea = remnants.Count > 0 ? remnants[0] : new Box(0, 0, 0, 0);
}
if (packArea.Width > 0 && packArea.Length > 0)
{
var packParts = PackArea(packArea, packItems, progress, token);
allParts.AddRange(packParts);
}
}
// Deduct placed quantities from original items.
foreach (var item in items)
{
if (item.Quantity <= 0)
continue;
var placed = allParts.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
return allParts;
}
}
}
```
- [ ] **Step 3: Run smoke test**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~StripEngine_Nest_ProducesResults"`
Expected: PASS.
- [ ] **Step 4: Run all engine tests to check for regressions**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~EngineRefactorSmokeTests|FullyQualifiedName~IterativeShrinkFillerTests|FullyQualifiedName~ShrinkFillerTests|FullyQualifiedName~RemnantFillerTests"`
Expected: All pass.
- [ ] **Step 5: Commit**
```bash
git add OpenNest.Engine/StripNestEngine.cs
git commit -m "feat(engine): rewrite StripNestEngine.Nest with iterative shrink-fill"
```
---
### Task 5: Delete obsolete files
**Files:**
- Delete: `OpenNest.Engine/StripNestResult.cs`
- Delete: `OpenNest.Engine/StripDirection.cs`
- [ ] **Step 1: Verify no remaining references**
Run: `grep -r "StripNestResult\|StripDirection" --include="*.cs" . | grep -v "\.md"`
Expected: No matches (all references were in the old `StripNestEngine.TryOrientation` which was deleted in Task 4).
- [ ] **Step 2: Delete the files**
```bash
rm OpenNest.Engine/StripNestResult.cs OpenNest.Engine/StripDirection.cs
```
- [ ] **Step 3: Build to verify no breakage**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds with no errors.
- [ ] **Step 4: Run full test suite**
Run: `dotnet test OpenNest.Tests`
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
git add -u OpenNest.Engine/StripNestResult.cs OpenNest.Engine/StripDirection.cs
git commit -m "refactor(engine): delete obsolete StripNestResult and StripDirection"
```
---
### Task 6: Update spec and docs
**Files:**
- Modify: `docs/superpowers/specs/2026-03-19-iterative-shrink-fill-design.md`
- [ ] **Step 1: Update spec with "rotating calipers already included" note**
The spec was already updated during planning. Verify it reflects the final state — no `AngleCandidateBuilder` or `NestItem.CaliperAngle` changes listed.
- [ ] **Step 2: Commit if any changes**
```bash
git add docs/
git commit -m "docs: finalize iterative shrink-fill spec"
```

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View File

@@ -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)

View File

@@ -0,0 +1,238 @@
# Nest API Design
## Overview
A new `OpenNest.Api` project providing a clean programmatic facade for nesting operations. A single `NestRequest` goes in, a self-contained `NestResponse` comes out. Designed for external callers (MCP server, console app, LaserQuote, future web API) that don't want to manually wire engine + timing + IO.
## Motivation
Today, running a nest programmatically requires manually coordinating:
1. DXF import (IO layer)
2. Plate/NestItem setup (Core)
3. Engine selection and execution (Engine layer)
4. Timing calculation (Core's `Timing` class)
5. File persistence (IO layer)
This design wraps all five steps behind a single stateless call. The response captures the original request, making nests reproducible and re-priceable months later.
## New Project: `OpenNest.Api`
Class library targeting `net8.0-windows`. References Core, Engine, and IO.
```
OpenNest.Api/
CutParameters.cs
NestRequest.cs
NestRequestPart.cs
NestStrategy.cs
NestResponse.cs
NestRunner.cs
```
All types live in the `OpenNest.Api` namespace.
## Types
### CutParameters
Unified timing and quoting parameters. Replaces the existing `OpenNest.CutParameters` in Core.
```csharp
namespace OpenNest.Api;
public class CutParameters
{
public double Feedrate { get; init; } // in/min or mm/sec depending on Units
public double RapidTravelRate { get; init; } // in/min or mm/sec
public TimeSpan PierceTime { get; init; }
public double LeadInLength { get; init; } // forward-looking: unused until Timing rework
public string PostProcessor { get; init; } // forward-looking: unused until Timing rework
public Units Units { get; init; }
public static CutParameters Default => new()
{
Feedrate = 100,
RapidTravelRate = 300,
PierceTime = TimeSpan.FromSeconds(0.5),
Units = Units.Inches
};
}
```
`LeadInLength` and `PostProcessor` are included for forward compatibility but will not be wired into `Timing.CalculateTime` until the Timing rework. Implementers should not attempt to use them in the initial implementation.
### NestRequestPart
A part to nest, identified by DXF file path. No `Drawing` reference — keeps the request fully serializable for persistence.
```csharp
namespace OpenNest.Api;
public class NestRequestPart
{
public string DxfPath { get; init; }
public int Quantity { get; init; } = 1;
public bool AllowRotation { get; init; } = true;
public int Priority { get; init; } = 0;
}
```
### NestStrategy
```csharp
namespace OpenNest.Api;
public enum NestStrategy { Auto }
```
- `Auto` maps to `DefaultNestEngine` (multi-phase fill).
- Additional strategies (`Linear`, `BestFit`, `Pack`) will be added later, driven by ML-based auto-detection of part type during the training work. The intelligence for selecting the best strategy for a given part will live inside `DefaultNestEngine`.
### NestRequest
Immutable input capturing everything needed to run and reproduce a nest.
```csharp
namespace OpenNest.Api;
public class NestRequest
{
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
public Size SheetSize { get; init; } = new(60, 120); // OpenNest.Geometry.Size(width, length)
public string Material { get; init; } = "Steel, A1011 HR";
public double Thickness { get; init; } = 0.06;
public double Spacing { get; init; } = 0.1; // part-to-part spacing; edge spacing defaults to zero
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
public CutParameters Cutting { get; init; } = CutParameters.Default;
}
```
- `Parts` uses `IReadOnlyList<T>` to prevent mutation after construction, preserving reproducibility when the request is stored in the response.
- `Spacing` maps to `Plate.PartSpacing`. `Plate.EdgeSpacing` defaults to zero on all sides.
- `SheetSize` is `OpenNest.Geometry.Size` (not `System.Drawing.Size`).
### NestResponse
Immutable output containing computed metrics, the resulting `Nest`, and the original request for reproducibility.
```csharp
namespace OpenNest.Api;
public class NestResponse
{
public int SheetCount { get; init; }
public double Utilization { get; init; }
public TimeSpan CutTime { get; init; }
public TimeSpan Elapsed { get; init; }
public Nest Nest { get; init; }
public NestRequest Request { get; init; }
public Task SaveAsync(string path) => ...;
public static Task<NestResponse> LoadAsync(string path) => ...;
}
```
`SaveAsync`/`LoadAsync` live on the data class for API simplicity — a pragmatic choice over a separate IO helper class.
### NestRunner
Stateless orchestrator. Single public method.
```csharp
namespace OpenNest.Api;
public static class NestRunner
{
public static async Task<NestResponse> RunAsync(
NestRequest request,
IProgress<NestProgress> progress = null,
CancellationToken token = default)
{
// 1. Validate request (non-empty parts list, all DXF paths exist)
// 2. Import DXFs → Drawings via DxfImporter + ConvertGeometry.ToProgram
// 3. Create Plate from request.SheetSize / Thickness / Spacing
// 4. Convert NestRequestParts → NestItems
// 5. Multi-plate loop:
// a. Create engine via NestEngineRegistry
// b. Fill plate
// c. Deduct placed quantities
// d. If remaining quantities > 0, create next plate and repeat
// 6. Compute TimingInfo → CutTime using request.Cutting (placeholder for Timing rework)
// 7. Build and return NestResponse (with stopwatch for Elapsed)
}
}
```
- Static class, no state, no DI. If we need dependency injection later, we add an instance-based overload.
- `Timing.CalculateTime` is called with the new `CutParameters` (placeholder integration — `Timing` will be reworked later).
#### Multi-plate Loop
`NestRunner` handles multi-plate nesting: it fills a plate, deducts placed quantities from the remaining request, creates a new plate, and repeats until all quantities are met or no progress is made (a part doesn't fit on a fresh sheet). This is new logic — `AutoNester` and `NestEngineBase` are single-plate only.
#### Error Handling
- If any DXF file path does not exist or fails to import (empty geometry, conversion failure), `RunAsync` throws `FileNotFoundException` or `InvalidOperationException` with a message identifying the failing file. Fail-fast on first bad DXF — no partial results.
- If cancellation is requested, the method throws `OperationCanceledException` per standard .NET patterns.
## Renames
| Current | New | Reason |
|---------|-----|--------|
| `OpenNest.NestResult` (Engine) | `OpenNest.OptimizationResult` | Frees the "result" name for the public API; this type is engine-internal (sequence/score/iterations) |
| `OpenNest.CutParameters` (Core) | Deleted | Replaced by `OpenNest.Api.CutParameters` |
| `.opnest` file extension | `.nest` | Standardize file extensions |
All references to the renamed types and extensions must be updated across the solution: Engine, Core, IO, MCP, Console, Training, Tests, and WinForms.
The WinForms project gains a reference to `OpenNest.Api` to use the new `CutParameters` type (it already references Core and Engine, so no circular dependency).
## Persistence
### File Extensions
- **`.nest`** — nest files (renamed from `.opnest`)
- **`.nestquote`** — quote files (new)
### `.nestquote` Format
ZIP archive containing:
```
quote.nestquote (ZIP)
├── request.json ← serialized NestRequest
├── response.json ← computed metrics (SheetCount, Utilization, CutTime, Elapsed)
└── nest.nest ← embedded .nest file (existing format, produced by NestWriter)
```
- `NestResponse.SaveAsync(path)` writes this ZIP. The embedded `nest.nest` is written to a `MemoryStream` via `NestWriter`, then added as a ZIP entry alongside the JSON files.
- `NestResponse.LoadAsync(path)` reads it back using `NestReader` for the `.nest` payload and JSON deserialization for the metadata.
- Source DXF files are **not** embedded — they are referenced by path in `request.json`. The actual geometry is captured in the `.nest`. Paths exist so the request can be re-run with different parameters if the DXFs are still available.
## Consumer Integration
### MCP Server
The MCP server can expose a single `nest_and_quote` tool that takes request parameters and calls `NestRunner.RunAsync()` internally, replacing the current multi-tool orchestration for batch nesting workflows.
### Console App
The console app gains a one-liner for batch nesting:
```csharp
var response = await NestRunner.RunAsync(request, progress, token);
await response.SaveAsync(outputPath);
```
### WinForms
The WinForms app continues using the engine directly for its interactive workflow. It gains a reference to `OpenNest.Api` only for the shared `CutParameters` type used by `TimingForm` and `CutParametersForm`.
## Out of Scope
- ML-based auto-strategy detection in `DefaultNestEngine` (future, part of training work)
- `Timing` rework (will happen separately; placeholder integration for now)
- Embedding source DXFs in `.nestquote` files
- Builder pattern for `NestRequest` (C# `init` properties suffice)
- DI/instance-based `NestRunner` (add later if needed)
- Additional `NestStrategy` enum values beyond `Auto` (added with ML work)