mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 22:01:06 -04:00
feat(engine): add Circles engine for round parts
Circles packs every part as its minimum enclosing circle at centers tangent to two of the work-area walls, placed discs and clamp keep-outs, bottom-left and left-bottom, with hexagonal and square lattice seeds. It bids only when every part fills at least 0.8 of its enclosing circle and otherwise returns everything unplaced at once. Default runs it as a third candidate. Rectangles' sheet-by-sheet stock loop moves to NestingEngines/SheetByStock (Rectangles output unchanged) and Default's candidate scoring and commit forwarding become shared helpers. Circles also runs the whole job preferring each offered stock and keeps the best result. Contract tests take shapes an engine can override. Synthetic round jobs, salvage-credited: Circles wins or ties all six with valid layouts; Default cost falls about 4% versus Irregular alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a430da89d0
commit
de845a5818
14 files changed
+925
-196
No files matched your search
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
@@ -12,6 +13,7 @@ public class NestingEngineRegistryTests
|
||||
|
||||
Assert.Contains("Rectangles", names);
|
||||
Assert.Contains("Irregular", names);
|
||||
Assert.Contains("Circles", names);
|
||||
Assert.Contains("Fill", names);
|
||||
Assert.Contains("Strip", names);
|
||||
Assert.Contains("Vertical Remnant", names);
|
||||
@@ -55,7 +57,13 @@ public class NestingEngineRegistryTests
|
||||
foreach (var info in NestingEngineRegistry.AvailableEngines)
|
||||
{
|
||||
var engine = info.Factory();
|
||||
var result = engine.Solve(FiniteStockJobTests.Job(1));
|
||||
// Circles bids only on round parts.
|
||||
var job = info.Name == "Circles"
|
||||
? new NestJob(new[] { new NestJobPart("p",
|
||||
PartGeometrySnapshot.FromProgram(NestingEngines.Shapes.Disc(5)), 3) },
|
||||
new[] { new NestPlateStock("s", new Size(100, 200), 1) })
|
||||
: FiniteStockJobTests.Job(1);
|
||||
var result = engine.Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.NestingEngines.Circles;
|
||||
using OpenNest.Geometry;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
/// <summary>Disc packing of round parts. Every layout is checked by the shared layout check.</summary>
|
||||
public class CirclesNestingEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void NonRoundJobBidsNothing()
|
||||
{
|
||||
var job = Job([Part("disc", Disc(2), 3), Part("square", Rectangle(4, 4), 3)], [Stock("s", 20, 20)]);
|
||||
var progress = 0;
|
||||
var result = new CirclesNestingEngine().Solve(job, new Capture(_ => progress++));
|
||||
|
||||
Assert.Empty(result.Plates);
|
||||
Assert.Equal(0, progress);
|
||||
Assert.All(result.Fulfillment, f => Assert.Equal(f.Requested, f.Unplaced));
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualDiscsPackHexagonallyDenserThanASquareGrid()
|
||||
{
|
||||
// A square grid holds 100 unit-radius discs on 20.1 x 20.1; staggered rows hold 105.
|
||||
var job = Job([Part("disc", Disc(1), 200)], [Stock("s", 20.1, 20.1, quantity: 1)]);
|
||||
var result = new CirclesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.True(Assert.Single(result.Plates).Placements.Count >= 105,
|
||||
$"{result.Plates[0].Placements.Count} discs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmallDiscsFillTheGapsBetweenLargeOnes()
|
||||
{
|
||||
// Four r=5 discs fit a 21 x 21 sheet only as a square; r=1 discs fit only in the five
|
||||
// gaps between them and the edges.
|
||||
var job = Job([Part("large", Disc(5), 4), Part("small", Disc(1), 5)],
|
||||
[Stock("s", 21, 21, spacing: 0.1, quantity: 1)]);
|
||||
var result = new CirclesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(9, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampZonesAndEdgeClearanceAreKept()
|
||||
{
|
||||
var clamps = new[] { new ClampZone("a", new Box(4, 0, 2, 1)), new ClampZone("b", new Box(14, 0, 2, 1)) };
|
||||
var job = Job([Part("disc", Disc(1.5), 60)],
|
||||
[Stock("s", 20, 20, spacing: 0.25, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quantity: 1, clampZones: clamps)]);
|
||||
var result = new CirclesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.NotEmpty(Assert.Single(result.Plates).Placements);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
public void RandomMixedDiametersNeverOverlap(int seed)
|
||||
{
|
||||
var random = new Random(seed);
|
||||
var parts = Enumerable.Range(0, 6)
|
||||
.Select(i => Part($"p{i}", random.Next(2) == 0
|
||||
? Disc(0.5 + random.NextDouble() * 4)
|
||||
: Ring(2 + random.NextDouble() * 8, 1),
|
||||
1 + random.Next(15), RotationPolicy.Automatic))
|
||||
.ToArray();
|
||||
var job = Job(parts, [Stock("a", 24, 36, spacing: 0.2 * random.Next(3)), Stock("b", 30, 30, spacing: 0.2)]);
|
||||
var result = new CirclesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
foreach (var plate in result.Plates)
|
||||
{
|
||||
var centers = plate.Placements.Select(p => new Vector(p.X, p.Y)).ToArray();
|
||||
for (var i = 0; i < centers.Length; i++)
|
||||
for (var j = i + 1; j < centers.Length; j++)
|
||||
Assert.NotEqual(centers[i], centers[j]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GearsPackByTheirTipCircle()
|
||||
{
|
||||
var job = Job([Part("gear", Gear(12, 4, 3.6), 10, RotationPolicy.Fixed(0.3))], [Stock("s", 30, 30, spacing: 0.25)]);
|
||||
var result = new CirclesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.All(result.Plates.SelectMany(p => p.Placements), p => Assert.Equal(0.3, p.Rotation, 12));
|
||||
}
|
||||
|
||||
/// <summary>A polygonal gear: alternating tip and root radii.</summary>
|
||||
private static Program Gear(int teeth, double tip, double root) =>
|
||||
Polyline(Enumerable.Range(0, 2 * teeth).Select(i =>
|
||||
{
|
||||
var angle = System.Math.PI * i / teeth;
|
||||
var r = i % 2 == 0 ? tip : root;
|
||||
return (r * System.Math.Cos(angle), r * System.Math.Sin(angle));
|
||||
}).ToArray());
|
||||
|
||||
private sealed class Capture(Action<NestJobProgress> action) : IProgress<NestJobProgress>
|
||||
{ public void Report(NestJobProgress value) => action(value); }
|
||||
}
|
||||
|
||||
public sealed class CirclesContractTests : EngineContractTests<CirclesNestingEngine>
|
||||
{
|
||||
protected override Program Block(double size) => Disc(size / 2);
|
||||
|
||||
protected override Program Mixed(int which) => which switch
|
||||
{
|
||||
0 => Ring(9, 4),
|
||||
1 => Disc(3.5),
|
||||
_ => Ring(6, 3),
|
||||
};
|
||||
|
||||
protected override Program Etchable() => Disc(5);
|
||||
}
|
||||
@@ -10,6 +10,21 @@ namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
/// <summary>Host contract only; engines retain their own packing-quality regressions.</summary>
|
||||
public abstract class EngineContractTests<TEngine> where TEngine : INestingEngine, new()
|
||||
{
|
||||
/// <summary>A part about <paramref name="size"/> across; an engine limited to one shape class
|
||||
/// overrides the shapes below with ones it bids on.</summary>
|
||||
protected virtual Program Block(double size) => Rectangle(size, size);
|
||||
|
||||
/// <summary>Three distinct shapes of decreasing size for mixed jobs.</summary>
|
||||
protected virtual Program Mixed(int which) => which switch
|
||||
{
|
||||
0 => LShape(9, 7, 3),
|
||||
1 => Triangle(7, 7),
|
||||
_ => LShape(6, 5, 2),
|
||||
};
|
||||
|
||||
/// <summary>A part that fits a 10.4 square sheet unrotated; scribe marks are added to it.</summary>
|
||||
protected virtual Program Etchable() => NotchedPartWithEtch();
|
||||
|
||||
[Fact]
|
||||
public void ContractPublicConstructor() => Assert.IsAssignableFrom<INestingEngine>(Activator.CreateInstance(typeof(TEngine)));
|
||||
|
||||
@@ -20,7 +35,7 @@ public abstract class EngineContractTests<TEngine> where TEngine : INestingEngin
|
||||
[InlineData(4)]
|
||||
public void ContractQuadrants(int quadrant)
|
||||
{
|
||||
var job = Job([Part("disc", Disc(2), 3), Part("ell", LShape(6, 5, 2), 3)],
|
||||
var job = Job([Part("disc", Disc(2), 3), Part("ell", Mixed(2), 3)],
|
||||
[Stock("s", 20, 30, 0.2, new Spacing(0.2, 0.3, 0.4, 0.5), quadrant)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
@@ -30,7 +45,7 @@ public abstract class EngineContractTests<TEngine> where TEngine : INestingEngin
|
||||
[Fact]
|
||||
public void ContractOverflowIndicesAndProgress()
|
||||
{
|
||||
var job = Job([Part("p", Rectangle(8, 8), 3)], [Stock("s", 10, 10)]);
|
||||
var job = Job([Part("p", Block(8), 3)], [Stock("s", 10, 10)]);
|
||||
var commits = new List<NestJobProgress>();
|
||||
var result = new TEngine().Solve(job, new Capture(p => { if (p.Stage == NestJobStage.PlateCommitted) commits.Add(p); }));
|
||||
LayoutAssert.Valid(job, result);
|
||||
@@ -47,7 +62,7 @@ public abstract class EngineContractTests<TEngine> where TEngine : INestingEngin
|
||||
[Fact]
|
||||
public void ContractOversize()
|
||||
{
|
||||
var job = Job([Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(2, 2), 2)], [Stock("s", 10, 10)]);
|
||||
var job = Job([Part("huge", Block(50), 1), Part("small", Block(2), 2)], [Stock("s", 10, 10)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
|
||||
@@ -58,7 +73,7 @@ public abstract class EngineContractTests<TEngine> where TEngine : INestingEngin
|
||||
[Fact]
|
||||
public void ContractLowerNumberPriorityWins()
|
||||
{
|
||||
var job = Job([Part("low", Rectangle(8, 8), 1, priority: 9), Part("high", Rectangle(8, 8), 1, priority: 0)],
|
||||
var job = Job([Part("low", Block(8), 1, priority: 9), Part("high", Block(8), 1, priority: 0)],
|
||||
[Stock("s", 10, 10, quantity: 1)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
@@ -68,7 +83,7 @@ public abstract class EngineContractTests<TEngine> where TEngine : INestingEngin
|
||||
[Fact]
|
||||
public void ContractEtchOutsideSheetIsIgnored()
|
||||
{
|
||||
var etched = NotchedPartWithEtch();
|
||||
var etched = Etchable();
|
||||
etched.Codes.Add(new RapidMove(5, 5));
|
||||
etched.Codes.Add(new LinearMove(100, 100) { Layer = LayerType.Scribe });
|
||||
var job = Job([Part("p", etched, 1, RotationPolicy.Fixed(0))], [Stock("s", 10.4, 10.4, quantity: 1)]);
|
||||
@@ -80,7 +95,7 @@ public abstract class EngineContractTests<TEngine> where TEngine : INestingEngin
|
||||
[Fact]
|
||||
public void ContractDeterminism()
|
||||
{
|
||||
NestJob Build() => Job([Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12)],
|
||||
NestJob Build() => Job([Part("disc", Disc(2.5), 12), Part("ell", Mixed(0), 12), Part("tri", Mixed(1), 12)],
|
||||
[Stock("a", 30, 45, 0.3), Stock("b", 40, 40, 0.3)]);
|
||||
var engine = new TEngine();
|
||||
var job = Build();
|
||||
@@ -97,7 +112,7 @@ public abstract class EngineContractTests<TEngine> where TEngine : INestingEngin
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
var job = Job([Part("p", Rectangle(2, 2), 5)], [Stock("s", 10, 10)]);
|
||||
var job = Job([Part("p", Block(2), 5)], [Stock("s", 10, 10)]);
|
||||
Assert.ThrowsAny<OperationCanceledException>(() => new TEngine().Solve(job, token: cancellation.Token));
|
||||
}
|
||||
|
||||
@@ -105,7 +120,7 @@ public abstract class EngineContractTests<TEngine> where TEngine : INestingEngin
|
||||
public void ContractCancellationDuringSolveThrows()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var job = Job([Part("p", Rectangle(2, 2), 20)], [Stock("s", 10, 10)]);
|
||||
var job = Job([Part("p", Block(2), 20)], [Stock("s", 10, 10)]);
|
||||
var progress = new Capture(p =>
|
||||
{
|
||||
if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel();
|
||||
@@ -118,7 +133,7 @@ public abstract class EngineContractTests<TEngine> where TEngine : INestingEngin
|
||||
[InlineData(false)]
|
||||
public void ContractStockAndPlateLimits(bool plateLimit)
|
||||
{
|
||||
var job = Job([Part("p", Rectangle(8, 8), 3)],
|
||||
var job = Job([Part("p", Block(8), 3)],
|
||||
[Stock("s", 10, 10, quantity: plateLimit ? null : 1)],
|
||||
new NestJobOptions(maxPlates: plateLimit ? 1 : null));
|
||||
var result = new TEngine().Solve(job);
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using OpenNest.Engine.NestingEngines.Circles;
|
||||
using OpenNest.Engine.NestingEngines.Default;
|
||||
using OpenNest.Engine.NestingEngines.FlatBar;
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
@@ -38,7 +39,7 @@ public static class NestingEngineRegistry
|
||||
// Listed first: the engine front ends use when the caller names none.
|
||||
Register(
|
||||
"Default",
|
||||
"Any job: runs Irregular and Rectangles and keeps the cheapest valid layout",
|
||||
"Any job: runs Irregular, Rectangles and Circles and keeps the cheapest valid layout",
|
||||
() => new DefaultNestingEngine()
|
||||
);
|
||||
|
||||
@@ -54,6 +55,12 @@ public static class NestingEngineRegistry
|
||||
() => new IrregularNestingEngine()
|
||||
);
|
||||
|
||||
Register(
|
||||
"Circles",
|
||||
"Round parts (blanks, washers, flanges): disc packing by enclosing circle",
|
||||
() => new CirclesNestingEngine()
|
||||
);
|
||||
|
||||
Register(
|
||||
"FlatBar",
|
||||
"Flat bar: parts cut in one row along each bar with the saw cut-list packer",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines;
|
||||
|
||||
/// <summary>Forwards a candidate run's progress except plate commits, which belong to the result
|
||||
/// finally chosen; <see cref="ReportCommits"/> reports those once it is.</summary>
|
||||
internal sealed class CandidateProgress(IProgress<NestJobProgress> inner) : IProgress<NestJobProgress>
|
||||
{
|
||||
public void Report(NestJobProgress value)
|
||||
{
|
||||
if (value != null && value.Stage != NestJobStage.PlateCommitted)
|
||||
inner.Report(value);
|
||||
}
|
||||
|
||||
public static void ReportCommits(NestJobResult result, IProgress<NestJobProgress>? progress)
|
||||
{
|
||||
if (progress == null)
|
||||
return;
|
||||
var parts = 0;
|
||||
for (var i = 0; i < result.Plates.Count; i++)
|
||||
{
|
||||
var plate = result.Plates[i];
|
||||
parts += plate.Placements.Count;
|
||||
progress.Report(new NestJobProgress(NestJobStage.PlateCommitted, plate.StockId, plate.PlateIndex, i + 1, parts));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#nullable enable
|
||||
using System.Linq;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines;
|
||||
|
||||
/// <summary>Ranks whole-job candidate results: a valid layout before an invalid one, then the
|
||||
/// fewest unplaced parts, then the lowest <see cref="NestJobCost"/>; remaining ties keep
|
||||
/// candidate order.</summary>
|
||||
internal sealed record CandidateScore(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order, bool Priced)
|
||||
{
|
||||
public static CandidateScore Of(NestJob job, NestJobResult result, int order)
|
||||
{
|
||||
var valid = NestLayoutCheck.Violations(job, result).Count == 0;
|
||||
var placed = result.Plates.Sum(p => p.Placements.Count);
|
||||
var unplaced = System.Math.Max(0, job.Parts.Sum(p => p.Quantity) - placed);
|
||||
// Cost needs every placement to name a known part, which only a valid result guarantees.
|
||||
var cost = valid ? NestJobCost.Evaluate(job, result) : double.PositiveInfinity;
|
||||
return new CandidateScore(result, valid, unplaced, cost, order, NestJobCost.UsesExplicitCosts(job));
|
||||
}
|
||||
|
||||
public bool IsBetterThan(CandidateScore other)
|
||||
{
|
||||
if (Valid != other.Valid)
|
||||
return Valid;
|
||||
if (Unplaced != other.Unplaced)
|
||||
return Unplaced < other.Unplaced;
|
||||
if (Priced && Cost != other.Cost)
|
||||
return Cost < other.Cost;
|
||||
var scale = System.Math.Max(1, System.Math.Max(System.Math.Abs(Cost), System.Math.Abs(other.Cost)));
|
||||
if (double.IsFinite(Cost) && double.IsFinite(other.Cost) && System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
|
||||
return Cost < other.Cost;
|
||||
return Order < other.Order;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.CirclePacking;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Cutouts;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Circles;
|
||||
|
||||
/// <summary>
|
||||
/// Round-part nesting engine: every part is packed as its minimum enclosing circle
|
||||
/// (<see cref="Item"/>), so blanks, washers, flanges and gears by their tip circle pack as discs
|
||||
/// with exact tangent placement. Rings already nested inside rings by the cutout pre-pass arrive as
|
||||
/// their outer frame and pack the same way.
|
||||
///
|
||||
/// Bids only on round jobs: when any readable part fills less than
|
||||
/// <see cref="MinFillRatio"/> of its enclosing circle, it returns everything unplaced at once, so
|
||||
/// Default discards it at almost no cost and keeps routing such jobs to Irregular and Rectangles.
|
||||
///
|
||||
/// Sheet by sheet (<see cref="SheetByStock"/>), each available stock is packed bottom-left (rows)
|
||||
/// and left-bottom (columns), plain and seeded with a hexagonal lattice of the type with the most
|
||||
/// remaining area, and bottom-left over a square lattice of it; the candidate with the lowest
|
||||
/// estimated whole-job cost wins. The whole job runs once with that free choice and once
|
||||
/// preferring each offered stock, and the best result by <see cref="CandidateScore"/> is kept.
|
||||
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
|
||||
/// </summary>
|
||||
public sealed class CirclesNestingEngine : INestingEngine
|
||||
{
|
||||
/// <summary>Parts below this outline-to-enclosing-circle area ratio waste too much as discs;
|
||||
/// the same threshold the cutout circle packer uses.</summary>
|
||||
public const double MinFillRatio = CutoutCirclePacker.MinFillRatio;
|
||||
|
||||
private static readonly (DiscOrder Order, DiscSeed Seed)[] Variants =
|
||||
{
|
||||
(DiscOrder.BottomLeft, DiscSeed.None), (DiscOrder.LeftBottom, DiscSeed.None),
|
||||
(DiscOrder.BottomLeft, DiscSeed.Hexagonal), (DiscOrder.LeftBottom, DiscSeed.Hexagonal),
|
||||
(DiscOrder.BottomLeft, DiscSeed.Square),
|
||||
};
|
||||
|
||||
public NestJobResult Solve(
|
||||
NestJob job,
|
||||
IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var types = new List<DiscType>(job.Parts.Count);
|
||||
var remaining = new int[job.Parts.Count];
|
||||
for (var i = 0; i < job.Parts.Count; i++)
|
||||
{
|
||||
var part = job.Parts[i];
|
||||
var geometry = JobPartGeometry.TryRead(part.Geometry);
|
||||
var rotations = part.Rotation.EnumerateAngles(maxSamples: 1);
|
||||
var item = geometry == null ? null : Item.Create(geometry);
|
||||
if (item != null && item.FillRatio < MinFillRatio)
|
||||
return new NestJobResultBuilder(job, progress).Build(NestJobStopReason.NoPlacementFound);
|
||||
// Unreadable parts keep a placeholder so type indices match requirement order.
|
||||
types.Add(new DiscType(i, part, item ?? new Item(), rotations.Count > 0 ? rotations[0] : 0,
|
||||
geometry?.MaterialArea ?? 0));
|
||||
if (item != null && rotations.Count > 0 && job.Plates.Any(s => DiscPacker.Fits(s, types[i])))
|
||||
remaining[i] = part.Quantity;
|
||||
}
|
||||
|
||||
var demand = types.Select(t => new SheetDemand(t.Index, t.Id, t.Priority, t.MaterialArea)).ToArray();
|
||||
// Look-ahead: sheet-by-sheet cost estimates can take a large sheet first and then need a
|
||||
// second sheet anyway, so the whole job also runs preferring each offered stock in turn.
|
||||
var forward = progress == null ? null : new CandidateProgress(progress);
|
||||
CandidateScore? best = null;
|
||||
var runs = new NestPlateStock?[] { null }.Concat(job.Plates.Count > 1 ? job.Plates : Array.Empty<NestPlateStock>());
|
||||
var order = 0;
|
||||
foreach (var preferred in runs)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var result = SheetByStock.Run(job, demand, remaining.ToArray(), Pack, forward, token,
|
||||
preferred: preferred);
|
||||
var scored = CandidateScore.Of(job, result, order++);
|
||||
if (best == null || scored.IsBetterThan(best))
|
||||
best = scored;
|
||||
}
|
||||
CandidateProgress.ReportCommits(best!.Result, progress);
|
||||
return best.Result;
|
||||
|
||||
IEnumerable<SheetLayout> Pack(NestPlateStock stock, IReadOnlyList<int> left)
|
||||
{
|
||||
if (!types.Any(t => left[t.Index] > 0 && DiscPacker.Fits(stock, t)))
|
||||
yield break;
|
||||
foreach (var (order, seed) in Variants)
|
||||
yield return DiscPacker.Pack(types, left, stock, order, seed, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.CirclePacking;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Cutouts;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Circles;
|
||||
|
||||
/// <summary>A requested part packed as its enclosing circle at one allowed rotation.</summary>
|
||||
internal sealed record DiscType(int Index, NestJobPart Part, Item Item, double Rotation, double MaterialArea)
|
||||
{
|
||||
public string Id => Part.Id;
|
||||
public int Priority => Part.Priority;
|
||||
public double Radius => Item.Radius;
|
||||
}
|
||||
|
||||
/// <summary>Which way a sheet fills: rows from the bottom edge, or columns from the left edge.</summary>
|
||||
internal enum DiscOrder
|
||||
{
|
||||
BottomLeft,
|
||||
LeftBottom,
|
||||
}
|
||||
|
||||
/// <summary>The lattice one type is tiled in before the greedy pass, if any. Staggered rows are
|
||||
/// densest on a large sheet; a square grid wins when only a few discs span the sheet.</summary>
|
||||
internal enum DiscSeed
|
||||
{
|
||||
None,
|
||||
Hexagonal,
|
||||
Square,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs discs onto one sheet. Each disc goes to the best feasible center tangent to two of
|
||||
/// {work-area walls, placed discs, clamp keep-outs}, the exact candidate set for circles, so no
|
||||
/// no-fit polygons are needed. Discs go lower priority number first, then largest first, so small
|
||||
/// discs land in the gaps between large ones. An optional hexagonal or square lattice of one type
|
||||
/// seeds the sheet before the greedy pass fills the rest.
|
||||
/// </summary>
|
||||
/// <remarks>Every disc keeps the part spacing plus <see cref="Margin"/> from every other disc, and
|
||||
/// its radius from the work-area edges and from every clamp zone grown by the bottom-edge
|
||||
/// clearance. Radii are <see cref="Item.Radius"/>, which covers the layout check's outward flattening,
|
||||
/// so packing is never more lenient than the check. Deterministic.</remarks>
|
||||
internal static class DiscPacker
|
||||
{
|
||||
/// <summary>Extra gap beyond the spacing between discs, as in <see cref="CutoutCirclePacker"/>.</summary>
|
||||
public const double Margin = CutoutCirclePacker.Margin;
|
||||
|
||||
private const double Epsilon = 1e-9;
|
||||
|
||||
public static SheetLayout Pack(IReadOnlyList<DiscType> types, IReadOnlyList<int> remaining,
|
||||
NestPlateStock stock, DiscOrder order, DiscSeed seed, CancellationToken token)
|
||||
{
|
||||
var left = remaining.ToArray();
|
||||
var fits = types.Where(t => left[t.Index] > 0 && Fits(stock, t)).ToList();
|
||||
var sheet = new Sheet(stock, types.Where(t => Fits(stock, t)).Select(t => t.Radius).DefaultIfEmpty(0).Max(), order);
|
||||
if (seed != DiscSeed.None && LatticeType(fits, left) is { } lattice)
|
||||
{
|
||||
foreach (var center in Lattice(stock.WorkArea, lattice.Radius, sheet.Gap, order, seed))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (left[lattice.Index] == 0)
|
||||
break;
|
||||
if (!sheet.Feasible(center, lattice.Radius))
|
||||
continue;
|
||||
sheet.Place(lattice, center);
|
||||
left[lattice.Index]--;
|
||||
}
|
||||
}
|
||||
// Stable: equal radii keep requirement order.
|
||||
foreach (var type in fits.OrderBy(t => t.Priority).ThenByDescending(t => t.Radius))
|
||||
{
|
||||
while (left[type.Index] > 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
// Space only shrinks, so a copy that finds no center leaves none for later copies.
|
||||
if (sheet.Best(type.Radius) is not { } center)
|
||||
break;
|
||||
sheet.Place(type, center);
|
||||
left[type.Index]--;
|
||||
}
|
||||
}
|
||||
return sheet.Layout(types);
|
||||
}
|
||||
|
||||
/// <summary>True when the disc fits the work area in both directions.</summary>
|
||||
public static bool Fits(NestPlateStock stock, DiscType type)
|
||||
{
|
||||
var work = stock.WorkArea;
|
||||
return 2 * type.Radius <= work.Right - work.Left + Epsilon
|
||||
&& 2 * type.Radius <= work.Top - work.Bottom + Epsilon;
|
||||
}
|
||||
|
||||
/// <summary>The top-priority type with the most remaining area, when it has copies to tile.</summary>
|
||||
private static DiscType? LatticeType(IReadOnlyList<DiscType> fits, int[] left)
|
||||
{
|
||||
if (fits.Count == 0)
|
||||
return null;
|
||||
var top = fits.Min(t => t.Priority);
|
||||
var best = fits.Where(t => t.Priority == top)
|
||||
.OrderByDescending(t => left[t.Index] * t.Radius * t.Radius)
|
||||
.First();
|
||||
return left[best.Index] >= 2 ? best : null;
|
||||
}
|
||||
|
||||
/// <summary>Lattice centers in fill order: rows along X for bottom-left, columns along Y for
|
||||
/// left-bottom. Neighbors sit exactly one pitch apart.</summary>
|
||||
private static IEnumerable<Vector> Lattice(Box work, double r, double gap, DiscOrder order, DiscSeed seed)
|
||||
{
|
||||
var pitch = 2 * r + gap;
|
||||
var hexagonal = seed == DiscSeed.Hexagonal;
|
||||
var step = hexagonal ? pitch * System.Math.Sqrt(3) / 2 : pitch;
|
||||
var rows = order == DiscOrder.BottomLeft;
|
||||
var (along0, along1) = rows ? (work.Left + r, work.Right - r) : (work.Bottom + r, work.Top - r);
|
||||
var (across0, across1) = rows ? (work.Bottom + r, work.Top - r) : (work.Left + r, work.Right - r);
|
||||
for (var line = 0; across0 + line * step <= across1 + Epsilon; line++)
|
||||
{
|
||||
var across = across0 + line * step;
|
||||
var shift = hexagonal && line % 2 == 1 ? pitch / 2 : 0;
|
||||
for (var along = along0 + shift; along <= along1 + Epsilon; along += pitch)
|
||||
yield return rows ? new Vector(along, across) : new Vector(across, along);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Sheet
|
||||
{
|
||||
private readonly NestPlateStock stock;
|
||||
private readonly Box work;
|
||||
private readonly Box[] clamps;
|
||||
private readonly double cell;
|
||||
private readonly DiscOrder order;
|
||||
private readonly Dictionary<(long, long), List<int>> grid = new();
|
||||
private readonly List<(DiscType Type, Vector Center)> placed = new();
|
||||
// A disc with no feasible tangent center for radius r has none for any larger radius:
|
||||
// shrinking a feasible larger disc toward the same tangent point keeps it feasible.
|
||||
private readonly List<double> deadFrom = new();
|
||||
|
||||
public Sheet(NestPlateStock stock, double maxRadius, DiscOrder order)
|
||||
{
|
||||
this.stock = stock;
|
||||
work = stock.WorkArea;
|
||||
// The layout check grows each zone by the bottom-edge clearance as a box, square
|
||||
// corners included, so discs keep their radius from that grown box.
|
||||
var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom);
|
||||
clamps = stock.ClampZones.Select(z => z.Bounds.Offset(clearance)).ToArray();
|
||||
Gap = stock.PartSpacing + Margin;
|
||||
// Any disc within reach of a new one lies in the adjacent cells.
|
||||
cell = System.Math.Max(2 * maxRadius + Gap, 1e-6);
|
||||
this.order = order;
|
||||
}
|
||||
|
||||
public double Gap { get; }
|
||||
|
||||
public void Place(DiscType type, Vector center)
|
||||
{
|
||||
var key = Key(center);
|
||||
if (!grid.TryGetValue(key, out var list))
|
||||
grid[key] = list = new List<int>();
|
||||
list.Add(placed.Count);
|
||||
placed.Add((type, center));
|
||||
deadFrom.Add(double.PositiveInfinity);
|
||||
}
|
||||
|
||||
public bool Feasible(Vector p, double r)
|
||||
{
|
||||
if (p.X < work.Left + r - Epsilon || p.X > work.Right - r + Epsilon
|
||||
|| p.Y < work.Bottom + r - Epsilon || p.Y > work.Top - r + Epsilon)
|
||||
return false;
|
||||
foreach (var zone in clamps)
|
||||
if (Distance(p, zone) < r - Epsilon)
|
||||
return false;
|
||||
foreach (var j in Near(p, 1))
|
||||
if (placed[j].Center.DistanceTo(p) < placed[j].Type.Radius + r + Gap - Epsilon)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The best feasible center for a disc of radius <paramref name="r"/>, or null.</summary>
|
||||
public Vector? Best(double r)
|
||||
{
|
||||
var vertical = new List<double> { work.Left + r, work.Right - r };
|
||||
var horizontal = new List<double> { work.Bottom + r, work.Top - r };
|
||||
var corners = new List<Circle>();
|
||||
foreach (var zone in clamps)
|
||||
{
|
||||
vertical.Add(zone.Left - r);
|
||||
vertical.Add(zone.Right + r);
|
||||
horizontal.Add(zone.Bottom - r);
|
||||
horizontal.Add(zone.Top + r);
|
||||
corners.Add(new Circle(new Vector(zone.Left, zone.Bottom), r));
|
||||
corners.Add(new Circle(new Vector(zone.Right, zone.Bottom), r));
|
||||
corners.Add(new Circle(new Vector(zone.Left, zone.Top), r));
|
||||
corners.Add(new Circle(new Vector(zone.Right, zone.Top), r));
|
||||
}
|
||||
|
||||
Vector? best = null;
|
||||
void Consider(Vector p)
|
||||
{
|
||||
if ((best is not { } current || Better(p, current)) && Feasible(p, r))
|
||||
best = p;
|
||||
}
|
||||
|
||||
foreach (var x in vertical)
|
||||
foreach (var y in horizontal)
|
||||
Consider(new Vector(x, y));
|
||||
for (var a = 0; a < corners.Count; a++)
|
||||
{
|
||||
foreach (var p in Lines(corners[a], vertical, horizontal))
|
||||
Consider(p);
|
||||
for (var b = a + 1; b < corners.Count; b++)
|
||||
foreach (var p in Intersections(corners[a], corners[b]))
|
||||
Consider(p);
|
||||
}
|
||||
|
||||
for (var i = 0; i < placed.Count; i++)
|
||||
{
|
||||
if (r >= deadFrom[i] - Epsilon)
|
||||
continue;
|
||||
var around = new Circle(placed[i].Center, placed[i].Type.Radius + r + Gap);
|
||||
var generated = false;
|
||||
var feasible = false;
|
||||
void Tangent(Vector p)
|
||||
{
|
||||
generated = true;
|
||||
if (!Feasible(p, r))
|
||||
return;
|
||||
feasible = true;
|
||||
if (best is not { } current || Better(p, current))
|
||||
best = p;
|
||||
}
|
||||
|
||||
foreach (var p in Lines(around, vertical, horizontal))
|
||||
Tangent(p);
|
||||
foreach (var corner in corners)
|
||||
foreach (var p in Intersections(around, corner))
|
||||
Tangent(p);
|
||||
foreach (var j in Near(around.Center, 2))
|
||||
{
|
||||
if (j <= i || r >= deadFrom[j] - Epsilon)
|
||||
continue;
|
||||
foreach (var p in Intersections(around,
|
||||
new Circle(placed[j].Center, placed[j].Type.Radius + r + Gap)))
|
||||
Tangent(p);
|
||||
}
|
||||
// A tangent circle that crosses nothing is either wholly free or wholly blocked;
|
||||
// only one that crosses constraints yet keeps no feasible point is surely closed.
|
||||
if (generated && !feasible)
|
||||
deadFrom[i] = r;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
public SheetLayout Layout(IReadOnlyList<DiscType> types)
|
||||
{
|
||||
var counts = new int[types.Count];
|
||||
var poses = new List<(string PartId, double X, double Y, double Rotation)>(placed.Count);
|
||||
Box? envelope = null;
|
||||
foreach (var (type, center) in placed)
|
||||
{
|
||||
counts[type.Index]++;
|
||||
var origin = center + Rotate(type.Item.PartOffset, type.Rotation);
|
||||
poses.Add((type.Id, origin.X, origin.Y, type.Rotation));
|
||||
var box = new Box(center.X - type.Radius, center.Y - type.Radius, 2 * type.Radius, 2 * type.Radius);
|
||||
envelope = envelope == null ? box : Union(envelope, box);
|
||||
}
|
||||
// Summed per type in type order, the form of the stock loop's demand area.
|
||||
var area = types.Sum(t => counts[t.Index] * t.MaterialArea);
|
||||
return new SheetLayout(stock, poses, area, envelope);
|
||||
}
|
||||
|
||||
private bool Better(Vector candidate, Vector current)
|
||||
{
|
||||
var (a, b) = order == DiscOrder.BottomLeft
|
||||
? (candidate.Y - current.Y, candidate.X - current.X)
|
||||
: (candidate.X - current.X, candidate.Y - current.Y);
|
||||
if (System.Math.Abs(a) > Epsilon)
|
||||
return a < 0;
|
||||
return b < -Epsilon;
|
||||
}
|
||||
|
||||
private (long, long) Key(Vector p) =>
|
||||
((long)System.Math.Floor((p.X - work.Left) / cell), (long)System.Math.Floor((p.Y - work.Bottom) / cell));
|
||||
|
||||
private IEnumerable<int> Near(Vector p, int cells)
|
||||
{
|
||||
var (kx, ky) = Key(p);
|
||||
for (var dx = -cells; dx <= cells; dx++)
|
||||
for (var dy = -cells; dy <= cells; dy++)
|
||||
if (grid.TryGetValue((kx + dx, ky + dy), out var list))
|
||||
foreach (var j in list)
|
||||
yield return j;
|
||||
}
|
||||
|
||||
private static double Distance(Vector p, Box box)
|
||||
{
|
||||
var dx = System.Math.Max(0, System.Math.Max(box.Left - p.X, p.X - box.Right));
|
||||
var dy = System.Math.Max(0, System.Math.Max(box.Bottom - p.Y, p.Y - box.Top));
|
||||
return System.Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Vector> Lines(Circle circle, List<double> vertical, List<double> horizontal)
|
||||
{
|
||||
var c = circle.Center;
|
||||
var r = circle.Radius;
|
||||
foreach (var x in vertical)
|
||||
{
|
||||
var d = x - c.X;
|
||||
if (System.Math.Abs(d) > r)
|
||||
continue;
|
||||
var h = System.Math.Sqrt(System.Math.Max(0, r * r - d * d));
|
||||
yield return new Vector(x, c.Y - h);
|
||||
if (h > Epsilon)
|
||||
yield return new Vector(x, c.Y + h);
|
||||
}
|
||||
foreach (var y in horizontal)
|
||||
{
|
||||
var d = y - c.Y;
|
||||
if (System.Math.Abs(d) > r)
|
||||
continue;
|
||||
var h = System.Math.Sqrt(System.Math.Max(0, r * r - d * d));
|
||||
yield return new Vector(c.X - h, y);
|
||||
if (h > Epsilon)
|
||||
yield return new Vector(c.X + h, y);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Vector> Intersections(Circle a, Circle b)
|
||||
{
|
||||
var d = a.Center.DistanceTo(b.Center);
|
||||
if (d < Epsilon || d > a.Radius + b.Radius || d < System.Math.Abs(a.Radius - b.Radius))
|
||||
yield break;
|
||||
var along = (a.Radius * a.Radius - b.Radius * b.Radius + d * d) / (2 * d);
|
||||
var across = System.Math.Sqrt(System.Math.Max(0, a.Radius * a.Radius - along * along));
|
||||
var ux = (b.Center.X - a.Center.X) / d;
|
||||
var uy = (b.Center.Y - a.Center.Y) / d;
|
||||
var mx = a.Center.X + ux * along;
|
||||
var my = a.Center.Y + uy * along;
|
||||
yield return new Vector(mx - uy * across, my + ux * across);
|
||||
if (across > Epsilon)
|
||||
yield return new Vector(mx + uy * across, my - ux * across);
|
||||
}
|
||||
|
||||
private static Vector Rotate(Vector v, double angle)
|
||||
{
|
||||
var sine = System.Math.Sin(angle);
|
||||
var cosine = System.Math.Cos(angle);
|
||||
return new Vector(v.X * cosine - v.Y * sine, v.X * sine + v.Y * cosine);
|
||||
}
|
||||
|
||||
private static Box Union(Box a, Box b)
|
||||
{
|
||||
var l = System.Math.Min(a.Left, b.Left);
|
||||
var bo = System.Math.Min(a.Bottom, b.Bottom);
|
||||
var r = System.Math.Max(a.Right, b.Right);
|
||||
var t = System.Math.Max(a.Top, b.Top);
|
||||
return new Box(l, bo, r - l, t - bo);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.NestingEngines.Circles;
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||
|
||||
@@ -16,8 +17,10 @@ namespace OpenNest.Engine.NestingEngines.Default;
|
||||
/// layout before an invalid one, then the fewest unplaced parts, then the lowest
|
||||
/// <see cref="NestJobCost"/>; remaining ties keep candidate order.
|
||||
///
|
||||
/// Candidates are Irregular, then Rectangles. Neither wins every job: Rectangles packs each part
|
||||
/// as its box, which is often best for plain plates and is a cheap, valid fallback for the rest.
|
||||
/// Candidates are Irregular, then Rectangles, then Circles. Neither of the first two wins every
|
||||
/// job: Rectangles packs each part as its box, which is often best for plain plates and is a
|
||||
/// cheap, valid fallback for the rest. Circles bids only when every part is round, and otherwise
|
||||
/// returns everything unplaced at once.
|
||||
/// A candidate that throws or returns no result is skipped; when every candidate throws, the
|
||||
/// first exception is rethrown. Cancellation stops the search. Candidates' plate commits are not forwarded; the chosen
|
||||
/// result's commits are reported once it is selected.
|
||||
@@ -29,7 +32,10 @@ public sealed class DefaultNestingEngine : INestingEngine
|
||||
private readonly IReadOnlyList<Func<INestingEngine>> candidates;
|
||||
|
||||
public DefaultNestingEngine()
|
||||
: this(new Func<INestingEngine>[] { () => new IrregularNestingEngine(), () => new RectanglesNestingEngine() })
|
||||
: this(new Func<INestingEngine>[]
|
||||
{
|
||||
() => new IrregularNestingEngine(), () => new RectanglesNestingEngine(), () => new CirclesNestingEngine(),
|
||||
})
|
||||
{
|
||||
}
|
||||
|
||||
@@ -51,7 +57,7 @@ public sealed class DefaultNestingEngine : INestingEngine
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
var forward = progress == null ? null : new CandidateProgress(progress);
|
||||
Scored? best = null;
|
||||
CandidateScore? best = null;
|
||||
ExceptionDispatchInfo? firstFailure = null;
|
||||
|
||||
for (var order = 0; order < candidates.Count; order++)
|
||||
@@ -71,7 +77,7 @@ public sealed class DefaultNestingEngine : INestingEngine
|
||||
if (result == null)
|
||||
continue;
|
||||
|
||||
var scored = Scored.Of(job, result, order);
|
||||
var scored = CandidateScore.Of(job, result, order);
|
||||
if (best == null || scored.IsBetterThan(best))
|
||||
best = scored;
|
||||
}
|
||||
@@ -83,57 +89,7 @@ public sealed class DefaultNestingEngine : INestingEngine
|
||||
throw new InvalidOperationException("No candidate engine returned a result.");
|
||||
}
|
||||
|
||||
ReportCommits(best.Result, progress);
|
||||
CandidateProgress.ReportCommits(best.Result, progress);
|
||||
return best.Result;
|
||||
}
|
||||
|
||||
private static void ReportCommits(NestJobResult result, IProgress<NestJobProgress>? progress)
|
||||
{
|
||||
if (progress == null)
|
||||
return;
|
||||
var parts = 0;
|
||||
for (var i = 0; i < result.Plates.Count; i++)
|
||||
{
|
||||
var plate = result.Plates[i];
|
||||
parts += plate.Placements.Count;
|
||||
progress.Report(new NestJobProgress(NestJobStage.PlateCommitted, plate.StockId, plate.PlateIndex, i + 1, parts));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Scored(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order, bool Priced)
|
||||
{
|
||||
public static Scored Of(NestJob job, NestJobResult result, int order)
|
||||
{
|
||||
var valid = NestLayoutCheck.Violations(job, result).Count == 0;
|
||||
var placed = result.Plates.Sum(p => p.Placements.Count);
|
||||
var unplaced = System.Math.Max(0, job.Parts.Sum(p => p.Quantity) - placed);
|
||||
// Cost needs every placement to name a known part, which only a valid result guarantees.
|
||||
var cost = valid ? NestJobCost.Evaluate(job, result) : double.PositiveInfinity;
|
||||
return new Scored(result, valid, unplaced, cost, order, NestJobCost.UsesExplicitCosts(job));
|
||||
}
|
||||
|
||||
public bool IsBetterThan(Scored other)
|
||||
{
|
||||
if (Valid != other.Valid)
|
||||
return Valid;
|
||||
if (Unplaced != other.Unplaced)
|
||||
return Unplaced < other.Unplaced;
|
||||
if (Priced && Cost != other.Cost)
|
||||
return Cost < other.Cost;
|
||||
var scale = System.Math.Max(1, System.Math.Max(System.Math.Abs(Cost), System.Math.Abs(other.Cost)));
|
||||
if (double.IsFinite(Cost) && double.IsFinite(other.Cost) && System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
|
||||
return Cost < other.Cost;
|
||||
return Order < other.Order;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forwards a candidate's progress except plate commits, which belong to the chosen result.</summary>
|
||||
private sealed class CandidateProgress(IProgress<NestJobProgress> inner) : IProgress<NestJobProgress>
|
||||
{
|
||||
public void Report(NestJobProgress value)
|
||||
{
|
||||
if (value != null && value.Stage != NestJobStage.PlateCommitted)
|
||||
inner.Report(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,9 @@ namespace OpenNest.Engine.NestingEngines.Rectangles;
|
||||
/// and exact box packing beats contour-sliding engines on both speed and density. Irregular parts
|
||||
/// are still placed validly, only as their bounding boxes; they are not nested into each other.
|
||||
///
|
||||
/// Sheet by sheet, each available stock is packed under several free-space scoring rules and
|
||||
/// two pick modes (best-fitting box anywhere, or largest type first). The candidate sheet with
|
||||
/// the lowest estimated whole-job cost wins: its salvage-credited net cost (NestJobCost) plus the
|
||||
/// remaining demand priced at the best net-cost-per-part-area ratio seen among the candidates.
|
||||
/// Sheet by sheet (<see cref="SheetByStock"/>), each available stock is packed under several
|
||||
/// free-space scoring rules and two pick modes (best-fitting box anywhere, or largest type first),
|
||||
/// and the candidate with the lowest estimated whole-job cost wins.
|
||||
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
|
||||
/// </summary>
|
||||
public sealed class RectanglesNestingEngine : INestingEngine
|
||||
@@ -52,130 +51,23 @@ public sealed class RectanglesNestingEngine : INestingEngine
|
||||
if (t.Orientations.Count == 0 || !job.Plates.Any(stock => t.Orientations.Any(o => FitsStock(stock, o))))
|
||||
remaining[t.Index] = 0;
|
||||
|
||||
var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
|
||||
var parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
||||
var result = new NestJobResultBuilder(job, progress);
|
||||
NestJobStopReason reason;
|
||||
var demand = types.Select(t => new SheetDemand(t.Index, t.Id, t.Priority, t.MaterialArea)).ToArray();
|
||||
return SheetByStock.Run(job, demand, remaining, Pack, progress, token, BatchSelected);
|
||||
|
||||
while (true)
|
||||
IEnumerable<SheetLayout> Pack(NestPlateStock stock, IReadOnlyList<int> left)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (remaining.All(r => r == 0))
|
||||
{
|
||||
reason = NestJobStopReason.NoPlacementFound; // Builder reports Completed when demand is met.
|
||||
break;
|
||||
}
|
||||
if (job.Options.MaxPlates is int cap && result.SheetsUsed(job) >= cap)
|
||||
{
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
}
|
||||
|
||||
var trials = new List<(SheetPlan Plan, double Net)>();
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (stock.Quantity is int available && used[stock.Id] >= available)
|
||||
continue;
|
||||
progress?.Report(new NestJobProgress(
|
||||
NestJobStage.EvaluatingCandidate, stock.Id, result.SheetsUsed(job), result.SheetsUsed(job), 0));
|
||||
foreach (var mode in Modes)
|
||||
foreach (var rule in Rules)
|
||||
{
|
||||
var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token);
|
||||
SheetPacked?.Invoke(mode);
|
||||
if (plan.Parts.Count > 0)
|
||||
trials.Add((plan, NetCost(job, plan)));
|
||||
}
|
||||
}
|
||||
|
||||
if (trials.Count == 0)
|
||||
{
|
||||
var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q);
|
||||
reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
|
||||
var chosen = Choose(types, remaining, trials);
|
||||
var poses = chosen.Poses().ToArray();
|
||||
var demand = types.ToDictionary(t => t.Id, t => remaining[t.Index], StringComparer.Ordinal);
|
||||
var counts = chosen.Parts.GroupBy(p => p.Type.Id, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
||||
var availableEntries = job.Plates.Count(s => s.Quantity == null || used[s.Id] < s.Quantity);
|
||||
// Both private MaxRects modes return only after their normal demand/no-fit search;
|
||||
// neither returns partial/budget-limited output. This is not a geometric impossibility proof.
|
||||
var copies = CompletedLayoutRepeat.Limit(demand, counts, searchCompleted: true, availableEntries,
|
||||
chosen.Stock.Quantity - used[chosen.Stock.Id], job.Options.MaxPlates - result.SheetsUsed(job));
|
||||
if (copies > 1)
|
||||
{
|
||||
try
|
||||
foreach (var mode in Modes)
|
||||
foreach (var rule in Rules)
|
||||
{
|
||||
NestJobValidator.ValidateCandidate(new PlateCandidate(poses.Select(p =>
|
||||
new NestJobPlacement(p.PartId, 0, p.X, p.Y, p.Rotation))), chosen.Stock, demand, parts);
|
||||
var plan = SheetPacker.Pack(types, left, stock, rule, mode, token);
|
||||
SheetPacked?.Invoke(mode);
|
||||
yield return new SheetLayout(stock, plan.Poses().ToArray(), plan.MaterialArea, plan.Envelope);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Keep the original one-sheet path if the candidate validator cannot certify
|
||||
// reuse (including its stricter work-area tolerance). Final job validation still applies.
|
||||
copies = 1;
|
||||
}
|
||||
}
|
||||
BatchSelected?.Invoke(new RepeatBatch(chosen.Stock.Id, copies, availableEntries, result.SheetsUsed(job), false));
|
||||
for (var copy = 0; copy < copies; copy++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
result.AddSheet(chosen.Stock, poses); // Builder owns fresh placements/instance indices.
|
||||
}
|
||||
used[chosen.Stock.Id] = checked(used[chosen.Stock.Id] + copies);
|
||||
foreach (var type in types)
|
||||
if (counts.TryGetValue(type.Id, out var count))
|
||||
remaining[type.Index] = checked(remaining[type.Index] - checked(count * copies));
|
||||
}
|
||||
|
||||
return result.Build(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the sheet with the lowest estimated whole-job cost. Remaining demand is priced at the
|
||||
/// best net-cost-per-material ratio any candidate achieved, so a sheet that finishes the job
|
||||
/// competes fairly with a denser partial one. Ties: more material placed, then enumeration order.
|
||||
/// </summary>
|
||||
private static SheetPlan Choose(
|
||||
IReadOnlyList<BoxType> types, int[] remaining, List<(SheetPlan Plan, double Net)> trials)
|
||||
{
|
||||
var demandArea = types.Sum(t => remaining[t.Index] * t.MaterialArea);
|
||||
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Plan.MaterialArea, 1e-12));
|
||||
return trials
|
||||
.Select((t, order) => (t.Plan, order,
|
||||
Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio)))
|
||||
.OrderBy(t => PriorityDebt(types, remaining, t.Plan))
|
||||
.ThenBy(t => t.Estimate)
|
||||
.ThenByDescending(t => t.Plan.MaterialArea)
|
||||
.ThenBy(t => t.order)
|
||||
.First()
|
||||
.Plan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Priority guard: how many instances of the most urgent (lowest-number) tier with remaining
|
||||
/// demand this plan leaves unplaced. Plans are ranked on this before cost, so a cheaper sheet
|
||||
/// can never win by serving a later tier at the expense of an earlier one.
|
||||
/// </summary>
|
||||
private static int PriorityDebt(IReadOnlyList<BoxType> types, int[] remaining, SheetPlan plan)
|
||||
{
|
||||
var active = types.Where(t => remaining[t.Index] > 0).ToList();
|
||||
if (active.Count == 0)
|
||||
return 0;
|
||||
var top = active.Min(t => t.Priority);
|
||||
var placed = plan.Parts.Count(p => p.Type.Priority == top);
|
||||
return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed;
|
||||
}
|
||||
|
||||
private static double NetCost(NestJob job, SheetPlan plan) =>
|
||||
plan.Envelope is { } envelope
|
||||
? NestJobCost.NetSheetCost(job.Options, plan.Stock, envelope)
|
||||
: NestJobCost.GrossSheetCost(plan.Stock);
|
||||
|
||||
private static bool FitsStock(NestPlateStock stock, BoxOrientation o)
|
||||
{
|
||||
var work = stock.WorkArea;
|
||||
@@ -183,9 +75,3 @@ public sealed class RectanglesNestingEngine : INestingEngine
|
||||
&& o.Height <= work.Top - work.Bottom + SheetPacker.OverhangAllowance;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ResultBuilderExtensions
|
||||
{
|
||||
public static int SheetsUsed(this NestJobResultBuilder builder, NestJob job) =>
|
||||
job.Plates.Sum(builder.SheetsUsed);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines;
|
||||
|
||||
/// <summary>A requested part type as the stock loop sees it; <see cref="Index"/> addresses the
|
||||
/// remaining-demand array.</summary>
|
||||
internal sealed record SheetDemand(int Index, string Id, int Priority, double MaterialArea);
|
||||
|
||||
/// <summary>One proposed single-sheet layout from an engine's packer.</summary>
|
||||
/// <param name="MaterialArea">Material placed, summed per type in type order so equal layouts get
|
||||
/// bit-identical areas (placement-order rounding differs across platform math libraries).</param>
|
||||
/// <param name="Envelope">Bounds of the placed material, or null to price the gross sheet.</param>
|
||||
internal sealed record SheetLayout(
|
||||
NestPlateStock Stock,
|
||||
IReadOnlyList<(string PartId, double X, double Y, double Rotation)> Poses,
|
||||
double MaterialArea,
|
||||
Box? Envelope);
|
||||
|
||||
/// <summary>
|
||||
/// Sheet-by-sheet stock selection shared by engines that propose whole single-sheet layouts.
|
||||
/// Each round asks the engine for layouts of the remaining demand on every available stock and
|
||||
/// commits the one with the lowest estimated whole-job cost: its salvage-credited net cost
|
||||
/// (<see cref="NestJobCost"/>) plus the remaining demand priced at the best net-cost-per-material
|
||||
/// ratio seen among the candidates. A completed layout repeats while demand and stock allow it.
|
||||
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
|
||||
/// </summary>
|
||||
internal static class SheetByStock
|
||||
{
|
||||
/// <param name="remaining">Demand per type index; updated as sheets commit. Zero a type the
|
||||
/// engine can never place before calling.</param>
|
||||
/// <param name="pack">Layouts of the given remaining demand on one stock. Each must be the
|
||||
/// packer's completed search, not budget-limited output, so a repeat is safe.</param>
|
||||
/// <param name="preferred">When set, only this stock is tried while it is available and
|
||||
/// takes a layout; the others are tried only once it cannot.</param>
|
||||
public static NestJobResult Run(
|
||||
NestJob job,
|
||||
IReadOnlyList<SheetDemand> types,
|
||||
int[] remaining,
|
||||
Func<NestPlateStock, IReadOnlyList<int>, IEnumerable<SheetLayout>> pack,
|
||||
IProgress<NestJobProgress>? progress,
|
||||
CancellationToken token,
|
||||
Action<RepeatBatch>? batchSelected = null,
|
||||
NestPlateStock? preferred = null)
|
||||
{
|
||||
var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
|
||||
var parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
||||
var priority = types.ToDictionary(t => t.Id, t => t.Priority, StringComparer.Ordinal);
|
||||
var result = new NestJobResultBuilder(job, progress);
|
||||
NestJobStopReason reason;
|
||||
|
||||
while (true)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (remaining.All(r => r == 0))
|
||||
{
|
||||
reason = NestJobStopReason.NoPlacementFound; // Builder reports Completed when demand is met.
|
||||
break;
|
||||
}
|
||||
if (job.Options.MaxPlates is int cap && SheetsUsed(result, job) >= cap)
|
||||
{
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
}
|
||||
|
||||
var trials = new List<(SheetLayout Layout, double Net)>();
|
||||
if (preferred != null)
|
||||
Try(preferred, trials);
|
||||
if (trials.Count == 0)
|
||||
foreach (var stock in job.Plates)
|
||||
if (!ReferenceEquals(stock, preferred))
|
||||
Try(stock, trials);
|
||||
|
||||
if (trials.Count == 0)
|
||||
{
|
||||
var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q);
|
||||
reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
|
||||
var chosen = Choose(types, remaining, priority, trials);
|
||||
var poses = chosen.Poses;
|
||||
var demand = types.ToDictionary(t => t.Id, t => remaining[t.Index], StringComparer.Ordinal);
|
||||
var counts = poses.GroupBy(p => p.PartId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
||||
var availableEntries = job.Plates.Count(s => s.Quantity == null || used[s.Id] < s.Quantity);
|
||||
var copies = CompletedLayoutRepeat.Limit(demand, counts, searchCompleted: true, availableEntries,
|
||||
chosen.Stock.Quantity - used[chosen.Stock.Id], job.Options.MaxPlates - SheetsUsed(result, job));
|
||||
if (copies > 1)
|
||||
{
|
||||
try
|
||||
{
|
||||
NestJobValidator.ValidateCandidate(new PlateCandidate(poses.Select(p =>
|
||||
new NestJobPlacement(p.PartId, 0, p.X, p.Y, p.Rotation))), chosen.Stock, demand, parts);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Keep the original one-sheet path if the candidate validator cannot certify
|
||||
// reuse (including its stricter work-area tolerance). Final job validation still applies.
|
||||
copies = 1;
|
||||
}
|
||||
}
|
||||
batchSelected?.Invoke(new RepeatBatch(chosen.Stock.Id, copies, availableEntries, SheetsUsed(result, job), false));
|
||||
for (var copy = 0; copy < copies; copy++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
result.AddSheet(chosen.Stock, poses); // Builder owns fresh placements/instance indices.
|
||||
}
|
||||
used[chosen.Stock.Id] = checked(used[chosen.Stock.Id] + copies);
|
||||
foreach (var type in types)
|
||||
if (counts.TryGetValue(type.Id, out var count))
|
||||
remaining[type.Index] = checked(remaining[type.Index] - checked(count * copies));
|
||||
}
|
||||
|
||||
return result.Build(reason);
|
||||
|
||||
void Try(NestPlateStock stock, List<(SheetLayout Layout, double Net)> trials)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (stock.Quantity is int available && used[stock.Id] >= available)
|
||||
return;
|
||||
progress?.Report(new NestJobProgress(
|
||||
NestJobStage.EvaluatingCandidate, stock.Id, SheetsUsed(result, job), SheetsUsed(result, job), 0));
|
||||
foreach (var layout in pack(stock, remaining))
|
||||
if (layout.Poses.Count > 0)
|
||||
trials.Add((layout, NetCost(job, layout)));
|
||||
}
|
||||
}
|
||||
|
||||
public static int SheetsUsed(NestJobResultBuilder builder, NestJob job) =>
|
||||
job.Plates.Sum(builder.SheetsUsed);
|
||||
|
||||
/// <summary>
|
||||
/// Picks the layout with the lowest estimated whole-job cost. Remaining demand is priced at the
|
||||
/// best net-cost-per-material ratio any candidate achieved, so a sheet that finishes the job
|
||||
/// competes fairly with a denser partial one. Ties: more material placed, then enumeration order.
|
||||
/// </summary>
|
||||
private static SheetLayout Choose(IReadOnlyList<SheetDemand> types, int[] remaining,
|
||||
IReadOnlyDictionary<string, int> priority, List<(SheetLayout Layout, double Net)> trials)
|
||||
{
|
||||
var demandArea = types.Sum(t => remaining[t.Index] * t.MaterialArea);
|
||||
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Layout.MaterialArea, 1e-12));
|
||||
return trials
|
||||
.Select((t, order) => (t.Layout, order,
|
||||
Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, demandArea - t.Layout.MaterialArea) * bestRatio)))
|
||||
.OrderBy(t => PriorityDebt(types, remaining, priority, t.Layout))
|
||||
.ThenBy(t => t.Estimate)
|
||||
.ThenByDescending(t => t.Layout.MaterialArea)
|
||||
.ThenBy(t => t.order)
|
||||
.First()
|
||||
.Layout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Priority guard: how many instances of the most urgent (lowest-number) tier with remaining
|
||||
/// demand this layout leaves unplaced. Layouts are ranked on this before cost, so a cheaper sheet
|
||||
/// can never win by serving a later tier at the expense of an earlier one.
|
||||
/// </summary>
|
||||
private static int PriorityDebt(IReadOnlyList<SheetDemand> types, int[] remaining,
|
||||
IReadOnlyDictionary<string, int> priority, SheetLayout layout)
|
||||
{
|
||||
var active = types.Where(t => remaining[t.Index] > 0).ToList();
|
||||
if (active.Count == 0)
|
||||
return 0;
|
||||
var top = active.Min(t => t.Priority);
|
||||
var placed = layout.Poses.Count(p => priority[p.PartId] == top);
|
||||
return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed;
|
||||
}
|
||||
|
||||
private static double NetCost(NestJob job, SheetLayout layout) =>
|
||||
layout.Envelope is { } envelope
|
||||
? NestJobCost.NetSheetCost(job.Options, layout.Stock, envelope)
|
||||
: NestJobCost.GrossSheetCost(layout.Stock);
|
||||
}
|
||||
@@ -25,10 +25,11 @@ namespace OpenNest.Mcp.Tools
|
||||
|
||||
/// <summary>Built-in engines with what each suits; a test keeps this in step with the registry.</summary>
|
||||
private const string JobEngines =
|
||||
"Whole-job engine. Default (used when omitted): suits any job; runs Irregular and Rectangles "
|
||||
"Whole-job engine. Default (used when omitted): suits any job; runs Irregular, Rectangles and Circles "
|
||||
+ "and keeps the cheapest valid layout. "
|
||||
+ "Irregular: irregular profiles; no-fit-polygon packing into notches and gaps, best-fit pairs. "
|
||||
+ "Rectangles: plain and near-rectangular parts; packs each part as its bounding box, fastest. "
|
||||
+ "Circles: round parts (blanks, washers, flanges); packs each part as its enclosing circle; places nothing when any part is not round. "
|
||||
+ "Fill: many copies of few drawings; lattice fill with pairs and rectangle best-fit. "
|
||||
+ "Strip: Fill variant laying mixed drawings in strips. "
|
||||
+ "Vertical Remnant, Horizontal Remnant: Fill variants keeping a right-side or top-side drop. "
|
||||
|
||||
@@ -88,7 +88,7 @@ Run `dotnet run --project OpenNest.Console -- --help` for the complete options a
|
||||
|
||||
## Nesting Engines
|
||||
|
||||
Default compares the built-in Irregular and Rectangles engines and keeps the best valid whole-job layout. Other engines and plug-ins suit particular jobs; see [which engine to use](docs/nesting-engines.md). Desktop Auto Nest, console autonest, MCP autonest, and the API share an independent validation pipeline, including for plug-ins. [Automatic nesting and validation](docs/automatic-nesting.md) explains how callers handle invalid results.
|
||||
Default compares the built-in Irregular, Rectangles and Circles engines and keeps the best valid whole-job layout. Other engines and plug-ins suit particular jobs; see [which engine to use](docs/nesting-engines.md). Desktop Auto Nest, console autonest, MCP autonest, and the API share an independent validation pipeline, including for plug-ins. [Automatic nesting and validation](docs/automatic-nesting.md) explains how callers handle invalid results.
|
||||
|
||||
## File Format
|
||||
|
||||
|
||||
+26
-2
@@ -11,8 +11,9 @@ in `OpenNest.Engine/NestingEngines/<Name>/`, its tests in `OpenNest.Engine.Tests
|
||||
|
||||
| Engine | Best for | Method |
|
||||
|---|---|---|
|
||||
| Default | Any job; used when no engine is named | Runs Irregular, then Rectangles, checks both layouts with the layout check and keeps the best: valid first, then fewest unplaced parts, then lowest salvage-credited cost; ties keep Irregular |
|
||||
| Default | Any job; used when no engine is named | Runs Irregular, Rectangles and Circles, checks each layout with the layout check and keeps the best: valid first, then fewest unplaced parts, then lowest salvage-credited cost; ties keep the earlier engine |
|
||||
| Rectangles | Plain and near-rectangular plates | Each part packed as the box of its material at its minimum-area rotation, using a maximal-rectangles free list; stock chosen sheet by sheet by salvage-credited look-ahead cost |
|
||||
| Circles | Round parts: blanks, washers, flanges, gears by their tip circle | Each part packed as its minimum enclosing circle at centers tangent to two of walls, placed discs and clamp keep-outs, with hexagonal and square lattice seeds; bids only when every part is round; stock chosen as Rectangles does, plus whole-job runs preferring each stock |
|
||||
| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan |
|
||||
| FlatBar | Flat bar: parts about as wide as the bar | Each stock read as a bar and cut in one row: the CutList cut-list packer and a best-fill search on part lengths less their self-nesting gain, then a slide-left pass that nests shaped ends and refills freed length; stock chosen bar by bar by whole-plan cost |
|
||||
| StockLadder | Caller-supplied stock ladders | Constrained-first fill with equivalent-demand area repacking |
|
||||
@@ -22,7 +23,8 @@ Neither Irregular nor Rectangles wins every job, even within its own lane, so De
|
||||
rather than choosing by part shape; Rectangles adds little time and is also the fallback when an
|
||||
Irregular layout fails the check. A candidate that throws is skipped. Default routes the whole job:
|
||||
engines cannot share a sheet, so a job mixing plain and irregular parts goes to both engines whole.
|
||||
A future circle/ring engine joins Default as another candidate.
|
||||
Circles is a third candidate that bids only on round jobs (see below), so a job mixing round and
|
||||
other parts goes to Irregular and Rectangles.
|
||||
|
||||
Rectangles places irregular parts validly, but only as their bounding boxes; it never nests into a
|
||||
notch or hole. Box sides account for how the layout check flattens arcs, so round-edged parts stay
|
||||
@@ -166,6 +168,28 @@ Default inherits these fast paths by running the two engines; other engines and
|
||||
keep their existing search behavior. Quantity-bearing presentation is a separate validated-pipeline
|
||||
representation; repeating does not bypass whole-job validation or change cutting programs.
|
||||
|
||||
## Circles
|
||||
|
||||
Circles (`OpenNest.Engine/NestingEngines/Circles/`) packs every part as its minimum enclosing
|
||||
circle (`CirclePacking/Item`): the circle covers the layout check's outward-flattened perimeter,
|
||||
so packing is never more lenient than the check. When any readable part fills less than 0.8 of
|
||||
that circle (a square fills 0.64, a gear about 0.85), it returns everything unplaced without
|
||||
reporting progress, and Default discards it at almost no cost. Holes are not used here; rings in
|
||||
rings come from the cutout pre-pass, which hands the engine each nested chain as its outer frame.
|
||||
|
||||
Each disc goes, lower priority number first and then largest first, to the best feasible center
|
||||
tangent to two of: the work-area walls, placed discs, and clamp zones grown by the bottom-edge
|
||||
clearance (square-cornered, as the layout check grows them). Discs keep the part spacing plus
|
||||
twice `NestTolerances.ValidationOutline` between them. Bottom-left fills rows and left-bottom
|
||||
fills columns; each also runs seeded with a hexagonal lattice of the type with the most remaining
|
||||
area, and bottom-left runs over a square lattice too, which wins when only a few discs span the
|
||||
sheet. A disc with no feasible tangent center for one radius is skipped for that radius and larger
|
||||
ones. Sheets and stock are chosen by the loop Rectangles uses (`NestingEngines/SheetByStock`):
|
||||
salvage-credited look-ahead cost, priority first, and repeated completed layouts. That per-sheet
|
||||
estimate can take a large sheet first and still need a second, so Circles runs the whole job once
|
||||
with a free stock choice and once preferring each offered stock, and keeps the best result as
|
||||
Default ranks candidates (valid, fewest unplaced, lowest cost).
|
||||
|
||||
## Filling cutouts
|
||||
|
||||
Every whole-job `NestPipeline.Run` (desktop Auto Nest, Console, MCP and API) first places parts
|
||||
|
||||
Reference in new issue
Block a user