fix(opus55): honor part priority and use host scoring and tolerances
Opus55 ignored NestJobPart.Priority, so the shared contract test (lower number wins scarce stock) failed; lower-number priority now precedes its placement score. SheetEconomics is replaced by the host's NestJobCost so it optimizes exactly what the benchmark scores, and its footprint margin comes from NestTolerances.SafeClearanceMargin plus four Clipper grid units - the same 0.003 total as before, which keeps its contact points. Synthetic benchmark (5 jobs, salvage 0.5): all valid, cost unchanged at 5452.79, time 611 -> 456 ms. Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -63,25 +63,9 @@ internal sealed class FrontierPacker
|
||||
this.stock = stock;
|
||||
this.axis = axis;
|
||||
this.beta = beta;
|
||||
work = WorkArea(stock);
|
||||
work = stock.WorkArea;
|
||||
}
|
||||
|
||||
public static Box WorkArea(NestPlateStock stock)
|
||||
{
|
||||
var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
|
||||
var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
|
||||
return new Box(
|
||||
left + stock.EdgeSpacing.Left,
|
||||
bottom + stock.EdgeSpacing.Bottom,
|
||||
stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right,
|
||||
stock.Size.Width - stock.EdgeSpacing.Bottom - stock.EdgeSpacing.Top
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>True when the orientation's bounds fit the work area at all (Box.Length is the X extent).</summary>
|
||||
public static bool Fits(Orientation o, Box work) =>
|
||||
o.Width <= work.Length + 1e-9 && o.Height <= work.Width + 1e-9;
|
||||
|
||||
public SheetFill Fill(IReadOnlyList<int> remaining, CancellationToken token)
|
||||
{
|
||||
var left = remaining.ToArray();
|
||||
@@ -91,7 +75,7 @@ internal sealed class FrontierPacker
|
||||
if (left[type.Index] <= 0)
|
||||
continue;
|
||||
foreach (var o in type.Orientations)
|
||||
if (Fits(o, work))
|
||||
if (stock.Fits(o.Width, o.Height))
|
||||
states.Add(new Region(o, work));
|
||||
}
|
||||
|
||||
@@ -140,17 +124,21 @@ internal sealed class FrontierPacker
|
||||
var bestValue = double.PositiveInfinity;
|
||||
var bestSide = double.PositiveInfinity;
|
||||
var bestLead = double.PositiveInfinity;
|
||||
var bestPriority = int.MaxValue;
|
||||
|
||||
foreach (var region in states)
|
||||
{
|
||||
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
|
||||
continue;
|
||||
var area = types[region.Orientation.TypeIndex].Area;
|
||||
var priority = types[region.Orientation.TypeIndex].Part.Priority;
|
||||
if (priority > bestPriority) continue;
|
||||
var fills = advance <= Tie;
|
||||
// Gap fill prefers bigger parts (negated area); advance prefers least advance per area.
|
||||
var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta);
|
||||
|
||||
var better = bestRegion == null
|
||||
|| priority < bestPriority
|
||||
|| (fills && !bestFills)
|
||||
|| (
|
||||
fills == bestFills
|
||||
@@ -165,6 +153,7 @@ internal sealed class FrontierPacker
|
||||
if (!better)
|
||||
continue;
|
||||
bestRegion = region;
|
||||
bestPriority = priority;
|
||||
bestPoint = point;
|
||||
bestFills = fills;
|
||||
bestValue = value;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.Opus55;
|
||||
|
||||
@@ -15,7 +16,7 @@ namespace OpenNest.Engine.Opus55;
|
||||
internal sealed class NoFitCache
|
||||
{
|
||||
/// <summary>Clipper decimal precision; 1e-4 job units is far below any margin we keep.</summary>
|
||||
public const int Precision = 4;
|
||||
public const int Precision = NestTolerances.ClipperPrecision;
|
||||
|
||||
private readonly double halfClearance;
|
||||
private readonly ConcurrentDictionary<(int, int), PathD> footprints = new();
|
||||
@@ -43,7 +44,10 @@ internal sealed class NoFitCache
|
||||
// footprint is a superset of "every point within the clearance of the outline".
|
||||
var inflated = Clipper.InflatePaths(
|
||||
new PathsD { o.Outline },
|
||||
halfClearance + o.Tolerance,
|
||||
// Four additional grid units cover this engine's repeated footprint/NFP
|
||||
// Boolean operations. Keep its established contact points and packing quality.
|
||||
halfClearance + NestTolerances.SafeClearanceMargin(o.Tolerance) / 2
|
||||
+ 4 * System.Math.Pow(10, -Precision),
|
||||
JoinType.Miter,
|
||||
EndType.Polygon,
|
||||
2.0,
|
||||
|
||||
@@ -19,17 +19,6 @@ namespace OpenNest.Engine.Opus55;
|
||||
/// </summary>
|
||||
public sealed class Opus55NestingEngine : INestingEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Extra clearance beyond the stock's part spacing, in job units. Footprints already contain
|
||||
/// the true outline grown by half the clearance (chord tolerance is added separately), so this
|
||||
/// only has to cover the validator's side: NestValidator flattens each perimeter within
|
||||
/// 0.001 of the true arc (OutlineTolerance), so two true arcs can read up to 0.002 closer
|
||||
/// than they are. The remaining 0.001 absorbs the 1e-4 Clipper grid on both sides (footprint
|
||||
/// offset, Minkowski union, free-region difference, the validator's own offset); the
|
||||
/// validator's round-join chord error (0.0001) only ever makes it more lenient.
|
||||
/// </summary>
|
||||
internal const double ClearanceMargin = 0.003;
|
||||
|
||||
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
|
||||
private static readonly (PackAxis Axis, double Beta)[] Variants =
|
||||
{
|
||||
@@ -54,6 +43,7 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
var types = PartCatalog.Build(job);
|
||||
var solver = new Solver(job, types, progress, token);
|
||||
|
||||
@@ -63,7 +53,7 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
{
|
||||
var placeable = job.Plates.Any(stock =>
|
||||
stock.Quantity != 0
|
||||
&& type.Orientations.Any(o => FrontierPacker.Fits(o, FrontierPacker.WorkArea(stock)))
|
||||
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
|
||||
);
|
||||
demand[type.Index] = placeable ? type.Part.Quantity : 0;
|
||||
}
|
||||
@@ -99,7 +89,7 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
|
||||
public WorkCounter Work { get; } = new();
|
||||
|
||||
private double Penalty => job.Plates.Count == 0 ? 0 : job.Plates.Max(SheetEconomics.SheetArea);
|
||||
private double Penalty => NestJobCost.UnplacedPartPenalty(job);
|
||||
|
||||
public Plan Plan(int[] demand, PackAxis axis, double beta)
|
||||
{
|
||||
@@ -126,7 +116,7 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
var prefix = sheets.Take(sheets.Count - k).ToList();
|
||||
var tail = sheets.Skip(sheets.Count - k).ToList();
|
||||
var tailParts = tail.Sum(s => s.Parts.Count);
|
||||
var tailNet = tail.Sum(s => SheetEconomics.NetArea(job.Options, s));
|
||||
var tailNet = tail.Sum(s => NetArea(job.Options, s));
|
||||
var tailDemand = new int[types.Count];
|
||||
foreach (var part in tail.SelectMany(s => s.Parts))
|
||||
tailDemand[part.Orientation.TypeIndex]++;
|
||||
@@ -158,7 +148,7 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
|
||||
private NoFitCache CacheFor(NestPlateStock stock)
|
||||
{
|
||||
var clearance = System.Math.Max(0, stock.PartSpacing) + ClearanceMargin;
|
||||
var clearance = System.Math.Max(0, stock.PartSpacing);
|
||||
if (!caches.TryGetValue(clearance, out var cache))
|
||||
caches[clearance] = cache = new NoFitCache(clearance);
|
||||
return cache;
|
||||
@@ -209,7 +199,7 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work);
|
||||
var fill = packer.Fill(remaining, token);
|
||||
if (fill.Parts.Count > 0)
|
||||
trials.Add((fill, SheetEconomics.NetArea(job.Options, fill)));
|
||||
trials.Add((fill, NetArea(job.Options, fill)));
|
||||
}
|
||||
|
||||
if (trials.Count == 0)
|
||||
@@ -243,36 +233,23 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
|
||||
private sealed record Run(IReadOnlyList<SheetFill> Sheets, double Net, NestJobStopReason Reason);
|
||||
|
||||
private static NestJobResult BuildResult(
|
||||
NestJob job,
|
||||
IReadOnlyList<PartType> types,
|
||||
Plan plan,
|
||||
IProgress<NestJobProgress>? progress
|
||||
)
|
||||
private static NestJobResult BuildResult(NestJob job, IReadOnlyList<PartType> types,
|
||||
Plan plan, IProgress<NestJobProgress>? progress)
|
||||
{
|
||||
var placed = new int[types.Count];
|
||||
var plates = new List<NestJobPlateResult>(plan.Sheets.Count);
|
||||
var committedParts = 0;
|
||||
var builder = new NestJobResultBuilder(job, progress);
|
||||
foreach (var sheet in plan.Sheets)
|
||||
{
|
||||
var placements = sheet.Parts.Select(p =>
|
||||
{
|
||||
var type = types[p.Orientation.TypeIndex];
|
||||
return new NestJobPlacement(type.Part.Id, placed[type.Index]++, p.X, p.Y, p.Orientation.Rotation);
|
||||
});
|
||||
plates.Add(new NestJobPlateResult(plates.Count, sheet.Stock, placements.ToList()));
|
||||
committedParts += sheet.Parts.Count;
|
||||
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, sheet.Stock.Id, plates.Count - 1, plates.Count, committedParts));
|
||||
builder.AddSheet(sheet.Stock, sheet.Parts.Select(p =>
|
||||
(types[p.Orientation.TypeIndex].Part.Id, p.X, p.Y, p.Orientation.Rotation)));
|
||||
return builder.Build(plan.Reason);
|
||||
}
|
||||
|
||||
var fulfillment = types.Select(t => new PartFulfillment(t.Part.Id, t.Part.Quantity, placed[t.Index], t.Part.Quantity - placed[t.Index]));
|
||||
var usage = job.Plates.Select(stock =>
|
||||
private static double NetArea(NestJobOptions options, SheetFill fill)
|
||||
{
|
||||
var count = plan.Sheets.Count(s => ReferenceEquals(s.Stock, stock));
|
||||
return new StockUsage(stock.Id, count, stock.Quantity - count);
|
||||
});
|
||||
var status = plan.Unplaced == 0 ? NestJobStatus.Complete : NestJobStatus.Incomplete;
|
||||
return new NestJobResult(status, plan.Reason, plates, fulfillment.ToList(), usage.ToList());
|
||||
if (fill.Parts.Count == 0) return fill.Stock.Area;
|
||||
var left = fill.Parts.Min(p => p.Left);
|
||||
var bottom = fill.Parts.Min(p => p.Bottom);
|
||||
return NestJobCost.NetSheetArea(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom,
|
||||
fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom));
|
||||
}
|
||||
|
||||
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Opus55;
|
||||
@@ -56,8 +54,6 @@ internal static class PartCatalog
|
||||
/// <summary>Hard cap on distinct orientations evaluated per part type.</summary>
|
||||
private const int MaxOrientations = 8;
|
||||
|
||||
private const double TwoPi = System.Math.PI * 2;
|
||||
|
||||
public static IReadOnlyList<PartType> Build(NestJob job)
|
||||
{
|
||||
// Fewer orientations per type for jobs with many distinct parts; every (type, rotation)
|
||||
@@ -83,21 +79,15 @@ internal static class PartCatalog
|
||||
continue;
|
||||
}
|
||||
|
||||
var angles = CandidateAngles(part.Rotation, perimeter, perType);
|
||||
var angles = RotationCandidates.DistinctOutlines(perimeter,
|
||||
CandidateAngles(part.Rotation, perimeter, perType));
|
||||
var tolerance = ChooseTolerance(perimeter);
|
||||
var orientations = new List<Orientation>();
|
||||
var signatures = new List<string>();
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var outline = Polygonize(perimeter, angle, tolerance);
|
||||
if (outline.Count < 3)
|
||||
continue;
|
||||
// Point-symmetric parts (rectangles, discs...) look identical at several angles;
|
||||
// evaluating duplicates only costs time.
|
||||
var signature = Signature(outline);
|
||||
if (signatures.Contains(signature))
|
||||
continue;
|
||||
signatures.Add(signature);
|
||||
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance));
|
||||
}
|
||||
|
||||
@@ -107,17 +97,8 @@ internal static class PartCatalog
|
||||
return types;
|
||||
}
|
||||
|
||||
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry)
|
||||
{
|
||||
var entities = ConvertProgram
|
||||
.ToGeometry(DrawingJobMapper.ToProgram(geometry))
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
.ToList();
|
||||
if (entities.Count == 0)
|
||||
return null;
|
||||
var profile = new ShapeProfile(entities);
|
||||
return profile.Perimeter is { } perimeter && perimeter.Area() > 1e-9 ? perimeter : null;
|
||||
}
|
||||
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) =>
|
||||
JobPartGeometry.TryRead(geometry)?.Perimeter;
|
||||
|
||||
/// <summary>
|
||||
/// Coarsens arc polygonization (up to 0.1% of the part size) until the outline is small
|
||||
@@ -170,105 +151,6 @@ internal static class PartCatalog
|
||||
};
|
||||
}
|
||||
|
||||
private static string Signature(PathD outline)
|
||||
{
|
||||
var bounds = Clipper.GetBounds(outline);
|
||||
var points = outline
|
||||
.Select(p => (System.Math.Round(p.x - bounds.left, 5), System.Math.Round(p.y - bounds.top, 5)))
|
||||
.OrderBy(p => p.Item1)
|
||||
.ThenBy(p => p.Item2)
|
||||
.Select(p => $"{p.Item1:R},{p.Item2:R}");
|
||||
return string.Join(";", points);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotations to try, all satisfying the part's policy. Automatic parts get the four
|
||||
/// right angles plus the two orientations that align their minimum-area bounding
|
||||
/// rectangle with the sheet axes.
|
||||
/// </summary>
|
||||
internal static List<double> CandidateAngles(RotationPolicy policy, Shape perimeter, int limit)
|
||||
{
|
||||
var raw = new List<double>();
|
||||
switch (policy.Kind)
|
||||
{
|
||||
case RotationPolicyKind.Fixed:
|
||||
raw.Add(policy.Start);
|
||||
if (policy.Allow180Equivalent)
|
||||
raw.Add(policy.Start + System.Math.PI);
|
||||
break;
|
||||
|
||||
case RotationPolicyKind.BoundedSweep:
|
||||
{
|
||||
var steps = (int)System.Math.Floor((policy.End - policy.Start) / policy.Step + 1e-9);
|
||||
var samples = System.Math.Min(steps + 1, policy.Allow180Equivalent ? System.Math.Max(1, limit / 2) : limit);
|
||||
for (var i = 0; i < samples; i++)
|
||||
{
|
||||
var k = samples == 1 ? 0 : (int)System.Math.Round(i * (double)steps / (samples - 1));
|
||||
raw.Add(policy.Start + k * policy.Step);
|
||||
if (policy.Allow180Equivalent)
|
||||
raw.Add(policy.Start + k * policy.Step + System.Math.PI);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
var rightAngles = new[] { 0, System.Math.PI / 2, System.Math.PI, System.Math.PI * 1.5 };
|
||||
var aligned = AlignedAngle(perimeter);
|
||||
raw.Add(0);
|
||||
raw.Add(System.Math.PI / 2);
|
||||
if (aligned is double a)
|
||||
{
|
||||
raw.Add(Normalize(a));
|
||||
raw.Add(Normalize(a + System.Math.PI / 2));
|
||||
}
|
||||
raw.Add(System.Math.PI);
|
||||
raw.Add(System.Math.PI * 1.5);
|
||||
if (aligned is double b)
|
||||
{
|
||||
raw.Add(Normalize(b + System.Math.PI));
|
||||
raw.Add(Normalize(b + System.Math.PI * 1.5));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<double>();
|
||||
foreach (var angle in raw)
|
||||
{
|
||||
if (!policy.Allows(angle))
|
||||
continue;
|
||||
if (result.Any(existing => SameTurn(existing, angle)))
|
||||
continue;
|
||||
result.Add(angle);
|
||||
if (result.Count >= limit)
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static double? AlignedAngle(Shape perimeter)
|
||||
{
|
||||
var polygon = perimeter.ToPolygonWithTolerance(ChordTolerance * 5);
|
||||
if (polygon.Vertices.Count < 3)
|
||||
return null;
|
||||
var mbr = RotatingCalipers.MinimumBoundingRectangle(polygon.Vertices);
|
||||
var angle = Normalize(-mbr.Angle) % (System.Math.PI / 2);
|
||||
// Already axis-aligned (within ~0.05°): the right angles cover it.
|
||||
if (angle < 1e-3 || System.Math.PI / 2 - angle < 1e-3)
|
||||
return null;
|
||||
return angle;
|
||||
}
|
||||
|
||||
private static double Normalize(double angle)
|
||||
{
|
||||
var value = angle % TwoPi;
|
||||
return value < 0 ? value + TwoPi : value;
|
||||
}
|
||||
|
||||
private static bool SameTurn(double a, double b)
|
||||
{
|
||||
var delta = System.Math.Abs(Normalize(a - b));
|
||||
return delta < 1e-9 || TwoPi - delta < 1e-9;
|
||||
}
|
||||
internal static List<double> CandidateAngles(RotationPolicy policy, Shape perimeter, int limit) =>
|
||||
RotationCandidates.ForShape(policy, perimeter, limit).ToList();
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ Every placement decision (which part, which rotation, where, on which sheet) com
|
||||
coarsened for arc-heavy parts until the outline is ≤ ~64 vertices, capped at 0.1% of part size).
|
||||
- Candidate rotations come from the part's `RotationPolicy`: for `Automatic`, the four right
|
||||
angles plus the two orientations that axis-align the minimum-area bounding rectangle
|
||||
(`RotatingCalipers`); for sweeps, up to 8 evenly spaced legal steps. Point-symmetric duplicates are dropped.
|
||||
(`RotatingCalipers`); for sweeps, the host policy grid truncated by the engine's orientation limit. Point-symmetric duplicates are dropped.
|
||||
- Each orientation gets a **footprint**: outline inflated (miter joins, so it contains the exact
|
||||
round offset) by `(spacing + 0.003) / 2 + chordTolerance`. Two parts respect the spacing
|
||||
when their footprints don't overlap. The 0.003 covers `NestValidator` flattening each arc
|
||||
within 0.001 of true (0.002 for a pair), plus Clipper's 1e-4 grid on both sides.
|
||||
round offset) by `spacing / 2 + NestTolerances.SafeClearanceMargin(chordTolerance) / 2`
|
||||
plus four Clipper grid units per footprint. The extra grid allowance preserves this
|
||||
engine's established contact points through repeated NFP Boolean operations; removing it
|
||||
increased mixed-job cost from 1586.94 to 1589.81 in the migration check.
|
||||
- **No-fit polygons** between footprints come from Clipper2 Minkowski sums: an O(n+m)
|
||||
edge merge for convex pairs, and for concave pairs the boundary sweep ∪ (A + p₀) ∪ (−B + a₀).
|
||||
The last two terms cover "B inside A" and "B swallows A". NFPs are cached per orientation pair.
|
||||
@@ -27,14 +28,14 @@ Every placement decision (which part, which rotation, where, on which sheet) com
|
||||
legal reference points: the inner-fit rectangle minus the NFPs of everything placed. Each
|
||||
placement subtracts one translated NFP from each region (in parallel, which stays deterministic).
|
||||
Regions only shrink, and an empty region is retired for the rest of the sheet.
|
||||
- At every step all remaining types × orientations compete (there is no fixed placement sequence):
|
||||
- At every step the lowest-number priority with a feasible placement wins; peer types × orientations compete (there is no fixed placement sequence):
|
||||
1. **Gap fill:** if any part fits without pushing the packing front forward, place the
|
||||
*largest* such part at its lowest point.
|
||||
2. **Advance:** otherwise place the part with the least front advance per `area^β`, i.e. the
|
||||
most material coverage for the sheet length it consumes.
|
||||
- The front sweeps along X or Y, which leaves one full-width offcut strip for salvage credit.
|
||||
|
||||
**3. Whole job (`Opus55NestingEngine`, `SheetEconomics`)**
|
||||
**3. Whole job (`Opus55NestingEngine`, `NestJobCost`)**
|
||||
- Sheet by sheet, every available stock size is trial-filled. The trial with the lowest
|
||||
*estimated whole-job cost* (its net area, plus the remaining demand priced at the best
|
||||
efficiency any trial achieved) is committed. This lets a sheet that finishes the job beat a
|
||||
@@ -55,8 +56,7 @@ Every placement decision (which part, which rotation, where, on which sheet) com
|
||||
| `FrontierPacker.cs` | One-sheet fill: free regions and the gap-fill/advance choice rule |
|
||||
| `NoFitCache.cs` | Spacing footprints and cached NFPs (Clipper2 Minkowski) |
|
||||
| `PartCatalog.cs` | Snapshot → perimeter polygon per allowed orientation |
|
||||
| `SheetEconomics.cs` | Net-area objective with salvage credit |
|
||||
| `tests/` | xUnit suite. Layouts are judged by `OpenNest.Benchmark.NestValidator` |
|
||||
| `tests/` | xUnit suite. Layouts are judged by `Engine.Testing.LayoutAssert` and `NestLayoutCheck` |
|
||||
|
||||
## Build / test
|
||||
|
||||
@@ -91,7 +91,17 @@ The engine reports as `Opus55NestingEngine`.
|
||||
distinct parts: `48 / partCount`, minimum 2). Free-angle rotations aren't explored beyond the MBR alignment.
|
||||
- **Greedy core:** there is no order/permutation search. The variants and tail re-plan are the only
|
||||
search, and density on small mixed jobs trails what an interlocking-pair filler can reach.
|
||||
- **`NestJobPart.Priority` is ignored**, and progress reports only `EvaluatingCandidate`
|
||||
per trial and `PlateCommitted` at the end, with no finer-grained progress.
|
||||
- **Priority is enforced during placement** (lower number first). Progress reports
|
||||
`EvaluatingCandidate` per trial and `PlateCommitted` at the end, with no finer-grained progress.
|
||||
- Parts whose geometry has no readable closed perimeter, or that fit no offered stock at any
|
||||
allowed rotation, are reported unplaced (`NoPlacementFound`) instead of failing the job.
|
||||
|
||||
## Shared services migration
|
||||
|
||||
`JobPartGeometry.TryRead` supplies the perimeter. `ForShape(policy, perimeter, limit)`
|
||||
retains the engine's orientation cap and `DistinctOutlines` drops perimeter symmetry.
|
||||
Stock `WorkArea`/`Fits`, host salvage scoring and `NestJobResultBuilder` replace copied
|
||||
plumbing. The frontier, NFP cache, variant work budget and tail improvement remain local.
|
||||
The shared contract suite exposed and now guards lower-number priority precedence.
|
||||
Every old engine-specific test remains. All five salvage benchmark costs and validity
|
||||
match baseline; see [PR 5 results](../MIGRATION-PR5.md).
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.Opus55;
|
||||
|
||||
/// <summary>
|
||||
/// The objective the engine optimizes: sheet area consumed, less the salvage credit for the
|
||||
/// single largest full-width or full-length edge offcut the job's options allow. Packing toward
|
||||
/// one edge (see <see cref="PackAxis"/>) is what makes that offcut large.
|
||||
/// </summary>
|
||||
internal static class SheetEconomics
|
||||
{
|
||||
public static double SheetArea(NestPlateStock stock) => stock.Size.Width * stock.Size.Length;
|
||||
|
||||
public static double NetArea(NestJobOptions options, SheetFill fill)
|
||||
{
|
||||
var area = SheetArea(fill.Stock);
|
||||
var minimum = options.MinimumSalvageDimension;
|
||||
if (options.SalvageRate <= 0 || minimum <= 0 || fill.Parts.Count == 0)
|
||||
return area;
|
||||
|
||||
var work = FrontierPacker.WorkArea(fill.Stock);
|
||||
var gap = fill.Stock.PartSpacing;
|
||||
var left = fill.Parts.Min(p => p.Left);
|
||||
var right = fill.Parts.Max(p => p.Right);
|
||||
var bottom = fill.Parts.Min(p => p.Bottom);
|
||||
var top = fill.Parts.Max(p => p.Top);
|
||||
var offcuts = new[]
|
||||
{
|
||||
// Box.Length is the X extent, Box.Width the Y extent.
|
||||
(work.Length, bottom - work.Bottom - gap),
|
||||
(work.Length, work.Top - top - gap),
|
||||
(left - work.Left - gap, work.Width),
|
||||
(work.Right - right - gap, work.Width),
|
||||
};
|
||||
var salvage = 0.0;
|
||||
foreach (var (a, b) in offcuts)
|
||||
if (a >= minimum && b >= minimum)
|
||||
salvage = System.Math.Max(salvage, a * b);
|
||||
return area - options.SalvageRate * salvage;
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<ProjectReference Include="../../Engine.Testing/OpenNest.Engine.Testing.csproj" />
|
||||
<ProjectReference Include="../OpenNest.Engine.Opus55.csproj" />
|
||||
<!-- The benchmark's NestValidator is the arbiter the engine is scored by. -->
|
||||
<ProjectReference Include="$(OpenNestRoot)OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using OpenNest.Engine.Testing;
|
||||
using static OpenNest.Engine.Testing.JobBuilder;
|
||||
using static OpenNest.Engine.Testing.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
@@ -18,7 +20,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(12, result.Plates[0].Placements.Count);
|
||||
@@ -44,7 +46,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
@@ -55,7 +57,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
@@ -70,7 +72,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
@@ -84,7 +86,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal("small", Assert.Single(result.Plates).StockId);
|
||||
}
|
||||
@@ -96,7 +98,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.True(result.Plates.Count > 1);
|
||||
Assert.Equal(25, result.Plates.Sum(p => p.Placements.Count));
|
||||
@@ -120,7 +122,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
foreach (var placement in result.Plates.SelectMany(p => p.Placements))
|
||||
{
|
||||
var policy = placement.PartId == "fixed" ? fixedPolicy : sweep;
|
||||
@@ -138,7 +140,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
|
||||
@@ -152,7 +154,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
|
||||
Assert.Equal(2, result.Plates.Count);
|
||||
var usage = Assert.Single(result.StockUsage);
|
||||
@@ -171,7 +173,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
|
||||
}
|
||||
@@ -188,7 +190,7 @@ public class Opus55NestingEngineTests
|
||||
var first = new Opus55NestingEngine().Solve(Build());
|
||||
var second = new Opus55NestingEngine().Solve(Build());
|
||||
|
||||
Assert.Equal(Describe(first), Describe(second));
|
||||
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(first), System.Text.Json.JsonSerializer.Serialize(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -204,7 +206,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
@@ -216,86 +218,6 @@ public class Opus55NestingEngineTests
|
||||
Assert.IsAssignableFrom<INestingEngine>(engine);
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------------------------
|
||||
|
||||
private static string Describe(NestJobResult result) =>
|
||||
string.Join(
|
||||
"|",
|
||||
result.Plates.Select(p =>
|
||||
p.StockId + ":" + string.Join(",", p.Placements.Select(x => $"{x.PartId}#{x.InstanceIndex}@{x.X:R},{x.Y:R},{x.Rotation:R}"))
|
||||
)
|
||||
);
|
||||
|
||||
private static void AssertValid(NestJob job, NestJobResult result)
|
||||
{
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
var runs = materialized.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList())).ToList();
|
||||
var requirements = job.Parts.ToDictionary<NestJobPart, Drawing, (string Name, int Quantity)>(
|
||||
p => materialized.DrawingsByPartId[p.Id],
|
||||
p => (p.Id, p.Quantity),
|
||||
ReferenceEqualityComparer.Instance
|
||||
);
|
||||
var validation = NestValidator.Validate(runs, requirements);
|
||||
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
|
||||
Assert.True(validation.Valid, string.Join(Environment.NewLine, validation.Violations));
|
||||
|
||||
foreach (var f in result.Fulfillment)
|
||||
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
|
||||
}
|
||||
|
||||
private static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
|
||||
new(parts, stock, options);
|
||||
|
||||
private static NestJobPart Part(string id, Program program, int quantity, RotationPolicy? rotation = null) =>
|
||||
new(id, PartGeometrySnapshot.FromProgram(program), quantity, 0, rotation);
|
||||
|
||||
/// <param name="width">Y extent.</param>
|
||||
/// <param name="length">X extent.</param>
|
||||
private static NestPlateStock Stock(
|
||||
string id,
|
||||
double width,
|
||||
double length,
|
||||
double spacing = 0,
|
||||
Spacing edge = default,
|
||||
int quadrant = 1,
|
||||
int? quantity = null
|
||||
) => new(id, new Size(width, length), quantity, spacing, edge, quadrant);
|
||||
|
||||
private static Program Polyline(params (double X, double Y)[] points)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
|
||||
foreach (var (x, y) in points.Skip(1))
|
||||
program.Codes.Add(new LinearMove(x, y));
|
||||
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
|
||||
return program;
|
||||
}
|
||||
|
||||
private static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
|
||||
|
||||
private static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
|
||||
|
||||
private static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
|
||||
|
||||
private static Program Disc(double r)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(r, 0));
|
||||
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
|
||||
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
|
||||
return program;
|
||||
}
|
||||
|
||||
/// <summary>Stadium: two semicircular ends joined by straight sides, offset from the origin.</summary>
|
||||
private static Program Obround(double length, double width)
|
||||
{
|
||||
var r = width / 2;
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(1 + r, 1));
|
||||
program.Codes.Add(new LinearMove(1 + length - r, 1));
|
||||
program.Codes.Add(new ArcMove(1 + length - r, 1 + width, 1 + length - r, 1 + r, RotationType.CCW));
|
||||
program.Codes.Add(new LinearMove(1 + r, 1 + width));
|
||||
program.Codes.Add(new ArcMove(1 + r, 1, 1 + r, 1 + r, RotationType.CCW));
|
||||
return program;
|
||||
}
|
||||
}
|
||||
public sealed class Opus55ContractTests : EngineContractTests<Opus55NestingEngine> { }
|
||||
|
||||
Reference in New Issue
Block a user