feat(engine): introduce whole-job nesting contracts

This commit is contained in:
aj
2026-09-17 13:44:10 -04:00
parent 587000f68a
commit 71dffce72c
17 changed files with 490 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
using System;
using System.Threading;
namespace OpenNest;
/// <summary>Synchronous whole-job solver. Cancellation throws, rather than returning partial success.</summary>
public interface INestingEngine
{
NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null, CancellationToken token = default);
}
+11
View File
@@ -0,0 +1,11 @@
using System;
using System.Threading;
namespace OpenNest;
/// <summary>Places on one sheet only. Must not change stock, demand, or caller-owned domain objects.</summary>
public interface IPlateNester
{
PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress> progress = null,
CancellationToken token = default);
}
+32
View File
@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest;
/// <summary>One material/unit system's requirements. Collections are copied; all nested values are immutable.</summary>
public sealed class NestJob
{
public NestJob(IEnumerable<NestJobPart> parts, IEnumerable<NestPlateStock> plates, NestJobOptions options = null)
{
Parts = Own(parts);
Plates = Own(plates);
Options = options ?? new NestJobOptions();
if (Parts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Parts.Count ||
Plates.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Plates.Count)
throw new ArgumentException("Part and stock IDs must each be unique.");
}
public IReadOnlyList<NestJobPart> Parts { get; }
public IReadOnlyList<NestPlateStock> Plates { get; }
public NestJobOptions Options { get; }
internal static IReadOnlyList<T> Own<T>(IEnumerable<T> source)
{
ArgumentNullException.ThrowIfNull(source);
var values = source.ToArray();
if (values.Any(value => value is null))
throw new ArgumentException("Null entries are not allowed.", nameof(source));
return Array.AsReadOnly(values);
}
}
+19
View File
@@ -0,0 +1,19 @@
using System;
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)
{
ArgumentException.ThrowIfNullOrWhiteSpace(placementStrategy);
if (maxPlates <= 0) throw new ArgumentOutOfRangeException(nameof(maxPlates));
PlacementStrategy = placementStrategy;
MaxPlates = maxPlates;
}
public string PlacementStrategy { get; }
/// <summary>Maximum physical sheets to commit, or null for no explicit cap.</summary>
public int? MaxPlates { get; }
}
+27
View File
@@ -0,0 +1,27 @@
using System;
namespace OpenNest;
/// <summary>An immutable requirement, independent of drawing names, UI state, and drawing quantity counters.</summary>
public sealed class NestJobPart
{
public NestJobPart(string id, PartGeometrySnapshot geometry, int quantity, int priority = 0,
RotationPolicy rotation = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentNullException.ThrowIfNull(geometry);
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
Id = id;
Geometry = geometry;
Quantity = quantity;
Priority = priority;
Rotation = rotation ?? RotationPolicy.Automatic;
}
public string Id { get; }
public PartGeometrySnapshot Geometry { get; }
/// <summary>Positive number requested; never decremented by placement code.</summary>
public int Quantity { get; }
public int Priority { get; }
public RotationPolicy Rotation { get; }
}
+63
View File
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
namespace OpenNest;
public enum NestJobStatus { Complete, Incomplete }
public enum NestJobStopReason { Completed, StockExhausted, NoPlacementFound, PlateLimitReached }
public enum NestJobStage { EvaluatingCandidate, PlateCommitted }
/// <summary>Committed counts only; candidate evaluation does not imply committed production.</summary>
public sealed record NestJobProgress(NestJobStage Stage, string StockId, int PlateIndex,
int CommittedPlates, int CommittedParts);
/// <summary>
/// Rotate about the snapshot origin, then translate by X/Y into the selected plate quadrant frame.
/// Rotation is in radians. InstanceIndex is zero-based and unique within a part requirement across the job.
/// The runner assigns final instance indices when committing a candidate.
/// </summary>
public sealed record NestJobPlacement(string PartId, int InstanceIndex, double X, double Y, double Rotation);
/// <summary>Requested = Placed + Unplaced for a requirement ID.</summary>
public sealed record PartFulfillment(string PartId, int Requested, int Placed, int Unplaced);
/// <summary>Used counts physical sheets; Remaining is null only for unlimited stock.</summary>
public sealed record StockUsage(string StockId, int Used, int? Remaining);
/// <summary>One physical sheet, with owned ordered placements and immutable stock/settings snapshot.</summary>
public sealed class NestJobPlateResult
{
public NestJobPlateResult(int plateIndex, NestPlateStock stock, IEnumerable<NestJobPlacement> placements)
{
ArgumentNullException.ThrowIfNull(stock);
PlateIndex = plateIndex;
Stock = stock;
Placements = NestJob.Own(placements);
}
public int PlateIndex { get; }
public string StockId => Stock.Id;
public NestPlateStock Stock { get; }
public IReadOnlyList<NestJobPlacement> Placements { get; }
}
/// <summary>Detached result values in commit/input order; no mutable Drawing, Plate, or NestItem escapes.</summary>
public sealed class NestJobResult
{
public NestJobResult(NestJobStatus status, NestJobStopReason stopReason,
IEnumerable<NestJobPlateResult> plates, IEnumerable<PartFulfillment> fulfillment,
IEnumerable<StockUsage> stockUsage)
{
Status = status;
StopReason = stopReason;
Plates = NestJob.Own(plates);
Fulfillment = NestJob.Own(fulfillment);
StockUsage = NestJob.Own(stockUsage);
}
public NestJobStatus Status { get; }
public NestJobStopReason StopReason { get; }
public IReadOnlyList<NestJobPlateResult> Plates { get; }
public IReadOnlyList<PartFulfillment> Fulfillment { get; }
public IReadOnlyList<StockUsage> StockUsage { get; }
}
+30
View File
@@ -0,0 +1,30 @@
using System;
using System.Linq;
using System.Threading;
namespace OpenNest;
/// <summary>Contract-stage runner: empty jobs only. Nonempty allocation 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>
public NestJobRunner(Func<string, IPlateNester> plateNesterFactory)
{
ArgumentNullException.ThrowIfNull(plateNesterFactory);
this.plateNesterFactory = plateNesterFactory;
}
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
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)));
}
}
+29
View File
@@ -0,0 +1,29 @@
using System;
using OpenNest.Geometry;
namespace OpenNest;
/// <summary>Immutable stock settings. Size and spacing are copied value types, not caller-owned settings.</summary>
public sealed class NestPlateStock
{
public NestPlateStock(string id, Size size, int? quantity = null, double partSpacing = 0,
Spacing edgeSpacing = default, int quadrant = 1)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
if (quantity < 0) throw new ArgumentOutOfRangeException(nameof(quantity));
Id = id;
Size = size;
Quantity = quantity;
PartSpacing = partSpacing;
EdgeSpacing = edgeSpacing;
Quadrant = quadrant;
}
public string Id { get; }
public Size Size { get; }
/// <summary>Available physical sheets: null is unlimited, zero is legal but unavailable.</summary>
public int? Quantity { get; }
public double PartSpacing { get; }
public Spacing EdgeSpacing { get; }
public int Quadrant { get; }
}
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
namespace OpenNest;
/// <summary>Exact immutable CNC motion values. Rapid moves retain contour/hole boundaries; arcs are not tessellated.</summary>
public sealed record PartGeometryMotion(CodeType Type, double X, double Y, double CenterX,
double CenterY, RotationType Rotation, LayerType Layer, bool Suppressed);
/// <summary>
/// Owned geometry only: no Drawing, quantity, events, or mutable CNC references are retained.
/// This initial boundary supports flat rapid/linear/arc programs and rejects other instructions explicitly.
/// Coordinates and mode are preserved without normalization, rounding, or polygon approximation.
/// </summary>
public sealed class PartGeometrySnapshot
{
private PartGeometrySnapshot(Mode mode, IEnumerable<PartGeometryMotion> motions)
{
Mode = mode;
Motions = NestJob.Own(motions);
}
public Mode Mode { get; }
public IReadOnlyList<PartGeometryMotion> Motions { get; }
/// <summary>Copies supported motion geometry immediately; later program edits cannot affect this snapshot.</summary>
public static PartGeometrySnapshot FromProgram(Program program)
{
ArgumentNullException.ThrowIfNull(program);
var motions = program.Codes.Select(code => code switch
{
ArcMove arc => new PartGeometryMotion(arc.Type, arc.EndPoint.X, arc.EndPoint.Y,
arc.CenterPoint.X, arc.CenterPoint.Y, arc.Rotation, arc.Layer, arc.Suppressed),
LinearMove line => new PartGeometryMotion(line.Type, line.EndPoint.X, line.EndPoint.Y,
0, 0, default, line.Layer, line.Suppressed),
RapidMove rapid => new PartGeometryMotion(rapid.Type, rapid.EndPoint.X, rapid.EndPoint.Y,
0, 0, default, default, rapid.Suppressed),
_ => throw new NotSupportedException("Geometry snapshots currently support only flat rapid/linear/arc programs.")
});
return new PartGeometrySnapshot(program.Mode, motions);
}
}
+10
View File
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace OpenNest;
/// <summary>Owned candidate poses only; not committed fulfillment or inventory accounting.</summary>
public sealed class PlateCandidate
{
public PlateCandidate(IEnumerable<NestJobPlacement> placements) => Placements = NestJob.Own(placements);
public IReadOnlyList<NestJobPlacement> Placements { get; }
}
@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
namespace OpenNest;
/// <summary>Read-only stock settings and remaining requirements for a single candidate trial.</summary>
public sealed class PlatePlacementRequest
{
public PlatePlacementRequest(NestPlateStock stock, IEnumerable<NestJobPart> parts)
{
ArgumentNullException.ThrowIfNull(stock);
Stock = stock;
Parts = NestJob.Own(parts);
}
public NestPlateStock Stock { get; }
public IReadOnlyList<NestJobPart> Parts { get; }
}
+35
View File
@@ -0,0 +1,35 @@
using System;
namespace OpenNest;
public enum RotationPolicyKind { Fixed, BoundedSweep, Automatic }
/// <summary>Immutable rotation constraints, in radians about the geometry origin.</summary>
public sealed class RotationPolicy
{
private RotationPolicy(RotationPolicyKind kind, double start, double end, double step)
{
if (!double.IsFinite(start) || !double.IsFinite(end) || !double.IsFinite(step))
throw new ArgumentException("Angles must be finite.");
Kind = kind;
Start = start;
End = end;
Step = step;
}
public RotationPolicyKind Kind { get; }
public double Start { get; }
public double End { get; }
public double Step { get; }
public static RotationPolicy Automatic { get; } = new(RotationPolicyKind.Automatic, 0, 0, 0);
public static RotationPolicy Fixed(double angle) => new(RotationPolicyKind.Fixed, angle, angle, 0);
public static RotationPolicy BoundedSweep(double start, double end, double step)
{
if (step <= 0 || end < start) throw new ArgumentException("Sweep needs a positive step and ordered bounds.");
return new RotationPolicy(RotationPolicyKind.BoundedSweep, start, end, step);
}
/// <summary>Preserves the legacy zero-step automatic sentinel; zero never means locked rotation.</summary>
public static RotationPolicy FromLegacy(double stepAngle, double rotationStart, double rotationEnd) =>
stepAngle == 0 ? Automatic : BoundedSweep(rotationStart, rotationEnd, stepAngle);
}