Add tested caller-stock StockLadder baseline with strict geometry validation

This commit is contained in:
aj
2026-09-19 11:24:36 -04:00
parent 9b69c67572
commit ea4bd836cd
11 changed files with 759 additions and 11 deletions
+14 -1
View File
@@ -5,14 +5,27 @@ namespace OpenNest;
/// <summary>Immutable per-job options; selection never changes the legacy global registry.</summary>
public sealed class NestJobOptions
{
public NestJobOptions(string placementStrategy = "Default", int? maxPlates = null)
public NestJobOptions(string placementStrategy = "Default", int? maxPlates = null,
double salvageRate = 0, double minimumSalvageDimension = 0)
{
ArgumentException.ThrowIfNullOrWhiteSpace(placementStrategy);
if (maxPlates <= 0) throw new ArgumentOutOfRangeException(nameof(maxPlates));
if (!double.IsFinite(salvageRate) || salvageRate < 0 || salvageRate > 1)
throw new ArgumentOutOfRangeException(nameof(salvageRate));
if (!double.IsFinite(minimumSalvageDimension) || minimumSalvageDimension < 0)
throw new ArgumentOutOfRangeException(nameof(minimumSalvageDimension));
PlacementStrategy = placementStrategy;
MaxPlates = maxPlates;
SalvageRate = salvageRate;
MinimumSalvageDimension = minimumSalvageDimension;
}
/// <summary>Fraction of eligible edge-offcut area credited by StockLadder (0..1).</summary>
public double SalvageRate { get; }
/// <summary>Both offcut dimensions must meet this caller-supplied minimum in job units.
/// Zero disables credit; scraps and holes are never credited.</summary>
public double MinimumSalvageDimension { get; }
public string PlacementStrategy { get; }
/// <summary>Maximum physical sheets to commit, or null for no explicit cap.</summary>
public int? MaxPlates { get; }
@@ -76,14 +76,131 @@ internal static class NestJobPlacementValidator
var contours = ShapeBuilder.GetShapes(cutEntities);
if (contours.Count == 0) throw new ArgumentException("Geometry must contain a closed contour.");
var closedEntities = new List<Entity>();
var marks = new List<Shape>();
foreach (var contour in contours)
ValidateContour(contour);
{
if (contour.IsClosed())
{
ValidateContour(contour);
closedEntities.AddRange(contour.Entities);
}
else marks.Add(contour);
}
if (closedEntities.Count == 0)
throw new ArgumentException("Geometry must contain a closed outer contour.");
var profile = new ShapeProfile(cutEntities);
// ShapeProfile selects the outer profile, but does not validate containment and
// treats open chains as cutouts. Only validated closed contours may define material.
var profile = new ShapeProfile(closedEntities);
foreach (var cutout in profile.Cutouts)
ValidateInternalChain(cutout, profile.Perimeter, new List<Shape>());
foreach (var mark in marks)
ValidateMark(mark, profile.Perimeter, profile.Cutouts);
profile.NormalizeWinding();
return new ShapeTopology(profile.Perimeter, profile.Cutouts);
}
private static void ValidateMark(Shape mark, Shape perimeter, List<Shape> holes)
{
const double chordTolerance = 0.00001;
var boundaries = new List<Shape> { perimeter };
boundaries.AddRange(holes);
var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance));
foreach (var entity in mark.Entities)
{
if (entity.Length <= Epsilon || entity is not (Line or Arc))
throw new ArgumentException("Unsupported or degenerate internal mark.");
var parameters = new List<double> { 0, 1 };
foreach (var boundary in boundaries)
{
entity.Intersects(boundary, out var intersections);
foreach (var point in intersections)
AddParameter(point);
// Include endpoints of coincident edges (parallel intersections may be empty).
foreach (var point in boundary.Entities.CollectPoints())
if (entity.ClosestPointTo(point).DistanceTo(point) <= Epsilon)
AddParameter(point);
}
parameters.Sort();
for (var index = 0; index < parameters.Count; index++)
{
Check(PointAt(parameters[index]));
if (index > 0) Check(PointAt((parameters[index - 1] + parameters[index]) / 2));
}
void AddParameter(Vector point)
{
if (!point.IsValid()) throw new ArgumentException("Indeterminate mark intersection.");
var value = entity is Line line
? line.StartPoint.DistanceTo(point) / line.Length
: Angle.NormalizeRad(((Arc)entity).IsReversed
? ((Arc)entity).StartAngle - ((Arc)entity).Center.AngleTo(point)
: ((Arc)entity).Center.AngleTo(point) - ((Arc)entity).StartAngle) / ((Arc)entity).SweepAngle();
if (value >= 0 && value <= 1) parameters.Add(value);
}
Vector PointAt(double value)
{
if (entity is Line line) return line.StartPoint + (line.EndPoint - line.StartPoint) * value;
var arc = (Arc)entity;
var angle = arc.StartAngle + (arc.IsReversed ? -1 : 1) * arc.SweepAngle() * value;
return arc.Center + new Vector(System.Math.Cos(angle), System.Math.Sin(angle)) * arc.Radius;
}
void Check(Vector point)
{
for (var index = 0; index < boundaries.Count; index++)
{
// Exact analytic boundary contact is allowed; near-boundary uncertainty is not.
var onBoundary = false;
foreach (var edge in boundaries[index].Entities)
if (edge.ClosestPointTo(point).DistanceTo(point) <= Epsilon) onBoundary = true;
if (onBoundary) continue;
foreach (var edge in polygons[index].ToLines())
if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance)
throw new ArgumentException("Internal mark is too close to a material boundary.");
var inside = StrictlyInside(polygons[index], point);
if (index == 0 ? !inside : inside)
throw new ArgumentException("Open geometry leaves the closed material region.");
}
}
}
}
private static void ValidateInternalChain(Shape chain, Shape perimeter, List<Shape> holes)
{
// A connected analytic entity cannot leave material without crossing its boundary.
// Reject contact too: conservative, rather than guessing at tangent/collinear cuts.
// The witness point is farther than the polygonization error from every boundary.
const double chordTolerance = 0.00001;
var boundaries = new List<Shape> { perimeter };
boundaries.AddRange(holes);
var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance));
foreach (var entity in chain.Entities)
{
if (entity.Length <= Epsilon)
throw new ArgumentException("Geometry contains a zero-length internal edge.");
var point = entity switch
{
Line line => line.StartPoint,
Arc arc => arc.StartPoint(),
Circle circle => circle.Center.Offset(circle.Radius, 0),
_ => throw new ArgumentException("Unsupported internal geometry.")
};
if (!StrictlyInside(polygons[0], point))
throw new ArgumentException("Open or disconnected geometry lies outside the closed perimeter.");
for (var index = 0; index < boundaries.Count; index++)
{
if (index > 0 && polygons[index].ContainsPoint(point))
throw new ArgumentException("Internal geometry lies in a cutout.");
foreach (var edge in polygons[index].ToLines())
if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance)
throw new ArgumentException("Internal geometry is too close to a material boundary.");
if (entity.Intersects(boundaries[index]))
throw new ArgumentException("Internal geometry crosses or touches a material boundary.");
}
}
}
private static void ValidateContour(Shape contour)
{
if (!contour.IsClosed())
+1 -1
View File
@@ -31,7 +31,7 @@ public static class NestJobValidator
}
catch (ArgumentException exception)
{
throw new ArgumentException($"Geometry must contain usable closed edges: {part.Id}.", nameof(job), exception);
throw new ArgumentException($"Geometry must contain usable closed edges: {part.Id}. {exception.Message}", nameof(job), exception);
}
}
}
@@ -20,6 +20,9 @@ public static class NestingEngineRegistry
static NestingEngineRegistry()
{
Register("StockLadder", "Caller-stock constrained-first fill and equivalent-demand area repacking",
() => new StockLadderNestingEngine());
Register("Default", "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)",
() => new FixedStrategyNestingEngine("Default"));
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest;
/// <summary>Constrained-order linear fills in conservative rectangular free regions.
/// Regions are only search hints; every accepted pose passes the job geometry validator.</summary>
internal sealed class OrderedPlateNester : IPlateNester
{
private readonly Dictionary<string, Drawing> drawings = new(StringComparer.Ordinal);
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
var work = DrawingJobMapper.CreatePlate(request.Stock).WorkArea();
var poses = new List<NestJobPlacement>();
var obstacles = new List<Box>();
var requirements = request.Parts.ToDictionary(p => p.Id);
var demand = request.Parts.ToDictionary(p => p.Id, p => p.Quantity);
foreach (var requirement in request.Parts)
{
token.ThrowIfCancellationRequested();
if (!drawings.TryGetValue(requirement.Id, out var drawing))
drawings.Add(requirement.Id, drawing = DrawingJobMapper.CreateDrawing(requirement));
var left = requirement.Quantity;
while (left > 0)
{
var regions = new RemnantFinder(work, obstacles).FindRemnants();
List<Part> best = null;
foreach (var region in regions)
{
foreach (var angle in Angles(requirement.Rotation))
{
token.ThrowIfCancellationRequested();
// FillLinear uses actual line/arc geometry for copy distances.
var parts = new FillLinear(region, request.Stock.PartSpacing)
.Fill(drawing, angle, NestDirection.Horizontal).Take(left).ToList();
if (parts.Count == 0 || (best != null && parts.Count <= best.Count)) continue;
var trial = poses.Concat(parts.Select(p => new NestJobPlacement(requirement.Id, 0,
p.Location.X, p.Location.Y, p.Rotation))).ToList();
try
{
NestJobValidator.ValidateCandidate(new PlateCandidate(trial), request.Stock, demand, requirements);
best = parts;
}
catch (InvalidOperationException)
{
// Geometry kernels are proposal generators, never the acceptance gate.
}
if (best?.Count == left) break;
}
if (best?.Count == left) break;
}
if (best == null) break;
foreach (var part in best)
{
poses.Add(new NestJobPlacement(requirement.Id, 0, part.Location.X, part.Location.Y, part.Rotation));
obstacles.Add(part.BoundingBox.Offset(request.Stock.PartSpacing));
}
left -= best.Count;
}
}
token.ThrowIfCancellationRequested();
return new PlateCandidate(poses);
}
private static IEnumerable<double> Angles(RotationPolicy policy)
{
if (policy.Kind == RotationPolicyKind.Fixed)
{
yield return policy.Start;
yield break;
}
// A bounded deterministic search, not a proof that an unplaced part cannot fit.
if (policy.Kind == RotationPolicyKind.Automatic)
{
yield return 0;
yield return System.Math.PI / 2;
yield return System.Math.PI;
yield return 3 * System.Math.PI / 2;
for (var degrees = 5; degrees < 180; degrees += 5)
if (degrees != 90) yield return degrees * System.Math.PI / 180;
yield break;
}
for (var index = 0L; ; index++)
{
var angle = policy.Start + index * policy.Step;
if (angle > policy.End + 1e-9) yield break;
yield return angle;
}
}
}
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace OpenNest;
/// <summary>
/// Caller-stock-only allocation followed by bounded adjacent-sheet repacking. All replacements
/// must reproduce exactly the removed demand and reduce net sheet area; inventory is transactional.
/// This is a deterministic heuristic, not an optimality or geometric impossibility proof.
/// </summary>
public sealed class StockLadderNestingEngine : INestingEngine
{
private readonly Func<IPlateNester> factory;
public StockLadderNestingEngine() : this(() => new OrderedPlateNester()) { }
public StockLadderNestingEngine(Func<IPlateNester> factory) =>
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
NestJobValidator.Validate(job);
var nester = factory() ?? throw new InvalidOperationException("Null plate nester.");
var parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal);
var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
var areas = job.Parts.ToDictionary(p => p.Id, p => DrawingJobMapper.CreateDrawing(p).Area);
var sheets = new List<NestJobPlateResult>();
var feasible = job.Parts.ToDictionary(p => p.Id, _ => new HashSet<string>());
// Probe actual validated single-part placements, not bounding-box fit assertions.
foreach (var part in job.Parts)
foreach (var stock in job.Plates.Where(s => s.Quantity != 0))
{
var probe = Trial(stock, new[] { WithQuantity(part, 1) });
if (probe.Placements.Count != 0) feasible[part.Id].Add(stock.Id);
}
var ordered = job.Parts.OrderBy(p => p.Priority)
.ThenBy(p => feasible[p.Id].Count).ThenByDescending(p => areas[p.Id]).ToList();
var reason = NestJobStopReason.Completed;
while (remaining.Values.Any(n => n > 0))
{
token.ThrowIfCancellationRequested();
if (job.Options.MaxPlates <= sheets.Count)
{
if (Consolidate()) continue;
reason = NestJobStopReason.PlateLimitReached;
break;
}
var available = job.Plates.Where(s => s.Quantity == null || used[s.Id] < s.Quantity).ToList();
if (available.Count == 0)
{
if (Consolidate()) continue;
reason = NestJobStopReason.StockExhausted;
break;
}
var anchor = ordered.FirstOrDefault(p => remaining[p.Id] > 0 &&
available.Any(s => feasible[p.Id].Contains(s.Id)));
if (anchor == null)
{
reason = NestJobStopReason.NoPlacementFound;
break;
}
NestJobPlateResult winner = null;
var score = double.PositiveInfinity;
foreach (var stock in available.Where(s => feasible[anchor.Id].Contains(s.Id)))
{
// Pin the constrained anchor before fillers, including quantity-one requirements.
var requests = new[] { anchor }.Concat(ordered.Where(p => p.Id != anchor.Id))
.Where(p => remaining[p.Id] > 0).Select(p => WithQuantity(p, remaining[p.Id]));
var candidate = Trial(stock, requests);
if (!candidate.Placements.Any(p => p.PartId == anchor.Id)) continue;
var sheet = new NestJobPlateResult(sheets.Count, stock, candidate.Placements);
// Initial construction only: material area, never raw part counts. Repacking below
// compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill.
var value = EstimateNetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]);
if (value < score - 1e-9)
{
winner = sheet;
score = value;
}
}
if (winner == null)
{
reason = NestJobStopReason.NoPlacementFound;
break;
}
sheets.Add(winner);
used[winner.StockId]++;
foreach (var pose in winner.Placements) remaining[pose.PartId]--;
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, winner.StockId,
sheets.Count - 1, sheets.Count, sheets.Sum(s => s.Placements.Count)));
}
Consolidate();
token.ThrowIfCancellationRequested();
var placed = job.Parts.ToDictionary(p => p.Id, _ => 0);
var final = sheets.Select((sheet, index) => new NestJobPlateResult(index, sheet.Stock,
sheet.Placements.Select(p => p with { InstanceIndex = placed[p.PartId]++ }).ToList())).ToList();
return new NestJobResult(reason == NestJobStopReason.Completed ? NestJobStatus.Complete : NestJobStatus.Incomplete,
reason, final, job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, placed[p.Id], remaining[p.Id])),
job.Plates.Select(s => new StockUsage(s.Id, used[s.Id], s.Quantity - used[s.Id])));
PlateCandidate Trial(NestPlateStock stock, IEnumerable<NestJobPart> requirements)
{
token.ThrowIfCancellationRequested();
var request = new PlatePlacementRequest(stock, requirements);
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id,
sheets.Count, sheets.Count, sheets.Sum(s => s.Placements.Count)));
var candidate = nester.Place(request, null, token);
token.ThrowIfCancellationRequested();
NestJobValidator.ValidateCandidate(candidate, stock, request.Parts.ToDictionary(p => p.Id, p => p.Quantity), parts);
return candidate;
}
bool Consolidate()
{
var changed = false;
// Single downgrade and adjacent pair merge only: bounded local search, no combinatorial tree.
for (var index = 0; index < sheets.Count; index++)
for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--)
{
var old = sheets.Skip(index).Take(count).ToList();
var demand = old.SelectMany(s => s.Placements).GroupBy(p => p.PartId)
.ToDictionary(g => g.Key, g => g.Count());
var baseline = old.Sum(s => EstimateNetArea(job, s));
NestJobPlateResult replacement = null;
foreach (var stock in job.Plates)
{
token.ThrowIfCancellationRequested();
var returned = old.Count(s => s.StockId == stock.Id);
if (stock.Quantity is int limit && used[stock.Id] - returned >= limit) continue;
// Even the maximum possible salvage credit cannot beat the incumbent.
var lowerBound = stock.Size.Width * stock.Size.Length * (1 - job.Options.SalvageRate);
if (lowerBound >= baseline - 1e-9) continue;
if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id))) continue;
var candidate = Trial(stock, ordered.Where(p => demand.ContainsKey(p.Id))
.Select(p => WithQuantity(p, demand[p.Id])));
var actual = candidate.Placements.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count());
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value)) continue;
var trial = new NestJobPlateResult(index, stock, candidate.Placements);
var cost = EstimateNetArea(job, trial);
if (cost >= baseline - 1e-9) continue;
baseline = cost;
replacement = trial;
}
if (replacement == null) continue;
// No accounting changes until the entire equivalent-demand candidate is valid.
foreach (var sheet in old) used[sheet.StockId]--;
used[replacement.StockId]++;
sheets.RemoveRange(index, count);
sheets.Insert(index, replacement);
changed = true;
}
return changed;
}
}
private static NestJobPart WithQuantity(NestJobPart part, int quantity) =>
new(part.Id, part.Geometry, quantity, part.Priority, part.Rotation);
/// <summary>Full physical sheet area minus a conservative offcut estimate. Credits only ONE
/// empty full-span edge rectangle outside every placed bounding box plus part clearance, within
/// the usable work area, and meeting the caller's minimum in both dimensions. Not a certified
/// remnant: no cut-off toolpath, kerf, handling, or future-demand valuation is modelled.</summary>
public static double EstimateNetArea(NestJob job, NestJobPlateResult sheet)
{
var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length;
var minimum = job.Options.MinimumSalvageDimension;
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0) return area;
var work = DrawingJobMapper.CreatePlate(sheet.Stock).WorkArea();
var parts = job.Parts.ToDictionary(p => p.Id);
var boxes = sheet.Placements.Select(p =>
{
var part = new Part(DrawingJobMapper.CreateDrawing(parts[p.PartId]));
part.Rotate(p.Rotation);
part.Location = new OpenNest.Geometry.Vector(p.X, p.Y);
part.UpdateBounds();
return part.BoundingBox;
}).ToList();
var gap = sheet.Stock.PartSpacing;
var candidates = new[]
{
(work.Length, boxes.Min(b => b.Bottom) - work.Bottom - gap),
(work.Length, work.Top - boxes.Max(b => b.Top) - gap),
(boxes.Min(b => b.Left) - work.Left - gap, work.Width),
(work.Right - boxes.Max(b => b.Right) - gap, work.Width)
};
var salvage = candidates.Where(c => c.Item1 >= minimum && c.Item2 >= minimum)
.Select(c => c.Item1 * c.Item2).DefaultIfEmpty(0).Max();
return area - job.Options.SalvageRate * salvage;
}
}