fix(engine): fix pair candidate filtering for narrow plates and strips

The BestFitFilter's aspect ratio cap of 5.0 was rejecting valid pair
candidates needed for narrow plates (e.g. 60x6.5, aspect 9.2) and
remainder strips on normal plates. Three fixes:

- BestFitFinder: derive MaxAspectRatio from the plate's own aspect
  ratio so narrow plates don't reject all elongated pairs
- SelectPairCandidates: search the full unfiltered candidate list
  (not just Keep=true) in strip mode, so pairs rejected by aspect
  ratio for the main plate can still be used for narrow remainder
  strips
- BestFitCache.Populate: skip caching empty result lists so stale
  pre-computed data from nest files doesn't prevent recomputation

Also fixes console --size parsing to use LxW format matching
Size.Parse convention, and includes prior engine refactoring
(sequential fill loops, parallel FillPattern, pre-sorted edge
arrays in RotationSlideStrategy).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 01:14:07 -04:00
parent ae010212ac
commit 3c59da17c2
8 changed files with 185 additions and 127 deletions
+6 -5
View File
@@ -98,8 +98,8 @@ static class NestConsole
var parts = args[++i].Split('x'); var parts = args[++i].Split('x');
if (parts.Length == 2) if (parts.Length == 2)
{ {
o.PlateWidth = double.Parse(parts[0]); o.PlateHeight = double.Parse(parts[0]);
o.PlateHeight = double.Parse(parts[1]); o.PlateWidth = double.Parse(parts[1]);
} }
break; break;
case "--check-overlaps": case "--check-overlaps":
@@ -297,7 +297,8 @@ static class NestConsole
static void PrintHeader(Nest nest, Plate plate, Drawing drawing, int existingCount, Options options) static void PrintHeader(Nest nest, Plate plate, Drawing drawing, int existingCount, Options options)
{ {
Console.WriteLine($"Nest: {nest.Name}"); Console.WriteLine($"Nest: {nest.Name}");
Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}"); var wa = plate.WorkArea();
Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Length:F1} x {plate.Size.Width:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Length:F1}x{wa.Width:F1}");
Console.WriteLine($"Drawing: {drawing.Name}"); Console.WriteLine($"Drawing: {drawing.Name}");
Console.WriteLine(options.KeepParts Console.WriteLine(options.KeepParts
? $"Keeping {existingCount} existing parts" ? $"Keeping {existingCount} existing parts"
@@ -386,7 +387,7 @@ static class NestConsole
Console.Error.WriteLine(); Console.Error.WriteLine();
Console.Error.WriteLine("Modes:"); Console.Error.WriteLine("Modes:");
Console.Error.WriteLine(" <nest.zip> Load nest and fill (existing behavior)"); Console.Error.WriteLine(" <nest.zip> Load nest and fill (existing behavior)");
Console.Error.WriteLine(" <part.dxf> --size WxH Import DXF, create plate, and fill"); Console.Error.WriteLine(" <part.dxf> --size LxW Import DXF, create plate, and fill");
Console.Error.WriteLine(" <nest.zip> <part.dxf> Load nest and add imported DXF drawings"); Console.Error.WriteLine(" <nest.zip> <part.dxf> Load nest and add imported DXF drawings");
Console.Error.WriteLine(); Console.Error.WriteLine();
Console.Error.WriteLine("Options:"); Console.Error.WriteLine("Options:");
@@ -394,7 +395,7 @@ static class NestConsole
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)"); Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)");
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)"); Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
Console.Error.WriteLine(" --spacing <value> Override part spacing"); Console.Error.WriteLine(" --spacing <value> Override part spacing");
Console.Error.WriteLine(" --size <WxH> Override plate size (e.g. 120x60); required for DXF-only mode"); Console.Error.WriteLine(" --size <LxW> Override plate size (e.g. 120x60); required for DXF-only mode");
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.zip)"); Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.zip)");
Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)"); Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)");
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
+1 -1
View File
@@ -1103,7 +1103,7 @@ namespace OpenNest
return minDist; return minDist;
} }
private static double OneWayDistance( public static double OneWayDistance(
Vector vertex, (Vector start, Vector end)[] edges, Vector edgeOffset, Vector vertex, (Vector start, Vector end)[] edges, Vector edgeOffset,
PushDirection direction) PushDirection direction)
{ {
+3
View File
@@ -153,6 +153,9 @@ namespace OpenNest.Engine.BestFit
public static void Populate(Drawing drawing, double plateWidth, double plateHeight, public static void Populate(Drawing drawing, double plateWidth, double plateHeight,
double spacing, List<BestFitResult> results) double spacing, List<BestFitResult> results)
{ {
if (results == null || results.Count == 0)
return;
var key = new CacheKey(drawing, plateWidth, plateHeight, spacing); var key = new CacheKey(drawing, plateWidth, plateHeight, spacing);
_cache.TryAdd(key, results); _cache.TryAdd(key, results);
} }
+4 -1
View File
@@ -20,10 +20,13 @@ namespace OpenNest.Engine.BestFit
{ {
_evaluator = evaluator ?? new PairEvaluator(); _evaluator = evaluator ?? new PairEvaluator();
_slideComputer = slideComputer; _slideComputer = slideComputer;
var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) /
System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
_filter = new BestFitFilter _filter = new BestFitFilter
{ {
MaxPlateWidth = maxPlateWidth, MaxPlateWidth = maxPlateWidth,
MaxPlateHeight = maxPlateHeight MaxPlateHeight = maxPlateHeight,
MaxAspectRatio = System.Math.Max(5.0, plateAspect)
}; };
} }
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Engine.BestFit namespace OpenNest.Engine.BestFit
@@ -147,11 +148,82 @@ namespace OpenNest.Engine.BestFit
var results = new double[count]; var results = new double[count];
for (var i = 0; i < count; i++) // Pre-calculate moving vertices in local space.
var movingVerticesLocal = new HashSet<Vector>();
for (var i = 0; i < part2TemplateLines.Count; i++)
{ {
results[i] = Helper.DirectionalDistance( movingVerticesLocal.Add(part2TemplateLines[i].StartPoint);
part2TemplateLines, allDx[i], allDy[i], part1Lines, allDirs[i]); movingVerticesLocal.Add(part2TemplateLines[i].EndPoint);
} }
var movingVerticesArray = movingVerticesLocal.ToArray();
// Pre-calculate stationary vertices in local space.
var stationaryVerticesLocal = new HashSet<Vector>();
for (var i = 0; i < part1Lines.Count; i++)
{
stationaryVerticesLocal.Add(part1Lines[i].StartPoint);
stationaryVerticesLocal.Add(part1Lines[i].EndPoint);
}
var stationaryVerticesArray = stationaryVerticesLocal.ToArray();
// Pre-sort stationary and moving edges for all 4 directions.
var stationaryEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
var movingEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
foreach (var dir in AllDirections)
{
var sEdges = new (Vector start, Vector end)[part1Lines.Count];
for (var i = 0; i < part1Lines.Count; i++)
sEdges[i] = (part1Lines[i].StartPoint, part1Lines[i].EndPoint);
if (dir == PushDirection.Left || dir == PushDirection.Right)
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
stationaryEdgesByDir[dir] = sEdges;
var opposite = Helper.OppositeDirection(dir);
var mEdges = new (Vector start, Vector end)[part2TemplateLines.Count];
for (var i = 0; i < part2TemplateLines.Count; i++)
mEdges[i] = (part2TemplateLines[i].StartPoint, part2TemplateLines[i].EndPoint);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
movingEdgesByDir[dir] = mEdges;
}
// Use Parallel.For for the heavy lifting.
System.Threading.Tasks.Parallel.For(0, count, i =>
{
var dx = allDx[i];
var dy = allDy[i];
var dir = allDirs[i];
var movingOffset = new Vector(dx, dy);
var sEdges = stationaryEdgesByDir[dir];
var mEdges = movingEdgesByDir[dir];
var opposite = Helper.OppositeDirection(dir);
var minDist = double.MaxValue;
// Case 1: Moving vertices -> Stationary edges
foreach (var mv in movingVerticesArray)
{
var d = Helper.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir);
if (d < minDist) minDist = d;
}
// Case 2: Stationary vertices -> Moving edges (translated)
foreach (var sv in stationaryVerticesArray)
{
var d = Helper.OneWayDistance(sv, mEdges, movingOffset, opposite);
if (d < minDist) minDist = d;
}
results[i] = minDist;
});
return results; return results;
} }
+3 -2
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
@@ -512,7 +513,7 @@ namespace OpenNest
{ {
var bag = new System.Collections.Concurrent.ConcurrentBag<List<Part>>(); var bag = new System.Collections.Concurrent.ConcurrentBag<List<Part>>();
foreach (var entry in rotations) Parallel.ForEach(rotations, entry =>
{ {
var filler = new FillLinear(strip, PartSpacing); var filler = new FillLinear(strip, PartSpacing);
var h = filler.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal); var h = filler.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal);
@@ -523,7 +524,7 @@ namespace OpenNest
if (v != null && v.Count > 0) if (v != null && v.Count > 0)
bag.Add(v); bag.Add(v);
} });
List<Part> best = null; List<Part> best = null;
+91 -109
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using OpenNest.Engine.BestFit; using OpenNest.Engine.BestFit;
using OpenNest.Engine.ML; using OpenNest.Engine.ML;
using OpenNest.Geometry; using OpenNest.Geometry;
@@ -215,54 +216,48 @@ namespace OpenNest
// Linear phase // Linear phase
var linearSw = Stopwatch.StartNew(); var linearSw = Stopwatch.StartNew();
var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List<Part> parts)>();
var angleBag = new System.Collections.Concurrent.ConcurrentBag<AngleResult>();
var anglesCompleted = 0;
System.Threading.Tasks.Parallel.ForEach(angles,
new System.Threading.Tasks.ParallelOptions { CancellationToken = token },
angle =>
{
var localEngine = new FillLinear(workArea, Plate.PartSpacing);
var h = localEngine.Fill(item.Drawing, angle, NestDirection.Horizontal);
var v = localEngine.Fill(item.Drawing, angle, NestDirection.Vertical);
var angleDeg = Angle.ToDegrees(angle);
if (h != null && h.Count > 0)
{
linearBag.Add((FillScore.Compute(h, workArea), h));
angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count });
}
if (v != null && v.Count > 0)
{
linearBag.Add((FillScore.Compute(v, workArea), v));
angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count });
}
var done = Interlocked.Increment(ref anglesCompleted);
var bestCount = System.Math.Max(h?.Count ?? 0, v?.Count ?? 0);
progress?.Report(new NestProgress
{
Phase = NestPhase.Linear,
PlateNumber = PlateNumber,
Description = $"Linear: {done}/{angles.Count} angles, {angleDeg:F0}° = {bestCount} parts"
});
});
linearSw.Stop();
AngleResults.AddRange(angleBag);
var bestLinearCount = 0; var bestLinearCount = 0;
foreach (var (score, parts) in linearBag)
for (var ai = 0; ai < angles.Count; ai++)
{ {
if (parts.Count > bestLinearCount) token.ThrowIfCancellationRequested();
bestLinearCount = parts.Count;
if (score > bestScore) var angle = angles[ai];
var localEngine = new FillLinear(workArea, Plate.PartSpacing);
var h = localEngine.Fill(item.Drawing, angle, NestDirection.Horizontal);
var v = localEngine.Fill(item.Drawing, angle, NestDirection.Vertical);
var angleDeg = Angle.ToDegrees(angle);
if (h != null && h.Count > 0)
{ {
best = parts; var scoreH = FillScore.Compute(h, workArea);
bestScore = score; AngleResults.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count });
WinnerPhase = NestPhase.Linear; if (h.Count > bestLinearCount) bestLinearCount = h.Count;
if (scoreH > bestScore)
{
best = h;
bestScore = scoreH;
WinnerPhase = NestPhase.Linear;
}
} }
if (v != null && v.Count > 0)
{
var scoreV = FillScore.Compute(v, workArea);
AngleResults.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count });
if (v.Count > bestLinearCount) bestLinearCount = v.Count;
if (scoreV > bestScore)
{
best = v;
bestScore = scoreV;
WinnerPhase = NestPhase.Linear;
}
}
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea,
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts");
} }
linearSw.Stop();
PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds)); PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds));
Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}");
@@ -369,56 +364,51 @@ namespace OpenNest
Plate.PartSpacing); Plate.PartSpacing);
var candidates = SelectPairCandidates(bestFits, workArea); var candidates = SelectPairCandidates(bestFits, workArea);
Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); var diagMsg = $"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}\n" +
$"[FillWithPairs] Plate: {Plate.Size.Width:F2}x{Plate.Size.Length:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}";
Debug.WriteLine(diagMsg);
try { System.IO.File.AppendAllText(
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
$"{DateTime.Now:HH:mm:ss} {diagMsg}\n"); } catch { }
var resultBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List<Part> parts)>(); List<Part> best = null;
var bestScore = default(FillScore);
try try
{ {
var pairsCompleted = 0; for (var i = 0; i < candidates.Count; i++)
var pairsBestCount = 0; {
token.ThrowIfCancellationRequested();
System.Threading.Tasks.Parallel.For(0, candidates.Count, var result = candidates[i];
new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, var pairParts = result.BuildParts(item.Drawing);
i => var angles = result.HullAngles;
var engine = new FillLinear(workArea, Plate.PartSpacing);
var filled = FillPattern(engine, pairParts, angles, workArea);
if (filled != null && filled.Count > 0)
{ {
var result = candidates[i]; var score = FillScore.Compute(filled, workArea);
var pairParts = result.BuildParts(item.Drawing); if (best == null || score > bestScore)
var angles = result.HullAngles;
var engine = new FillLinear(workArea, Plate.PartSpacing);
var filled = FillPattern(engine, pairParts, angles, workArea);
if (filled != null && filled.Count > 0)
resultBag.Add((FillScore.Compute(filled, workArea), filled));
var done = Interlocked.Increment(ref pairsCompleted);
InterlockedMax(ref pairsBestCount, filled?.Count ?? 0);
progress?.Report(new NestProgress
{ {
Phase = NestPhase.Pairs, best = filled;
PlateNumber = PlateNumber, bestScore = score;
Description = $"Pairs: {done}/{candidates.Count} candidates, best = {pairsBestCount} parts" }
}); }
});
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea,
$"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts");
}
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
Debug.WriteLine("[FillWithPairs] Cancelled mid-phase, using results so far"); Debug.WriteLine("[FillWithPairs] Cancelled mid-phase, using results so far");
} }
List<Part> best = null;
var bestScore = default(FillScore);
foreach (var (score, parts) in resultBag)
{
if (best == null || score > bestScore)
{
best = parts;
bestScore = score;
}
}
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}"); Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}");
try { System.IO.File.AppendAllText(
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
$"{DateTime.Now:HH:mm:ss} [FillWithPairs] Best: {bestScore.Count} parts, density={bestScore.Density:P1}\n"); } catch { }
return best ?? new List<Part>(); return best ?? new List<Part>();
} }
@@ -436,11 +426,14 @@ namespace OpenNest
var plateShortSide = System.Math.Min(Plate.Size.Width, Plate.Size.Length); var plateShortSide = System.Math.Min(Plate.Size.Width, Plate.Size.Length);
// When the work area is significantly narrower than the plate, // When the work area is significantly narrower than the plate,
// include all pairs that fit the narrow dimension. // search ALL candidates (not just kept) for pairs that fit the
// narrow dimension. Pairs rejected by aspect ratio for the full
// plate may be exactly what's needed for a narrow remainder strip.
if (workShortSide < plateShortSide * 0.5) if (workShortSide < plateShortSide * 0.5)
{ {
var stripCandidates = kept var stripCandidates = bestFits
.Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon); .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon
&& r.Utilization >= 0.3);
var existing = new HashSet<BestFitResult>(top); var existing = new HashSet<BestFitResult>(top);
@@ -480,32 +473,33 @@ namespace OpenNest
private List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea) private List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
{ {
List<Part> best = null; var results = new System.Collections.Concurrent.ConcurrentBag<(List<Part> Parts, FillScore Score)>();
var bestScore = default(FillScore);
foreach (var angle in angles) Parallel.ForEach(angles, angle =>
{ {
var pattern = BuildRotatedPattern(groupParts, angle); var pattern = BuildRotatedPattern(groupParts, angle);
if (pattern.Parts.Count == 0) if (pattern.Parts.Count == 0)
continue; return;
var h = engine.Fill(pattern, NestDirection.Horizontal); var h = engine.Fill(pattern, NestDirection.Horizontal);
var scoreH = h != null && h.Count > 0 ? FillScore.Compute(h, workArea) : default; if (h != null && h.Count > 0)
results.Add((h, FillScore.Compute(h, workArea)));
if (scoreH.Count > 0 && (best == null || scoreH > bestScore))
{
best = h;
bestScore = scoreH;
}
var v = engine.Fill(pattern, NestDirection.Vertical); var v = engine.Fill(pattern, NestDirection.Vertical);
var scoreV = v != null && v.Count > 0 ? FillScore.Compute(v, workArea) : default; if (v != null && v.Count > 0)
results.Add((v, FillScore.Compute(v, workArea)));
});
if (scoreV.Count > 0 && (best == null || scoreV > bestScore)) List<Part> best = null;
var bestScore = default(FillScore);
foreach (var res in results)
{
if (best == null || res.Score > bestScore)
{ {
best = v; best = res.Parts;
bestScore = scoreV; bestScore = res.Score;
} }
} }
@@ -766,17 +760,5 @@ namespace OpenNest
} }
} }
// --- Utilities ---
private static void InterlockedMax(ref int location, int value)
{
int current;
do
{
current = location;
if (value <= current) return;
} while (Interlocked.CompareExchange(ref location, value, current) != current);
}
} }
} }
+2 -6
View File
@@ -862,9 +862,7 @@ namespace OpenNest.Forms
var progress = new Progress<NestProgress>(p => var progress = new Progress<NestProgress>(p =>
{ {
progressForm.UpdateProgress(p); progressForm.UpdateProgress(p);
activeForm.PlateView.SetTemporaryParts(p.BestParts);
if (p.BestParts != null)
activeForm.PlateView.SetTemporaryParts(p.BestParts);
}); });
progressForm.Show(this); progressForm.Show(this);
@@ -924,9 +922,7 @@ namespace OpenNest.Forms
var progress = new Progress<NestProgress>(p => var progress = new Progress<NestProgress>(p =>
{ {
progressForm.UpdateProgress(p); progressForm.UpdateProgress(p);
activeForm.PlateView.SetTemporaryParts(p.BestParts);
if (p.BestParts != null)
activeForm.PlateView.SetTemporaryParts(p.BestParts);
}); });
Action<List<Part>> onComplete = parts => Action<List<Part>> onComplete = parts =>