feat(engine): execute inventory-bounded multi-plate jobs
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Explicit-ID input mapping and exact supported-geometry reconstruction. Never retains caller objects.</summary>
|
||||
public static class DrawingJobMapper
|
||||
{
|
||||
public static NestJobPart FromDrawing(string partId, Drawing drawing, int quantity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drawing);
|
||||
var constraints = drawing.Constraints;
|
||||
return new NestJobPart(partId, PartGeometrySnapshot.FromProgram(drawing.Program), quantity, drawing.Priority,
|
||||
constraints == null ? RotationPolicy.Automatic :
|
||||
RotationPolicy.FromLegacy(constraints.StepAngle, constraints.StartAngle, constraints.EndAngle));
|
||||
}
|
||||
|
||||
public static NestJobPart FromItem(string partId, NestItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ArgumentNullException.ThrowIfNull(item.Drawing);
|
||||
return new NestJobPart(partId, PartGeometrySnapshot.FromProgram(item.Drawing.Program), item.Quantity,
|
||||
item.Priority, RotationPolicy.FromLegacy(item.StepAngle, item.RotationStart, item.RotationEnd));
|
||||
}
|
||||
|
||||
/// <summary>Available stock is explicit; the legacy plate repeat count is not inventory.</summary>
|
||||
public static NestPlateStock FromPlate(string stockId, Plate plate, int? quantity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
return new NestPlateStock(stockId, plate.Size, quantity, plate.PartSpacing, plate.EdgeSpacing, plate.Quadrant);
|
||||
}
|
||||
|
||||
public static Program ToProgram(PartGeometrySnapshot geometry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(geometry);
|
||||
var program = new Program(geometry.Mode);
|
||||
foreach (var motion in geometry.Motions)
|
||||
{
|
||||
var code = motion.Type switch
|
||||
{
|
||||
CodeType.RapidMove => (Motion)new RapidMove(motion.X, motion.Y),
|
||||
CodeType.LinearMove => new LinearMove(motion.X, motion.Y) { Layer = motion.Layer },
|
||||
CodeType.ArcMove => new ArcMove(motion.X, motion.Y, motion.CenterX, motion.CenterY, motion.Rotation)
|
||||
{ Layer = motion.Layer },
|
||||
_ => throw new NotSupportedException("Unsupported snapshot motion.")
|
||||
};
|
||||
code.Suppressed = motion.Suppressed;
|
||||
program.Codes.Add(code);
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
internal static Drawing CreateDrawing(NestJobPart part)
|
||||
{
|
||||
var drawing = new Drawing(part.Id, ToProgram(part.Geometry)) { Priority = part.Priority };
|
||||
drawing.Quantity.Required = part.Quantity;
|
||||
drawing.Constraints = new NestConstraints
|
||||
{
|
||||
StepAngle = LegacyStep(part.Rotation),
|
||||
StartAngle = part.Rotation.Start,
|
||||
EndAngle = part.Rotation.End
|
||||
};
|
||||
return drawing;
|
||||
}
|
||||
|
||||
// A fixed angle needs a nonzero legacy step so it is not misread as automatic.
|
||||
internal static double LegacyStep(RotationPolicy policy) => policy.Kind == RotationPolicyKind.Fixed
|
||||
? OpenNest.Math.Angle.TwoPI : policy.Step;
|
||||
|
||||
internal static Plate CreatePlate(NestPlateStock stock) => new(stock.Size)
|
||||
{
|
||||
Quantity = 1,
|
||||
PartSpacing = stock.PartSpacing,
|
||||
EdgeSpacing = stock.EdgeSpacing,
|
||||
Quadrant = stock.Quadrant
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// A fresh private legacy plate/drawing/item graph for each call. Only returned poses cross the boundary;
|
||||
/// legacy quantity mutations are deliberately ignored. Does not certify geometric safety or rotation compliance.
|
||||
/// </summary>
|
||||
public sealed class LegacyPlateNesterAdapter : IPlateNester
|
||||
{
|
||||
private readonly Func<Plate, NestEngineBase> engineFactory;
|
||||
|
||||
public LegacyPlateNesterAdapter(Func<Plate, NestEngineBase> engineFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(engineFactory);
|
||||
this.engineFactory = engineFactory;
|
||||
}
|
||||
|
||||
/// <summary>Minimal built-in selection; never reads or changes NestEngineRegistry.</summary>
|
||||
public static IPlateNester Create(string strategy) => strategy == "Default"
|
||||
? new LegacyPlateNesterAdapter(plate => new DefaultNestEngine(plate))
|
||||
: throw new NotSupportedException($"Unknown placement strategy: {strategy}.");
|
||||
|
||||
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
token.ThrowIfCancellationRequested();
|
||||
var plate = DrawingJobMapper.CreatePlate(request.Stock);
|
||||
var items = new List<NestItem>();
|
||||
var identities = new Dictionary<Drawing, string>(ReferenceEqualityComparer.Instance);
|
||||
foreach (var requirement in request.Parts)
|
||||
{
|
||||
var drawing = DrawingJobMapper.CreateDrawing(requirement);
|
||||
identities.Add(drawing, requirement.Id);
|
||||
items.Add(new NestItem
|
||||
{
|
||||
Drawing = drawing,
|
||||
Quantity = requirement.Quantity,
|
||||
Priority = requirement.Priority,
|
||||
StepAngle = DrawingJobMapper.LegacyStep(requirement.Rotation),
|
||||
RotationStart = requirement.Rotation.Start,
|
||||
RotationEnd = requirement.Rotation.End
|
||||
});
|
||||
}
|
||||
var engine = engineFactory(plate) ?? throw new InvalidOperationException("Legacy engine factory returned null.");
|
||||
var parts = engine.Nest(items, null, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (parts == null) throw new InvalidOperationException("Legacy engine returned null placements.");
|
||||
var placements = new List<NestJobPlacement>();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part?.BaseDrawing == null || !identities.TryGetValue(part.BaseDrawing, out var id))
|
||||
throw new InvalidOperationException("Legacy placement does not reference a private requirement drawing.");
|
||||
placements.Add(new NestJobPlacement(id, 0, part.Location.X, part.Location.Y, part.Rotation));
|
||||
}
|
||||
return new PlateCandidate(placements);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>A detached mutable domain nest plus explicit requirement identity (never inferred from names).</summary>
|
||||
public sealed class MaterializedNestResult
|
||||
{
|
||||
internal MaterializedNestResult(Nest nest, Dictionary<string, Drawing> drawings)
|
||||
{
|
||||
Nest = nest;
|
||||
DrawingsByPartId = new ReadOnlyDictionary<string, Drawing>(drawings);
|
||||
}
|
||||
|
||||
public Nest Nest { get; }
|
||||
public IReadOnlyDictionary<string, Drawing> DrawingsByPartId { get; }
|
||||
}
|
||||
|
||||
/// <summary>Materializes a result from the same job. Geometry safety remains the solver's future validation boundary.</summary>
|
||||
public static class NestResultMaterializer
|
||||
{
|
||||
public static MaterializedNestResult Materialize(NestJob job, NestJobResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
var nest = new Nest();
|
||||
var drawings = job.Parts.ToDictionary(p => p.Id, DrawingJobMapper.CreateDrawing, StringComparer.Ordinal);
|
||||
foreach (var drawing in drawings.Values) nest.Drawings.Add(drawing);
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
var plate = DrawingJobMapper.CreatePlate(sheet.Stock);
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
if (!drawings.TryGetValue(pose.PartId, out var drawing))
|
||||
throw new ArgumentException("Result contains a requirement not present in the job.", nameof(result));
|
||||
// Do not use CreateAtOrigin: it normalizes bounds and would change the snapshot frame.
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(pose.Rotation);
|
||||
part.Location = new Vector(pose.X, pose.Y);
|
||||
part.UpdateBounds();
|
||||
// Quantity=1 is set before the only attachment; Plate's event owns Nested accounting.
|
||||
plate.Parts.Add(part);
|
||||
}
|
||||
nest.Plates.Add(plate);
|
||||
}
|
||||
return new MaterializedNestResult(nest, drawings);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Contract-stage runner: empty jobs only. Nonempty allocation is not implemented yet.</summary>
|
||||
/// <summary>
|
||||
/// Single-stock physical-sheet allocation. Candidate accounting is validated before commit;
|
||||
/// full geometry, clearance, and rotation-policy validation is not implemented yet.
|
||||
/// </summary>
|
||||
public sealed class NestJobRunner : INestingEngine
|
||||
{
|
||||
private readonly Func<string, IPlateNester> plateNesterFactory;
|
||||
|
||||
/// <summary>Stores a runner-local strategy factory; never consults the global engine registry.</summary>
|
||||
/// <summary>Runner-local strategy resolution. A factory must reject unknown keys or return null.</summary>
|
||||
public NestJobRunner(Func<string, IPlateNester> plateNesterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plateNesterFactory);
|
||||
@@ -21,10 +25,58 @@ public sealed class NestJobRunner : INestingEngine
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
NestJobValidator.Validate(job);
|
||||
var plates = new List<NestJobPlateResult>();
|
||||
var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal);
|
||||
var placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal);
|
||||
var reason = NestJobStopReason.Completed;
|
||||
if (job.Parts.Count != 0)
|
||||
throw new NotSupportedException("Whole-job allocation is not implemented yet; only empty jobs are supported.");
|
||||
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||
Array.Empty<NestJobPlateResult>(), Array.Empty<PartFulfillment>(),
|
||||
job.Plates.Select(stock => new StockUsage(stock.Id, 0, stock.Quantity)));
|
||||
{
|
||||
var nester = plateNesterFactory(job.Options.PlacementStrategy) ??
|
||||
throw new NotSupportedException($"Unknown placement strategy: {job.Options.PlacementStrategy}.");
|
||||
var stock = job.Plates.SingleOrDefault();
|
||||
while (remaining.Values.Any(count => count > 0))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (stock == null || stock.Quantity <= plates.Count)
|
||||
{
|
||||
reason = NestJobStopReason.StockExhausted;
|
||||
break;
|
||||
}
|
||||
if (job.Options.MaxPlates <= plates.Count)
|
||||
{
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
}
|
||||
var request = new PlatePlacementRequest(stock, job.Parts.Where(p => remaining[p.Id] > 0)
|
||||
.Select(p => new NestJobPart(p.Id, p.Geometry, remaining[p.Id], p.Priority, p.Rotation)));
|
||||
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id,
|
||||
plates.Count, plates.Count, placed.Values.Sum()));
|
||||
token.ThrowIfCancellationRequested();
|
||||
// Do not forward legacy/candidate progress as committed production.
|
||||
var candidate = nester.Place(request, token: token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
NestJobValidator.ValidateCandidate(candidate, remaining);
|
||||
if (candidate.Placements.Count == 0)
|
||||
{
|
||||
reason = NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
var committed = new List<NestJobPlacement>();
|
||||
foreach (var pose in candidate.Placements)
|
||||
{
|
||||
committed.Add(pose with { InstanceIndex = placed[pose.PartId]++ });
|
||||
remaining[pose.PartId]--;
|
||||
}
|
||||
plates.Add(new NestJobPlateResult(plates.Count, stock, committed));
|
||||
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, stock.Id,
|
||||
plates.Count - 1, plates.Count, placed.Values.Sum()));
|
||||
}
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return new NestJobResult(reason == NestJobStopReason.Completed ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||
reason, plates, job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, placed[p.Id], remaining[p.Id])),
|
||||
job.Plates.Select(stock => new StockUsage(stock.Id, plates.Count(p => p.StockId == stock.Id),
|
||||
stock.Quantity - plates.Count(p => p.StockId == stock.Id))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Basic input and candidate accounting checks, NOT a geometry/clearance safety gate.</summary>
|
||||
public static class NestJobValidator
|
||||
{
|
||||
public static void Validate(NestJob job)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
var edges = stock.EdgeSpacing;
|
||||
if (!Positive(stock.Size.Width) || !Positive(stock.Size.Length) ||
|
||||
!Nonnegative(stock.PartSpacing) || !Nonnegative(edges.Left) || !Nonnegative(edges.Right) ||
|
||||
!Nonnegative(edges.Top) || !Nonnegative(edges.Bottom) || stock.Quadrant < 1 || stock.Quadrant > 4 ||
|
||||
edges.Left + edges.Right >= stock.Size.Length || edges.Top + edges.Bottom >= stock.Size.Width)
|
||||
throw new ArgumentException($"Invalid stock dimensions/settings: {stock.Id}.", nameof(job));
|
||||
}
|
||||
foreach (var part in job.Parts)
|
||||
{
|
||||
if (part.Geometry.Motions.Count == 0 || part.Geometry.Motions.Any(m =>
|
||||
!double.IsFinite(m.X) || !double.IsFinite(m.Y) ||
|
||||
!double.IsFinite(m.CenterX) || !double.IsFinite(m.CenterY)))
|
||||
throw new ArgumentException($"Geometry must contain finite motions: {part.Id}.", nameof(job));
|
||||
}
|
||||
// Empty jobs do not select or consume stock (including multiple unused stock entries).
|
||||
if (job.Parts.Count != 0 && job.Plates.Count > 1)
|
||||
throw new NotSupportedException("This slice supports one stock entry only; mixed-stock selection is not implemented.");
|
||||
}
|
||||
|
||||
internal static void ValidateCandidate(PlateCandidate candidate, IReadOnlyDictionary<string, int> remaining)
|
||||
{
|
||||
if (candidate == null) throw new InvalidOperationException("The plate nester returned a null candidate.");
|
||||
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var placement in candidate.Placements)
|
||||
{
|
||||
if (placement.PartId == null || !remaining.TryGetValue(placement.PartId, out var available))
|
||||
throw new InvalidOperationException("Candidate references an unknown requirement ID.");
|
||||
if (!double.IsFinite(placement.X) || !double.IsFinite(placement.Y) || !double.IsFinite(placement.Rotation))
|
||||
throw new InvalidOperationException("Candidate poses must be finite.");
|
||||
counts.TryGetValue(placement.PartId, out var count);
|
||||
if (count >= available) throw new InvalidOperationException("Candidate overproduces a requirement.");
|
||||
counts[placement.PartId] = count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Positive(double value) => double.IsFinite(value) && value > 0;
|
||||
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
|
||||
}
|
||||
Reference in New Issue
Block a user