mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 02:41:06 -04:00
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>
96 lines
4.7 KiB
C#
96 lines
4.7 KiB
C#
#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);
|
|
}
|
|
}
|
|
}
|