docs: document whole-job nesting contracts and migration boundaries
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Runnable end-to-end example of the whole-job engine API: multiple part requirements, multiple plate
|
||||
/// sizes, and an enumeration of every returned plate, placement, leftover, and stock line. Also the
|
||||
/// documentation checkpoint for the legacy caller boundaries that have not been migrated (task 8).
|
||||
/// </summary>
|
||||
public class NestJobExampleTests
|
||||
{
|
||||
[Fact]
|
||||
public void MultiRequirementMultiStockJobEnumeratesEveryPlateAndLeftover()
|
||||
{
|
||||
// Two requirements with independent IDs, quantities, and priorities.
|
||||
var job = new NestJob(
|
||||
new[]
|
||||
{
|
||||
Part("bracket", 100.0, 60.0, 5, priority: 0),
|
||||
Part("plate-clip", 40.0, 40.0, 8, priority: 1),
|
||||
},
|
||||
// Mixed inventory: five large sheets and unlimited small sheets.
|
||||
new[]
|
||||
{
|
||||
new NestPlateStock("large", new Size(600.0, 400.0), quantity: 5, partSpacing: 2.0,
|
||||
edgeSpacing: new Spacing(5.0, 5.0, 5.0, 5.0), quadrant: 1),
|
||||
new NestPlateStock("small", new Size(300.0, 300.0), quantity: null, partSpacing: 2.0,
|
||||
edgeSpacing: new Spacing(5.0, 5.0, 5.0, 5.0), quadrant: 1),
|
||||
});
|
||||
|
||||
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job);
|
||||
|
||||
// -- Every physical plate is enumerated with its stock identity and placements. --
|
||||
Console.WriteLine($"Status: {result.Status}, stop reason: {result.StopReason}.");
|
||||
foreach (var plate in result.Plates)
|
||||
{
|
||||
Console.WriteLine($"Plate {plate.PlateIndex} from stock '{plate.StockId}' " +
|
||||
$"({plate.Stock.Size.Width} x {plate.Stock.Size.Length}):");
|
||||
foreach (var placement in plate.Placements)
|
||||
Console.WriteLine($" {placement.PartId} #{placement.InstanceIndex} at " +
|
||||
$"({placement.X:F1}, {placement.Y:F1}) rotated {placement.Rotation:F3} rad.");
|
||||
}
|
||||
|
||||
// -- Every requirement reports exact fulfillment, including leftovers. --
|
||||
foreach (var fulfillment in result.Fulfillment)
|
||||
Console.WriteLine($"Requirement '{fulfillment.PartId}': requested {fulfillment.Requested}, " +
|
||||
$"placed {fulfillment.Placed}, unplaced {fulfillment.Unplaced}.");
|
||||
|
||||
// -- Every stock line reports physical sheets used and remaining availability. --
|
||||
foreach (var usage in result.StockUsage)
|
||||
Console.WriteLine($"Stock '{usage.StockId}': used {usage.Used}, " +
|
||||
$"remaining {(usage.Remaining.HasValue ? usage.Remaining.Value.ToString() : "unlimited")}.");
|
||||
|
||||
// Invariants the enumeration relies on: conservation per requirement and per stock line, no
|
||||
// empty plates, every plate bound to supplied stock, and per-placement instance accounting.
|
||||
foreach (var fulfillment in result.Fulfillment)
|
||||
{
|
||||
Assert.Equal(fulfillment.Requested, fulfillment.Placed + fulfillment.Unplaced);
|
||||
Assert.True(fulfillment.Unplaced >= 0);
|
||||
}
|
||||
foreach (var usage in result.StockUsage)
|
||||
{
|
||||
var stock = job.Plates.First(candidate => candidate.Id == usage.StockId);
|
||||
Assert.True(usage.Used >= 0);
|
||||
Assert.Equal(stock.Quantity is int capacity ? capacity - usage.Used : (int?)null, usage.Remaining);
|
||||
}
|
||||
|
||||
Assert.All(result.Plates, plate => Assert.NotEmpty(plate.Placements));
|
||||
var plateCountByStock = result.Plates.GroupBy(plate => plate.StockId)
|
||||
.ToDictionary(group => group.Key, group => group.Count());
|
||||
foreach (var usage in result.StockUsage)
|
||||
Assert.Equal(usage.Used, plateCountByStock.GetValueOrDefault(usage.StockId));
|
||||
|
||||
var instanceIndicesByPart = result.Plates
|
||||
.SelectMany(plate => plate.Placements)
|
||||
.GroupBy(placement => placement.PartId)
|
||||
.ToDictionary(group => group.Key, group => group.Select(placement => placement.InstanceIndex));
|
||||
foreach (var fulfillment in result.Fulfillment)
|
||||
Assert.Equal(Enumerable.Range(0, fulfillment.Placed),
|
||||
instanceIndicesByPart.GetValueOrDefault(fulfillment.PartId, new List<int>()).OrderBy(index => index));
|
||||
|
||||
// The default heuristic completes this synthetic job from the mixed inventory.
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.Completed, result.StopReason);
|
||||
Assert.Equal(5, result.Fulfillment.Single(fulfillment => fulfillment.PartId == "bracket").Placed);
|
||||
Assert.Equal(8, result.Fulfillment.Single(fulfillment => fulfillment.PartId == "plate-clip").Placed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxPlatesExampleShowsExplicitLeftovers()
|
||||
{
|
||||
// Same shape of job, but a plate budget forces an explicit partial result.
|
||||
var job = new NestJob(
|
||||
new[] { Part("part", 100.0, 100.0, 6, priority: 0) },
|
||||
new[] { new NestPlateStock("sheet", new Size(220.0, 220.0), quantity: null, partSpacing: 2.0,
|
||||
edgeSpacing: new Spacing(5.0, 5.0, 5.0, 5.0), quadrant: 1) },
|
||||
new NestJobOptions("Default", maxPlates: 1));
|
||||
|
||||
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
|
||||
var single = Assert.Single(result.Plates);
|
||||
Assert.Equal("sheet", single.StockId);
|
||||
var fulfillment = Assert.Single(result.Fulfillment);
|
||||
Assert.Equal(6, fulfillment.Requested);
|
||||
Assert.Equal(single.Placements.Count, fulfillment.Placed);
|
||||
Assert.Equal(fulfillment.Requested - fulfillment.Placed, fulfillment.Unplaced);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy caller boundaries documented for task 8 — these paths still use the old single-plate
|
||||
/// engine entry points and are deliberately NOT migrated in this slice. Verified against source at
|
||||
/// the time of writing:
|
||||
/// - Desktop UI: OpenNest/Forms/MainForm.cs RunAutoNestAsync (~line 1004) and NestSinglePlateAsync
|
||||
/// (~line 1087) orchestrate plate-first and part-first fills directly against NestEngineRegistry
|
||||
/// engines. Migration requires preserving populated-plate editing, preview routing, and
|
||||
/// Accept-versus-Cancel semantics — a separate adapter design (documented follow-on).
|
||||
/// - CLI: OpenNest.Console/Program.cs calls engine.Nest(...) (~line 316) on one plate. Migration
|
||||
/// point: build a NestJob from imported drawings plus CLI plate options and call Solve once.
|
||||
/// - MCP: OpenNest.Mcp/Tools/NestingTools.cs calls engine.Nest(...) (~line 239) on the session
|
||||
/// plate. Migration point: same single job call, materialized through NestResultMaterializer.
|
||||
/// The public API (OpenNest.Api NestRunner) already delegates to NestJobRunner.Solve (task 6).
|
||||
/// This test exercises the legacy compatibility signature so an accidental removal of that entry
|
||||
/// point breaks the documented contract.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LegacyCompatibilityEntryPointsStillExist()
|
||||
{
|
||||
var plate = new Plate { Size = new Size(300.0, 200.0), Quadrant = 1 };
|
||||
var drawing = new Drawing("legacy", TestDrawingFactory.Rectangle(50.0, 50.0));
|
||||
var item = new NestItem { Drawing = drawing, Quantity = 1 };
|
||||
|
||||
// MainForm/Console/MCP still reach the legacy single-plate signature unchanged; the engine
|
||||
// returns placed Parts for the caller to attach (legacy paths do not attach on their own).
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var parts = engine.Nest(new List<NestItem> { item }, null, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(engine);
|
||||
var placed = Assert.Single(parts);
|
||||
Assert.Same(drawing, placed.BaseDrawing);
|
||||
}
|
||||
|
||||
private static NestJobPart Part(string id, double width, double length, int quantity, int priority) =>
|
||||
new(id, PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(width, length)), quantity, priority);
|
||||
}
|
||||
@@ -72,11 +72,11 @@ 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.
|
||||
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. Callers own their inputs: the job copies everything at entry and the result leaks no mutable `Drawing`, `Plate`, or `NestItem`. One job is one material/thickness/unit system — no cross-material pooling. 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. In the public API, the legacy `SheetSize` request field is the unlimited-stock fallback only when `Plates` is null; an explicit empty `Plates` list means no available stock.
|
||||
|
||||
**Current scope (minimum runnable slice, not release-ready):** `NestJobRunner.Solve` allocates one stock entry across multiple physical sheets, respecting finite inventory (`null` is unlimited), positive remaining demand, and `MaxPlates`. Empty parts complete without consuming stock; empty/unavailable stock returns `Incomplete/StockExhausted`. A zero-placement candidate stops with `NoPlacementFound` and consumes no sheet. Nonempty jobs with multiple stock entries explicitly throw `NotSupportedException`; mixed-stock selection and optional optimizations are not implemented.
|
||||
`NestJobRunner.Solve` allocates a job across physical sheets from the full stock inventory: every available stock entry is trialled independently each iteration, and only the winning candidate consumes a sheet or reduces demand. Selection is a documented deterministic greedy policy — lexicographic placed-count vector by ascending part priority, then lower consumed sheet area, then smaller placement envelope, then original stock input order (see `NestJobCandidateComparer`). It is a tie policy, not a guarantee of global-minimum material or plate count. Finite stock is never exceeded; `MaxPlates` caps sheet count; empty parts complete without consuming stock; empty or fully exhausted stock returns `Incomplete/StockExhausted`; a zero-placement candidate stops with `NoPlacementFound` and consumes no sheet.
|
||||
|
||||
`DrawingJobMapper` snapshots caller drawings/items under explicit requirement IDs. `LegacyPlateNesterAdapter` creates fresh private legacy drawings, items, and plates for each trial and maps returned drawings **by reference**, never by name. Mutable legacy quantities never drive the fulfillment ledger. `PlateNesterFactory` resolves the built-in strategies (`Default`, `Strip`, `Vertical Remnant`, `Horizontal Remnant`) to private engine factories; it neither reads nor changes the process-global `NestEngineRegistry`, and unknown keys reject. Quantity deduction in the engine paths the runner reaches (base-class fill/pack, strip deduction, remnant-fill ledger, shrink-leftover counting) is keyed by drawing reference, not display name, so same-name drawings and repeated requirements stay independent. `NestResultMaterializer` returns a detached domain nest and `DrawingsByPartId` identity map. Each output plate represents one physical sheet (`Quantity = 1`), and each placement is attached exactly once so domain quantity events do not double count.
|
||||
`DrawingJobMapper` snapshots caller drawings/items under explicit requirement IDs. `LegacyPlateNesterAdapter` creates fresh private legacy drawings, items, and plates for each trial and maps returned drawings **by reference**, never by name. Mutable legacy quantities never drive the fulfillment ledger. `PlateNesterFactory` resolves the built-in strategy names (`Default`, `Strip`, `Vertical Remnant`, `Horizontal Remnant`) to instance-scoped placement strategies; it neither reads nor changes the process-global `NestEngineRegistry`, and unknown keys reject. Quantity deduction in the engine paths the runner reaches (base-class fill/pack, strip deduction, remnant-fill ledger, shrink-leftover counting) is keyed by drawing reference, not display name, so same-name drawings and repeated requirements stay independent. `NestResultMaterializer` returns a detached domain nest and `DrawingsByPartId` identity map. Each output plate represents one physical sheet (`Quantity = 1`), and each placement is attached exactly once so domain quantity events do not double count.
|
||||
|
||||
```csharp
|
||||
var job = new NestJob(
|
||||
@@ -88,7 +88,11 @@ var domainResult = NestResultMaterializer.Materialize(job, result);
|
||||
// domainResult.Nest and domainResult.DrawingsByPartId are detached from caller objects.
|
||||
```
|
||||
|
||||
**Unfinished safety boundary:** basic validation rejects invalid dimensions/settings, nonfinite geometry, unknown candidate IDs, nonfinite poses, and overproduction. Cancellation throws initially and immediately after the engine returns; no partial result is returned. Full contour validity, plate containment, allowed-rotation enforcement, inter-part overlap/clearance validation, and broader cancellation coverage remain task 5 work. A committed candidate is therefore **not yet certified safe for cutting**, even if the legacy engine reports success. Geometry snapshots preserve flat CNC rapid/line/arc programs, including origin and hole contours, without approximation; other instructions are explicitly rejected. Existing desktop, API, CLI, and MCP nesting paths are unchanged.
|
||||
**Safety gate:** before the runner commits any candidate, `NestJobPlacementValidator` re-checks it against the immutable job geometry: closed usable contours, finite poses, the requirement's rotation policy (automatic / fixed / bounded sweep with step), containment inside the per-quadrant usable work area, hole-aware material overlap, and required part spacing (touching is allowed at zero spacing, rejected at positive spacing). Malformed engine output fails explicitly without consuming stock or demand. Cancellation throws `OperationCanceledException` before each trial and immediately after each engine return; no half-committed state is returned. An `Incomplete` result means the heuristic stopped, not that the geometry is impossible — the stop reason says why. Geometry snapshots preserve flat CNC rapid/line/arc programs, including origin and hole contours, without approximation; other instructions are explicitly rejected.
|
||||
|
||||
**Placement strategies:** `Default` and `Strip` are migrated built-ins (`OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs`, `StripPlateNester.cs`) that reuse the engine geometry while keeping demand read-only; the remnant strategies still run through `LegacyPlateNesterAdapter` during rollout. A runnable end-to-end example — multiple requirements, mixed finite/unlimited stock, full plate/leftover enumeration — lives in `OpenNest.Engine.Tests/Jobs/NestJobExampleTests.cs`.
|
||||
|
||||
**Legacy caller boundaries (not yet migrated):** the desktop UI (`MainForm.RunAutoNestAsync` / `NestSinglePlateAsync`), the CLI (`OpenNest.Console`), and MCP (`NestingTools`) still call the old single-plate `engine.Nest(...)` entry points unchanged. UI adoption needs a separate adapter preserving populated-plate editing, preview routing, and Accept-versus-Cancel semantics. The public API (`OpenNest.Api`, `NestRunner.RunAsync`) already delegates to one `NestJobRunner.Solve` call and reports status, stop reason, part fulfillment, stock usage, and plate-to-stock mapping; `.nestquote` archives carry a schema version and round-trip incomplete jobs.
|
||||
|
||||
### Run
|
||||
|
||||
|
||||
Reference in New Issue
Block a user