From 71dffce72cdeec3cd5492cc76713ddec909a654a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 17 Sep 2026 13:44:10 -0400 Subject: [PATCH] feat(engine): introduce whole-job nesting contracts --- .../Jobs/NestJobRunnerTests.cs | 99 +++++++++++++++++++ .../Jobs/TestDrawingFactory.cs | 17 ++++ .../OpenNest.Engine.Tests.csproj | 18 ++++ OpenNest.Engine/Jobs/INestingEngine.cs | 10 ++ OpenNest.Engine/Jobs/IPlateNester.cs | 11 +++ OpenNest.Engine/Jobs/NestJob.cs | 32 ++++++ OpenNest.Engine/Jobs/NestJobOptions.cs | 19 ++++ OpenNest.Engine/Jobs/NestJobPart.cs | 27 +++++ OpenNest.Engine/Jobs/NestJobResult.cs | 63 ++++++++++++ OpenNest.Engine/Jobs/NestJobRunner.cs | 30 ++++++ OpenNest.Engine/Jobs/NestPlateStock.cs | 29 ++++++ OpenNest.Engine/Jobs/PartGeometrySnapshot.cs | 44 +++++++++ OpenNest.Engine/Jobs/PlateCandidate.cs | 10 ++ OpenNest.Engine/Jobs/PlatePlacementRequest.cs | 18 ++++ OpenNest.Engine/Jobs/RotationPolicy.cs | 35 +++++++ OpenNest.sln | 14 +++ README.md | 15 ++- 17 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 OpenNest.Engine.Tests/Jobs/NestJobRunnerTests.cs create mode 100644 OpenNest.Engine.Tests/Jobs/TestDrawingFactory.cs create mode 100644 OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj create mode 100644 OpenNest.Engine/Jobs/INestingEngine.cs create mode 100644 OpenNest.Engine/Jobs/IPlateNester.cs create mode 100644 OpenNest.Engine/Jobs/NestJob.cs create mode 100644 OpenNest.Engine/Jobs/NestJobOptions.cs create mode 100644 OpenNest.Engine/Jobs/NestJobPart.cs create mode 100644 OpenNest.Engine/Jobs/NestJobResult.cs create mode 100644 OpenNest.Engine/Jobs/NestJobRunner.cs create mode 100644 OpenNest.Engine/Jobs/NestPlateStock.cs create mode 100644 OpenNest.Engine/Jobs/PartGeometrySnapshot.cs create mode 100644 OpenNest.Engine/Jobs/PlateCandidate.cs create mode 100644 OpenNest.Engine/Jobs/PlatePlacementRequest.cs create mode 100644 OpenNest.Engine/Jobs/RotationPolicy.cs diff --git a/OpenNest.Engine.Tests/Jobs/NestJobRunnerTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobRunnerTests.cs new file mode 100644 index 0000000..5b955f2 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NestJobRunnerTests.cs @@ -0,0 +1,99 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +public class NestJobRunnerTests +{ + [Fact] + public void EmptyJobCompletesWithoutPlatesOrPlacementWork() + { + var fake = new FakePlateNester(); + var factoryCalls = 0; + var runner = new NestJobRunner(_ => { factoryCalls++; return fake; }); + var job = new NestJob(Array.Empty(), new[] + { + new NestPlateStock("finite", new Size(100, 200), 2), + new NestPlateStock("unlimited", new Size(100, 200)) + }); + + var result = runner.Solve(job); + + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(NestJobStopReason.Completed, result.StopReason); + Assert.Empty(result.Plates); + Assert.Empty(result.Fulfillment); + Assert.Collection(result.StockUsage, + usage => { Assert.Equal(0, usage.Used); Assert.Equal(2, usage.Remaining); }, + usage => { Assert.Equal(0, usage.Used); Assert.Null(usage.Remaining); }); + Assert.Equal(0, factoryCalls); + Assert.Equal(0, fake.Calls); + } + + [Fact] + public void PreCancelledEmptyJobThrows() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var runner = new NestJobRunner(_ => new FakePlateNester()); + var job = new NestJob(Array.Empty(), Array.Empty()); + Assert.Throws(() => runner.Solve(job, token: cancellation.Token)); + } + + [Fact] + public void NonemptyJobIsExplicitlyUnsupportedInContractSlice() + { + var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle()), 1); + var job = new NestJob(new[] { part }, Array.Empty()); + var runner = new NestJobRunner(_ => new FakePlateNester()); + Assert.Throws(() => runner.Solve(job)); + } + + [Fact] + public void JobOwnsCollectionsSettingsAndExactGeometryIncludingHoleArc() + { + var program = TestDrawingFactory.Rectangle(); + program.MoveTo(3.123456789, 4); + program.ArcTo(3.123456789, 4, 4, 4, RotationType.CW); + var geometry = PartGeometrySnapshot.FromProgram(program); + var parts = new List { new("p", geometry, 3) }; + var size = new Size(100, 200); + var edges = new Spacing(1, 2, 3, 4); + var stocks = new List { new("s", size, 0, 2, edges, 3) }; + var job = new NestJob(parts, stocks); + parts.Clear(); stocks.Clear(); program.Codes.Clear(); size.Width = 0; edges.Left = 999; + + Assert.Single(job.Parts); + Assert.Equal(3, job.Parts[0].Quantity); + Assert.Equal(7, geometry.Motions.Count); + Assert.Equal(CodeType.RapidMove, geometry.Motions[5].Type); + Assert.Equal(CodeType.ArcMove, geometry.Motions[6].Type); + Assert.Equal(3.123456789, geometry.Motions[6].X); + Assert.Equal(4, geometry.Motions[6].CenterX); + Assert.Equal(RotationType.CW, geometry.Motions[6].Rotation); + Assert.Equal(100, job.Plates[0].Size.Width); + Assert.Equal(1, job.Plates[0].EdgeSpacing.Left); + Assert.Equal(0, job.Plates[0].Quantity); + Assert.Equal("Default", job.Options.PlacementStrategy); + Assert.Throws(() => ((IList)job.Parts).Clear()); + } + + [Fact] + public void LegacyZeroStepMeansAutomaticNotFixed() + { + Assert.Equal(RotationPolicyKind.Automatic, RotationPolicy.FromLegacy(0, 1, 2).Kind); + Assert.Equal(RotationPolicyKind.Fixed, RotationPolicy.Fixed(1).Kind); + Assert.Equal(RotationPolicyKind.BoundedSweep, RotationPolicy.BoundedSweep(0, 1, 0.5).Kind); + } + + private sealed class FakePlateNester : IPlateNester + { + public int Calls { get; private set; } + public PlateCandidate Place(PlatePlacementRequest request, IProgress? progress = null, + CancellationToken token = default) + { + Calls++; + return new PlateCandidate(Array.Empty()); + } + } +} diff --git a/OpenNest.Engine.Tests/Jobs/TestDrawingFactory.cs b/OpenNest.Engine.Tests/Jobs/TestDrawingFactory.cs new file mode 100644 index 0000000..ddb5d51 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/TestDrawingFactory.cs @@ -0,0 +1,17 @@ +using OpenNest.CNC; + +namespace OpenNest.Engine.Tests.Jobs; + +internal static class TestDrawingFactory +{ + public static Program Rectangle(double width = 10, double length = 20) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(width, 0); + program.LineTo(width, length); + program.LineTo(0, length); + program.LineTo(0, 0); + return program; + } +} diff --git a/OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj b/OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj new file mode 100644 index 0000000..c83a4bd --- /dev/null +++ b/OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + enable + enable + false + true + + + + + + + + + + + diff --git a/OpenNest.Engine/Jobs/INestingEngine.cs b/OpenNest.Engine/Jobs/INestingEngine.cs new file mode 100644 index 0000000..aea5678 --- /dev/null +++ b/OpenNest.Engine/Jobs/INestingEngine.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading; + +namespace OpenNest; + +/// Synchronous whole-job solver. Cancellation throws, rather than returning partial success. +public interface INestingEngine +{ + NestJobResult Solve(NestJob job, IProgress progress = null, CancellationToken token = default); +} diff --git a/OpenNest.Engine/Jobs/IPlateNester.cs b/OpenNest.Engine/Jobs/IPlateNester.cs new file mode 100644 index 0000000..010a5a1 --- /dev/null +++ b/OpenNest.Engine/Jobs/IPlateNester.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading; + +namespace OpenNest; + +/// Places on one sheet only. Must not change stock, demand, or caller-owned domain objects. +public interface IPlateNester +{ + PlateCandidate Place(PlatePlacementRequest request, IProgress progress = null, + CancellationToken token = default); +} diff --git a/OpenNest.Engine/Jobs/NestJob.cs b/OpenNest.Engine/Jobs/NestJob.cs new file mode 100644 index 0000000..bed9d6b --- /dev/null +++ b/OpenNest.Engine/Jobs/NestJob.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest; + +/// One material/unit system's requirements. Collections are copied; all nested values are immutable. +public sealed class NestJob +{ + public NestJob(IEnumerable parts, IEnumerable 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 Parts { get; } + public IReadOnlyList Plates { get; } + public NestJobOptions Options { get; } + + internal static IReadOnlyList Own(IEnumerable 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); + } +} diff --git a/OpenNest.Engine/Jobs/NestJobOptions.cs b/OpenNest.Engine/Jobs/NestJobOptions.cs new file mode 100644 index 0000000..9013a8e --- /dev/null +++ b/OpenNest.Engine/Jobs/NestJobOptions.cs @@ -0,0 +1,19 @@ +using System; + +namespace OpenNest; + +/// Immutable per-job options; selection never changes the legacy global registry. +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; } + /// Maximum physical sheets to commit, or null for no explicit cap. + public int? MaxPlates { get; } +} diff --git a/OpenNest.Engine/Jobs/NestJobPart.cs b/OpenNest.Engine/Jobs/NestJobPart.cs new file mode 100644 index 0000000..f65bbf2 --- /dev/null +++ b/OpenNest.Engine/Jobs/NestJobPart.cs @@ -0,0 +1,27 @@ +using System; + +namespace OpenNest; + +/// An immutable requirement, independent of drawing names, UI state, and drawing quantity counters. +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; } + /// Positive number requested; never decremented by placement code. + public int Quantity { get; } + public int Priority { get; } + public RotationPolicy Rotation { get; } +} diff --git a/OpenNest.Engine/Jobs/NestJobResult.cs b/OpenNest.Engine/Jobs/NestJobResult.cs new file mode 100644 index 0000000..5ae5517 --- /dev/null +++ b/OpenNest.Engine/Jobs/NestJobResult.cs @@ -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 } + +/// Committed counts only; candidate evaluation does not imply committed production. +public sealed record NestJobProgress(NestJobStage Stage, string StockId, int PlateIndex, + int CommittedPlates, int CommittedParts); + +/// +/// 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. +/// +public sealed record NestJobPlacement(string PartId, int InstanceIndex, double X, double Y, double Rotation); + +/// Requested = Placed + Unplaced for a requirement ID. +public sealed record PartFulfillment(string PartId, int Requested, int Placed, int Unplaced); + +/// Used counts physical sheets; Remaining is null only for unlimited stock. +public sealed record StockUsage(string StockId, int Used, int? Remaining); + +/// One physical sheet, with owned ordered placements and immutable stock/settings snapshot. +public sealed class NestJobPlateResult +{ + public NestJobPlateResult(int plateIndex, NestPlateStock stock, IEnumerable 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 Placements { get; } +} + +/// Detached result values in commit/input order; no mutable Drawing, Plate, or NestItem escapes. +public sealed class NestJobResult +{ + public NestJobResult(NestJobStatus status, NestJobStopReason stopReason, + IEnumerable plates, IEnumerable fulfillment, + IEnumerable 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 Plates { get; } + public IReadOnlyList Fulfillment { get; } + public IReadOnlyList StockUsage { get; } +} diff --git a/OpenNest.Engine/Jobs/NestJobRunner.cs b/OpenNest.Engine/Jobs/NestJobRunner.cs new file mode 100644 index 0000000..d3f12b8 --- /dev/null +++ b/OpenNest.Engine/Jobs/NestJobRunner.cs @@ -0,0 +1,30 @@ +using System; +using System.Linq; +using System.Threading; + +namespace OpenNest; + +/// Contract-stage runner: empty jobs only. Nonempty allocation is not implemented yet. +public sealed class NestJobRunner : INestingEngine +{ + private readonly Func plateNesterFactory; + + /// Stores a runner-local strategy factory; never consults the global engine registry. + public NestJobRunner(Func plateNesterFactory) + { + ArgumentNullException.ThrowIfNull(plateNesterFactory); + this.plateNesterFactory = plateNesterFactory; + } + + public NestJobResult Solve(NestJob job, IProgress 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(), Array.Empty(), + job.Plates.Select(stock => new StockUsage(stock.Id, 0, stock.Quantity))); + } +} diff --git a/OpenNest.Engine/Jobs/NestPlateStock.cs b/OpenNest.Engine/Jobs/NestPlateStock.cs new file mode 100644 index 0000000..b0d1130 --- /dev/null +++ b/OpenNest.Engine/Jobs/NestPlateStock.cs @@ -0,0 +1,29 @@ +using System; +using OpenNest.Geometry; + +namespace OpenNest; + +/// Immutable stock settings. Size and spacing are copied value types, not caller-owned settings. +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; } + /// Available physical sheets: null is unlimited, zero is legal but unavailable. + public int? Quantity { get; } + public double PartSpacing { get; } + public Spacing EdgeSpacing { get; } + public int Quadrant { get; } +} diff --git a/OpenNest.Engine/Jobs/PartGeometrySnapshot.cs b/OpenNest.Engine/Jobs/PartGeometrySnapshot.cs new file mode 100644 index 0000000..e8cb326 --- /dev/null +++ b/OpenNest.Engine/Jobs/PartGeometrySnapshot.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; + +namespace OpenNest; + +/// Exact immutable CNC motion values. Rapid moves retain contour/hole boundaries; arcs are not tessellated. +public sealed record PartGeometryMotion(CodeType Type, double X, double Y, double CenterX, + double CenterY, RotationType Rotation, LayerType Layer, bool Suppressed); + +/// +/// 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. +/// +public sealed class PartGeometrySnapshot +{ + private PartGeometrySnapshot(Mode mode, IEnumerable motions) + { + Mode = mode; + Motions = NestJob.Own(motions); + } + + public Mode Mode { get; } + public IReadOnlyList Motions { get; } + + /// Copies supported motion geometry immediately; later program edits cannot affect this snapshot. + 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); + } +} diff --git a/OpenNest.Engine/Jobs/PlateCandidate.cs b/OpenNest.Engine/Jobs/PlateCandidate.cs new file mode 100644 index 0000000..a09512c --- /dev/null +++ b/OpenNest.Engine/Jobs/PlateCandidate.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace OpenNest; + +/// Owned candidate poses only; not committed fulfillment or inventory accounting. +public sealed class PlateCandidate +{ + public PlateCandidate(IEnumerable placements) => Placements = NestJob.Own(placements); + public IReadOnlyList Placements { get; } +} diff --git a/OpenNest.Engine/Jobs/PlatePlacementRequest.cs b/OpenNest.Engine/Jobs/PlatePlacementRequest.cs new file mode 100644 index 0000000..558273a --- /dev/null +++ b/OpenNest.Engine/Jobs/PlatePlacementRequest.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; + +namespace OpenNest; + +/// Read-only stock settings and remaining requirements for a single candidate trial. +public sealed class PlatePlacementRequest +{ + public PlatePlacementRequest(NestPlateStock stock, IEnumerable parts) + { + ArgumentNullException.ThrowIfNull(stock); + Stock = stock; + Parts = NestJob.Own(parts); + } + + public NestPlateStock Stock { get; } + public IReadOnlyList Parts { get; } +} diff --git a/OpenNest.Engine/Jobs/RotationPolicy.cs b/OpenNest.Engine/Jobs/RotationPolicy.cs new file mode 100644 index 0000000..13cdddd --- /dev/null +++ b/OpenNest.Engine/Jobs/RotationPolicy.cs @@ -0,0 +1,35 @@ +using System; + +namespace OpenNest; + +public enum RotationPolicyKind { Fixed, BoundedSweep, Automatic } + +/// Immutable rotation constraints, in radians about the geometry origin. +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); + } + + /// Preserves the legacy zero-step automatic sentinel; zero never means locked rotation. + public static RotationPolicy FromLegacy(double stepAngle, double rotationStart, double rotationEnd) => + stepAngle == 0 ? Automatic : BoundedSweep(rotationStart, rotationEnd, stepAngle); +} diff --git a/OpenNest.sln b/OpenNest.sln index cadc297..288e20e 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -34,6 +34,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.GravographIS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine.Tests", "OpenNest.Engine.Tests\OpenNest.Engine.Tests.csproj", "{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -200,6 +202,18 @@ Global {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.Build.0 = Release|Any CPU {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.ActiveCfg = Release|Any CPU {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.Build.0 = Release|Any CPU + {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|x64.ActiveCfg = Debug|Any CPU + {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|x64.Build.0 = Debug|Any CPU + {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|x86.ActiveCfg = Debug|Any CPU + {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|x86.Build.0 = Debug|Any CPU + {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|Any CPU.Build.0 = Release|Any CPU + {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x64.ActiveCfg = Release|Any CPU + {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x64.Build.0 = Release|Any CPU + {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x86.ActiveCfg = Release|Any CPU + {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index fbbf9cb..dff8ed9 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,18 @@ cd OpenNest dotnet build OpenNest.sln ``` +### Cross-platform engine contract tests + +```bash +dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj +``` + +`OpenNest.Engine.Tests` targets `net8.0` and runs on Linux, macOS, and Windows without the desktop project or local DXF fixtures. The existing `OpenNest.Tests` suite still requires Windows. + +The new whole-job contracts in `OpenNest.Engine/Jobs` (`namespace OpenNest`) use owned immutable geometry/settings, explicit part IDs and positive demand, finite or unlimited stock (`null` means unlimited; zero means unavailable), and result ID/pose values rather than mutable desktop models. Rotation is in radians about the geometry origin, followed by translation into the plate quadrant frame. Strategy factories belong to each runner, not the global registry. + +**Current scope:** `NestJobRunner.Solve` completes empty jobs without consuming stock and honors initial cancellation by throwing. Nonempty jobs explicitly throw `NotSupportedException`; allocation, legacy adapters, placement validation, and production strategy resolution are not implemented yet. Geometry snapshots currently preserve flat CNC rapid/line/arc programs, including hole contours, without approximation; other instructions are explicitly rejected. Existing desktop, API, CLI, and MCP nesting paths are unchanged. + ### Run ```bash @@ -142,7 +154,8 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext OpenNest.sln ├── OpenNest/ # WinForms desktop application (UI) ├── OpenNest.Core/ # Domain model, geometry, and CNC primitives -├── OpenNest.Engine/ # Nesting algorithms (fill, pack, compact, best-fit) +├── OpenNest.Engine/ # Nesting algorithms and whole-job contracts +├── OpenNest.Engine.Tests/ # Cross-platform whole-job contract tests (net8.0) ├── OpenNest.IO/ # File I/O — DXF import/export, nest file format ├── OpenNest.Console/ # Command-line interface for batch nesting ├── OpenNest.Api/ # Programmatic nesting API (NestRunner pipeline)