70 Commits
Author SHA1 Message Date
aj e3bcc77145 fix(cutting): keep ray parity consistent across nominal closure seams
Point-in-material used exact Y comparisons, but rings are only nominally
closed: capture admits closure seams and PEP-converted or rotated geometry
carries sub-micro-inch vertex jogs. When a jog straddled the test ray's Y,
the two edges at that shared vertex disagreed which side it was on and the
parity flipped, so a point ten inches outside a material read as interior.
LeadMaterialSnapshot reported leads as entering a neighbour they could not
touch, and the enclosing-perimeter check refused to capture materials whose
rings it misclassified.

Snap the ray comparison by the admitted seam tolerance so both edges at a
shared vertex decide together. Features larger than the tolerance still
count, and containment of a point within the tolerance of an edge stays as
ambiguous as before. On the 93-plate corpus 83 plates are untouched, one
previously refused plate now plans and replays, and 48/93 plates reach
Ready. Accepting more leads also changed one plate's first-found route to a
longer rapid path; that is search ranking, recorded with the measurements.

Also drops an extent-prefilter fast path measured at -6.8% corpus time: it
cleared leads whose native contact was reported beyond any fixed bbox
margin, which NearbyMaterialCheckTests deliberately pins as a refusal.
2026-10-12 03:50:54 -04:00
aj df94824054 fix(cutting): replay rings closed within nominal tolerance
Capture and preparation agree that a nominal contour may close within
NominalContourClosure.Tolerance inclusively, which converted drawings rely on:
the sampled production corpus carries vertex gaps up to 1e-6. Replay
verification still compared those rings at the exact geometry epsilon, so the
one joint a rotated tool path legitimately bridges refused the whole plate
after the search had already spent its budget.

Census of 91 plates from 60 stored nests: every searched plate containing such
a seam refused with contour-accounting (4 plates, 76.5s of wasted search, worst
71.7s for one 11-part plate), and none of the 42 ready plates had one. Replay
now carries that same admitted endpoint gap as positional and length slack for
the affected ring only. Direction sign, coverage order, retrace detection, ring
counting and selected-emission binding keep the exact epsilon, and capture's
closure admission is unchanged.

With the fix the census yields 45 ready plates instead of 42, no plate changed
any other way, and expansion counts are identical wherever status matched.
P260805-01's 16-part plate plans in 0.76s rather than refusing after 4,129
expansions.

Tests: SeamClosureReplayTests covers seams of 0, 5e-7 and 1e-6 plus four
accounting faults (missing edge, reversed edge, interior shift beyond the
admitted seam, second circuit) that must still refuse, and the beyond-tolerance
capture refusal. OpenNest.Tests 3751, OpenNest.Engine.Tests 690 and
OpenNest.IO.Tests 79 pass; Windows-only projects cannot build on Linux.
2026-10-12 00:36:48 -04:00
aj 9bd5d0dfa7 fix(cutting-ui): navigate selected source plate in planner 2026-10-11 22:45:54 -04:00
aj d6ae706098 refactor(cutting-ui): retire redundant planning and validation forms 2026-10-11 22:15:35 -04:00
aj f9b2e1282a feat(cutting-ui): inspect live nest validation in docked panel 2026-10-11 21:55:59 -04:00
aj 943b63937d feat(cutting-ui): plan and apply all plates from docked panel 2026-10-11 21:40:26 -04:00
aj 6ed7ab7392 fix(cutting-ui): restore canvas after panel layout and clamp splitter 2026-10-11 21:31:01 -04:00
aj 4738a0390e feat(cutting-ui): inspect one proposal on the main canvas 2026-10-11 21:21:07 -04:00
aj 322dc3eda6 fix(cutoffs): use outward union for fitted arc bounds 2026-10-11 20:56:34 -04:00
aj ddc4710dfd perf(cutting): defer alternate hole entries in preferred pass 2026-10-11 20:26:47 -04:00
aj 97a468ee2c fix(cutting): recover checked routes through interlocking holed parts 2026-10-11 19:51:29 -04:00
aj 09dd19ca66 feat(ui): show nest versions in database details tab 2026-10-11 19:42:06 -04:00
aj fae37dd1d0 fix(data): retry absent pending uploads without changing identity 2026-10-11 17:15:16 -04:00
aj 94ed8a0f8f fix(backup): refuse uncertain or reused restore volumes 2026-10-11 17:10:26 -04:00
aj 028832debc fix(data): reconcile ambiguous nest uploads before retry 2026-10-11 17:10:26 -04:00
aj 51ad700bec docs(storage): explain version browser and recovery 2026-10-11 17:02:41 -04:00
aj 0216a9e9e2 test(server): verify complete revision backup and restore 2026-10-11 17:02:31 -04:00
aj a7c8e8541e feat(ui): inspect and restore nest history 2026-10-11 17:01:01 -04:00
aj 5f1eeb00eb test(data): verify legacy metadata guard before mutation 2026-10-11 16:51:56 -04:00
aj 914c197181 feat(server): restore historical nest as new revision 2026-10-11 16:49:12 -04:00
aj 5598804942 feat(server): preserve deleted nests with guarded recovery 2026-10-11 16:49:12 -04:00
aj d216bcd406 feat(server): version guarded metadata edits 2026-10-11 16:49:12 -04:00
aj 11471e3c71 test(server): exercise version history through real client 2026-10-11 16:39:48 -04:00
aj ed7600dc90 feat(data): fetch bounded nest history and guard saves 2026-10-11 16:38:35 -04:00
aj a47b414392 feat(server): preserve file-save nest revisions 2026-10-11 16:37:28 -04:00
aj 3d512c794c fix(cnc): show first internal rapid after cut-first programs 2026-10-11 15:48:07 -04:00
aj 08acb092a7 fix(cutting): anchor near-corner entries to their native edge 2026-10-11 01:14:50 -04:00
aj dc142cc88d docs(readme): streamline engine guidance and tabulate CLI options 2026-10-10 21:31:11 -04:00
aj 6799e08356 docs(readme): remove stray plate header note 2026-10-10 21:28:25 -04:00
aj ca6966906f Support direct contour piercing and correct planner geometry checks 2026-10-10 16:44:03 -04:00
aj c28ccd7568 Move zoom controls into centered plate view header 2026-10-10 16:04:58 -04:00
aj 90ae3b6a84 Fix false self-intersection warnings at rounded contour joints 2026-10-10 15:57:16 -04:00
aj 72776886cb Remember saved nest browser sort preferences 2026-10-10 15:39:11 -04:00
aj d59c03f5ab Highlight validation contacts and mute unrelated parts 2026-10-10 15:11:41 -04:00
aj bb108e9341 Add interactive nest validation with animated issue highlights 2026-10-10 14:51:47 -04:00
aj 80c647bf07 fix(report): omit trailing empty stock plates 2026-10-10 13:23:58 -04:00
aj 24be90ace9 fix(report): replace closeups with best-effort grouped labels 2026-10-10 13:14:45 -04:00
aj 4feada094c fix(report): use plain numeric part IDs 2026-10-10 13:14:39 -04:00
aj 4e846007a2 fix(report): use parts per plate consistently 2026-10-10 13:14:31 -04:00
aj 8ebb48694f fix(report): rename plate copies to quantity 2026-10-10 13:14:25 -04:00
aj afa50c5a12 fix(report): display stock dimensions as width by length 2026-10-10 13:14:19 -04:00
aj ef6ca80e6d Merge branch 'master' of https://github.com/ajisaacs/OpenNest 2026-10-10 12:38:50 -04:00
aj 0c99455489 feat(ui): validate Auto Nest stock prices and report costs 2026-10-10 12:38:48 -04:00
aj c34ac2bc0e feat(mcp): support priced stock and preserve accepted job data 2026-10-10 12:38:36 -04:00
aj 076b794dda feat(api): accept stock prices and archive cost summaries 2026-10-10 12:38:30 -04:00
aj 5dbf887b68 feat(engine): score nesting jobs by per-sheet stock cost 2026-10-10 12:38:20 -04:00
aj 17e504fb70 feat(cutting): navigate diagnostic rows in enlarged planner 2026-10-10 11:37:57 -04:00
aj 69304d3851 feat(settings): set nest server URL from Options 2026-10-09 21:54:10 -04:00
aj 1c6d02c480 perf(cutting): try a bounded entry catalogue before full search 2026-10-09 21:26:40 -04:00
aj d328c5f691 perf(cutting): generate ranked alternatives only when visited 2026-10-09 20:40:10 -04:00
aj e897258a85 feat(cutouts): reconcile repeated frames and inserts through one solve 2026-10-09 20:36:43 -04:00
aj 1b9d8a36d8 fix(pipeline): certify bound programs and cancellation before commit 2026-10-09 20:28:05 -04:00
aj b750475228 fix(pipeline): refuse changed drawings before binding or commit 2026-10-09 20:15:17 -04:00
aj 138c86526a perf(cutting): reuse native curve wrappers for lead queries 2026-10-09 19:15:28 -04:00
aj 07e682a654 test(cutting): add opt-in complete-route performance probe 2026-10-09 19:15:09 -04:00
aj 0b29c683b9 feat(cutouts): trial physical composite reconciliation in pipeline 2026-10-09 18:57:31 -04:00
aj 71bd964344 test(cutouts): trace accepted lattice and NFP poses in one run 2026-10-09 17:53:22 -04:00
aj 1d50c6ce60 feat(cutouts): compare measured ratio routes and certify leftovers 2026-10-09 17:47:12 -04:00
aj 962ae8c825 feat(cutouts): propose bounded mixed lattice and NFP inserts 2026-10-09 17:20:41 -04:00
aj d3e325f36c fix(ui): restore manual part sequencing and close control 2026-10-09 17:06:17 -04:00
aj 575412a464 feat(cutouts): propose certified single inserts with inner-fit geometry 2026-10-09 16:59:11 -04:00
aj 60fb009329 fix(ui): expose Auto Nest stock row selectors for deletion 2026-10-09 13:16:40 -04:00
aj 41737a4f4c feat(mcp): complete jobs with finite mixed sheet stock 2026-10-09 13:14:34 -04:00
aj f1d1df3f17 fix(mcp): preserve job constraints and compatible stock 2026-10-09 12:34:30 -04:00
aj 81404fc673 feat(mcp): complete whole jobs across matching plates 2026-10-09 12:22:40 -04:00
aj a5b6c8a00e fix(mcp): warn on unmet quantities rather than sheet ratio 2026-10-09 11:09:51 -04:00
aj 2a6e5504cd fix(mcp): report zero packing demand accurately 2026-10-09 10:52:41 -04:00
aj 3d5c73a353 fix(mcp): report requested and placed drawing mix 2026-10-09 10:48:59 -04:00
aj 8b94e49711 feat(mcp): delete unused session plates 2026-10-09 10:45:28 -04:00
aj 2623517219 fix(mcp): reject negative plate indices 2026-10-09 10:42:35 -04:00
161 changed files with 12066 additions and 2160 deletions

No files matched your search

+3
View File
@@ -5,6 +5,9 @@ namespace OpenNest.Api;
/// <summary>One explicit physical-stock type for a whole nesting job.</summary> /// <summary>One explicit physical-stock type for a whole nesting job.</summary>
public class NestRequestPlate public class NestRequestPlate
{ {
/// <summary>Positive per-sheet cost in common units; omit on every row for area scoring.</summary>
public double? Cost { get; init; }
public string Id { get; init; } public string Id { get; init; }
public Size Size { get; init; } public Size Size { get; init; }
+4
View File
@@ -44,6 +44,7 @@ public class NestResponse
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = []; public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = []; public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = []; public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
public NestCostSummary Costs { get; init; }
public Nest Nest { get; init; } public Nest Nest { get; init; }
public NestRequest Request { get; init; } public NestRequest Request { get; init; }
@@ -86,6 +87,7 @@ public class NestResponse
Fulfillment = Fulfillment is null Fulfillment = Fulfillment is null
? [] ? []
: new List<NestPartFulfillment>(Fulfillment), : new List<NestPartFulfillment>(Fulfillment),
Costs = Costs,
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage), StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
PlateStockMappings = PlateStockMappings is null PlateStockMappings = PlateStockMappings is null
? [] ? []
@@ -171,6 +173,7 @@ public class NestResponse
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [], Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [], StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [], PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
Costs = archive.Costs,
Nest = nest, Nest = nest,
Request = request, Request = request,
}; };
@@ -178,6 +181,7 @@ public class NestResponse
private sealed class NestResponseArchiveDto private sealed class NestResponseArchiveDto
{ {
public NestCostSummary Costs { get; init; }
public int SchemaVersion { get; init; } public int SchemaVersion { get; init; }
public int SheetCount { get; init; } public int SheetCount { get; init; }
public double Utilization { get; init; } public double Utilization { get; init; }
+8 -2
View File
@@ -28,6 +28,7 @@ public static class NestRunner
if (requestParts.Count == 0) if (requestParts.Count == 0)
throw new ArgumentException("Request must contain at least one part.", nameof(request)); throw new ArgumentException("Request must contain at least one part.", nameof(request));
var stock = CreateStock(request);
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var parts = IdentifyParts(requestParts); var parts = IdentifyParts(requestParts);
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal); var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
@@ -82,7 +83,6 @@ public static class NestRunner
}); });
} }
var stock = CreateStock(request);
var engineName = request.Engine ?? ResolvePlacementStrategy(request); var engineName = request.Engine ?? ResolvePlacementStrategy(request);
var jobProgress = progress == null ? null : new JobProgressBridge(progress); var jobProgress = progress == null ? null : new JobProgressBridge(progress);
var result = NestPipeline.Run(new NestPipelineRequest( var result = NestPipeline.Run(new NestPipelineRequest(
@@ -91,6 +91,9 @@ public static class NestRunner
// API returns a detached proposal, not an acceptance/commit to a caller's nest. // API returns a detached proposal, not an acceptance/commit to a caller's nest.
// Invalid but representable proposals retain every pose and carry explicit validation status. // Invalid but representable proposals retain every pose and carry explicit validation status.
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) }; var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
if (request.Plates != null)
nest.PlateOptions = stock.Where(s => s.Quantity != 0).Select(s => new PlateOption
{ Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
foreach (var item in items) foreach (var item in items)
nest.Drawings.Add(item.Drawing); nest.Drawings.Add(item.Drawing);
foreach (var proposed in result.Plates) foreach (var proposed in result.Plates)
@@ -144,6 +147,7 @@ public static class NestRunner
Violations = result.Violations, Violations = result.Violations,
Fulfillment = fulfillment, Fulfillment = fulfillment,
StockUsage = usage, StockUsage = usage,
Costs = result.CanKeep && result.IsValid ? NestCostSummary.FromAccepted(result) : null,
PlateStockMappings = result.Plates.Select((value, index) => PlateStockMappings = result.Plates.Select((value, index) =>
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(), new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
Nest = nest, Nest = nest,
@@ -209,11 +213,13 @@ public static class NestRunner
plate.Quantity, plate.Quantity,
plate.PartSpacing, plate.PartSpacing,
plate.EdgeSpacing, plate.EdgeSpacing,
plate.Quadrant plate.Quadrant,
plate.Cost
) )
); );
} }
NestJobValidator.ValidateStockCosts(stock);
return stock; return stock;
} }
@@ -55,11 +55,13 @@ internal static class ContourProgramVerifier
var ringIndex = -1; var ringIndex = -1;
var entityIndex = -1; var entityIndex = -1;
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++) for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
{
var admitted = AdmittedClosureSlack(material.Rings[r]);
for (var e = 0; e < material.Rings[r].Length; e++) for (var e = 0; e < material.Rings[r].Length; e++)
{ {
Query(token, ref budget); Query(token, ref budget);
var curve = material.Rings[r][e]; var curve = material.Rings[r][e];
if (curve.SameDirection(first) && curve.Contains(first.Start) if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon) && curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
{ {
ringIndex = r; ringIndex = r;
@@ -67,28 +69,39 @@ internal static class ContourProgramVerifier
break; break;
} }
} }
}
if (ringIndex < 0 || !visited.Add(ringIndex)) return false; if (ringIndex < 0 || !visited.Add(ringIndex)) return false;
var ring = material.Rings[ringIndex]; var ring = material.Rings[ringIndex];
// Capture admits a nominal ring that closes within NominalContourClosure.Tolerance, and
// preparation agrees at that inclusive boundary. A rotated tool path bridges that one
// admitted joint, so this ring's positional and length agreement carries exactly that
// admitted slack. Direction, coverage order and retrace accounting stay at the epsilon.
var tolerance = AdmittedClosureSlack(ring);
var nominalLength = ring.Sum(c => c.Length); var nominalLength = ring.Sum(c => c.Length);
var cutLength = run.Curves.Sum(c => c.Length); var cutLength = run.Curves.Sum(c => c.Length);
var missing = nominalLength - cutLength; var missing = nominalLength - cutLength;
if (missing < -Epsilon) return false; // Never a second circuit/retrace. if (missing < -Epsilon) return false; // Never a second circuit/retrace.
if (missing > Epsilon) if (missing > tolerance)
{ {
// Fixed legacy payloads have no certified selected tab/entry metadata. // Fixed legacy payloads have no certified selected tab/entry metadata.
// A merely open contour or a caller's current tab switch proves nothing. // A merely open contour or a caller's current tab switch proves nothing.
if (selected == null || ringIndex != 0 || selected.TabSize <= 0 if (selected == null || ringIndex != 0 || selected.TabSize <= 0
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > Epsilon) || System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > tolerance)
return false; return false;
// SameEmission has already bound this exact terminal gap to the owned // SameEmission has already bound this exact terminal gap to the owned
// settings' independently emitted, rounded/clamped selected geometry. // settings' independently emitted, rounded/clamped selected geometry.
} }
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start), if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
true, token, ref budget)) return false; true, tolerance, token, ref budget)) return false;
} }
return visited.Count == material.Rings.Count; return visited.Count == material.Rings.Count;
} }
// Zero for an exactly closed ring: a ring may lawfully close within the nominal closure
// tolerance, and only that admitted endpoint gap relaxes replay's positional agreement.
private static double AdmittedClosureSlack(IReadOnlyList<Curve> ring) => ring.Count == 0 ? Epsilon
: Epsilon + System.Math.Max(0.0, ring[^1].End.DistanceTo(ring[0].Start));
private static bool SameEmission(OwnedExecution actual, OwnedExecution expected, private static bool SameEmission(OwnedExecution actual, OwnedExecution expected,
CancellationToken token, ref int budget) CancellationToken token, ref int budget)
{ {
@@ -107,15 +120,17 @@ internal static class ContourProgramVerifier
if (a.End.DistanceTo(b.End) > Epsilon) return false; if (a.End.DistanceTo(b.End) > Epsilon) return false;
} }
else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon
|| !Follows(a.Curves, b.Curves, 0, 0, false, token, ref budget)) return false; || !Follows(a.Curves, b.Curves, 0, 0, false, Epsilon, token, ref budget)) return false;
} }
return true; return true;
} }
// Consume directed native arc-length from both streams. Subdivision, merged // Consume directed native arc-length from both streams. Subdivision, merged
// collinear moves and a cyclic ring's reindexing do not change coverage. // collinear moves and a cyclic ring's reindexing do not change coverage.
// `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon
// unless the nominal ring closes only within the nominal closure tolerance).
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected, private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
int index, double offset, bool cyclic, CancellationToken token, ref int budget) int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget)
{ {
foreach (var curve in actual) foreach (var curve in actual)
{ {
@@ -137,13 +152,13 @@ internal static class ContourProgramVerifier
continue; continue;
} }
var length = System.Math.Min(remaining, curve.Length - consumed); var length = System.Math.Min(remaining, curve.Length - consumed);
if (!nominal.SameDirection(curve) if (!nominal.SameDirection(curve, tolerance)
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > Epsilon || nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > tolerance
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > Epsilon) || nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > tolerance)
return false; return false;
consumed += length; consumed += length;
offset += length; offset += length;
if (offset >= nominal.Length - Epsilon) if (offset >= nominal.Length - tolerance)
{ {
index++; index++;
offset = 0; offset = 0;
@@ -151,7 +166,7 @@ internal static class ContourProgramVerifier
} }
} }
return cyclic || index == expected.Count || index == expected.Count - 1 return cyclic || index == expected.Count || index == expected.Count - 1
&& expected[index].Length - offset <= Epsilon; && expected[index].Length - offset <= tolerance;
} }
private static List<Run> Runs(OwnedExecution execution) private static List<Run> Runs(OwnedExecution execution)
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.Diagnostics; using OpenNest.Diagnostics;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingPlanning; namespace OpenNest.CNC.CuttingPlanning;
@@ -46,7 +47,7 @@ public sealed class LeadMaterialSnapshot
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon) if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Material contour is discontinuous."); throw new ArgumentException("Material contour is discontinuous.");
chain.Add(curve); chain.Add(curve);
if (PostVerificationGeometry.Closed(chain)) if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
Finish(); Finish();
} }
Finish(); Finish();
@@ -66,11 +67,23 @@ public sealed class LeadMaterialSnapshot
var b = rings[s][j]; var b = rings[s][j];
var contacts = a.Contacts(b, out var overlap); var contacts = a.Contacts(b, out var overlap);
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1)); var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
// Native arc queries pad angles by 1e-5 radians, admitting
// intersections on an extension (distance grows with radius).
// At a shared arc joint require both actual finite spans;
// overlap and genuine second intersections still refuse.
if (arcJoint)
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
// Native contacts at rounded arc/line joints can drift from the
// authored endpoint. Use the shared geometry tolerance only to
// recognize that joint; overlaps and other contacts still refuse.
if (overlap || contacts.Any(p => !adjacent if (overlap || contacts.Any(p => !adjacent
|| !(p.DistanceTo(a.End) <= PostVerificationGeometry.Epsilon || !(p.DistanceTo(a.End) <= Tolerance.Epsilon
&& p.DistanceTo(b.Start) <= PostVerificationGeometry.Epsilon) && p.DistanceTo(b.Start) <= Tolerance.Epsilon)
&& !(p.DistanceTo(a.Start) <= PostVerificationGeometry.Epsilon && !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
&& p.DistanceTo(b.End) <= PostVerificationGeometry.Epsilon))) && p.DistanceTo(b.End) <= Tolerance.Epsilon)))
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap})."); throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
} }
var outer = -1; var outer = -1;
@@ -97,8 +110,13 @@ public sealed class LeadMaterialSnapshot
{ {
if (chain.Count == 0) if (chain.Count == 0)
return; return;
if (!PostVerificationGeometry.Closed(chain)) if (!NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly."); {
var gap = chain[0].Start.DistanceTo(chain[^1].End);
throw new ArgumentException(FormattableString.Invariant(
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
}
rings.Add(chain.ToArray()); rings.Add(chain.ToArray());
chain.Clear(); chain.Clear();
} }
@@ -113,7 +113,8 @@ public static class LeadPathValidator
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null; return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
} }
} }
// Missing leads are the ReleasedContourState check's responsibility. // No lead motion is valid for direct contour piercing. The caller still
// checks rapid travel and contour accounting for the complete program.
return new(true, true, null); return new(true, true, null);
} }
catch (Exception ex) when (ex is ArgumentException or NotSupportedException) catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
@@ -0,0 +1,20 @@
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Closure of source contours during planner material capture and preparation.</summary>
internal static class NominalContourClosure
{
internal const double Tolerance = 0.000001;
internal static bool IsClosed(Vector start, Vector end)
{
// Subtracting translated/rotated coordinates can put an exact boundary gap
// a few floating-point ulps above the limit. Bound that allowance to 1e-12.
var scale = System.Math.Max(1, System.Math.Max(
System.Math.Max(System.Math.Abs(start.X), System.Math.Abs(start.Y)),
System.Math.Max(System.Math.Abs(end.X), System.Math.Abs(end.Y))));
var roundoff = System.Math.Min(1e-12, 4 * 2.2204460492503131e-16 * scale);
return start.DistanceTo(end) <= Tolerance + roundoff;
}
}
@@ -74,7 +74,7 @@ public sealed class PreparedContours
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon) if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Discontinuous native contour."); throw new ArgumentException("Discontinuous native contour.");
current.Entities.Add(entity); current.Entities.Add(entity);
if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon) if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
FinishContour(); FinishContour();
} }
if (current.Entities.Count != 0 || contours.Count == 0) if (current.Entities.Count != 0 || contours.Count == 0)
@@ -254,12 +254,11 @@ public sealed class PreparedContours
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner)) .Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var cornerPoint = End(shape.Entities[corner.EntityOrdinal]);
// Back INTO each incident edge from the corner: the edge ending at the // Back INTO each incident edge from the corner: the edge ending at the
// corner retreats against its own travel, the edge starting at the corner // corner retreats against its own travel, the edge starting at the corner
// advances along its own travel. // advances along its own travel.
AddInset(corner.EntityOrdinal, cornerPoint, inward: false); AddInset(corner.EntityOrdinal, inward: false);
AddInset((corner.EntityOrdinal + 1) % count, cornerPoint, inward: true); AddInset((corner.EntityOrdinal + 1) % count, inward: true);
} }
} }
@@ -276,7 +275,7 @@ public sealed class PreparedContours
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
return fallbacks; return fallbacks;
void AddInset(int entityOrdinal, Vector corner, bool inward) void AddInset(int entityOrdinal, bool inward)
{ {
// `inward` selects along the edge's own travel from its start; without it the // `inward` selects along the edge's own travel from its start; without it the
// point retreats against travel. Both ways move BACK INTO the edge from the // point retreats against travel. Both ways move BACK INTO the edge from the
@@ -285,7 +284,9 @@ public sealed class PreparedContours
return; return;
var direction = (line.EndPoint - line.StartPoint).Normalize(); var direction = (line.EndPoint - line.StartPoint).Normalize();
var offset = direction * (2 * lead); var offset = direction * (2 * lead);
var point = inward ? corner + offset : corner - offset; // Adjacent native endpoints may differ within contour-continuity tolerance.
// Anchor to the selected edge itself, never the preceding edge's vertex.
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
// Strictly inside the edge by projection parameter (distance alone loses the // Strictly inside the edge by projection parameter (distance alone loses the
// sign when a short edge is overshoot): never the corner, never the far // sign when a short edge is overshoot): never the corner, never the far
// endpoint (where a reflex vertex might sit). Short edges omit the point. // endpoint (where a reflex vertex might sit). Short edges omit the point.
@@ -11,10 +11,20 @@ namespace OpenNest.CNC.CuttingPlanning;
public sealed class ReleasedContourState public sealed class ReleasedContourState
{ {
private readonly List<Obstacle> obstacles = new(); private readonly List<Obstacle> obstacles = new();
private readonly bool reportMissingLeadIns;
public ReleasedContourState() : this(true) { }
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
/// may pierce directly on a contour and checks travel without requiring a lead.</param>
public ReleasedContourState(bool reportMissingLeadIns)
{
this.reportMissingLeadIns = reportMissingLeadIns;
}
public ReleasedContourState Copy() public ReleasedContourState Copy()
{ {
var copy = new ReleasedContourState(); var copy = new ReleasedContourState(reportMissingLeadIns);
copy.obstacles.AddRange(obstacles); copy.obstacles.AddRange(obstacles);
return copy; return copy;
} }
@@ -68,7 +78,13 @@ public sealed class ReleasedContourState
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token)) if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part, findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " + $"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}.")); $"of part {obstacle.Part}.")
{
Location = (start + move.End) * 0.5,
RapidStart = start,
RapidEnd = move.End,
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token)
});
} }
} }
else if (move.Layer == LayerType.Leadin) else if (move.Layer == LayerType.Leadin)
@@ -92,9 +108,10 @@ public sealed class ReleasedContourState
if (contour.Count == 0) if (contour.Count == 0)
{ {
contourNumber++; contourNumber++;
if (!cutoff && !hasLead) if (reportMissingLeadIns && !cutoff && !hasLead)
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null, findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")); $"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
{ Location = move.Curve.Start });
hasLead = false; hasLead = false;
// A rapid can pause/reposition without leaving any material gap. // A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles // Retain already-cut fragments, but do not turn them into obstacles
+20 -8
View File
@@ -31,16 +31,16 @@ namespace OpenNest.CNC
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results) private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
{ {
// Draw the rapid from the previous tool position to the program's first // Draw the approach to the first rapid destination, or to the program
// pierce point. The walk then starts at the program origin (basePos), not // origin when it starts cutting without a rapid. Only an initial rapid
// the pierce: the skipped first rapid still advances pos, so starting at // (or call arrival) is already represented by this approach; a later
// the pierce would apply a nonzero Incremental first delta twice (as in // first rapid must remain visible. Walk starts at the program origin,
// lead-in programs) and shift every later rapid by it. // so an incremental initial rapid is not applied twice.
var firstPierce = FirstPiercePoint(pgm, basePos); var firstPierce = FirstPiercePoint(pgm, basePos);
results.Add(new Segment(startPos, firstPierce)); results.Add(new Segment(startPos, firstPierce));
var pos = basePos; var pos = basePos;
Walk(pgm, basePos, ref pos, skipFirst: true, results); Walk(pgm, basePos, ref pos, skipFirst: StartsWithRapidOrCall(pgm), results);
// The last rapid ends at a pierce, not necessarily the final tool position. // The last rapid ends at a pierce, not necessarily the final tool position.
return pos; return pos;
} }
@@ -53,11 +53,23 @@ namespace OpenNest.CNC
return FirstPiercePoint(call.Program, basePos + call.Offset); return FirstPiercePoint(call.Program, basePos + call.Offset);
if (pgm[i] is Motion motion) if (pgm[i] is Motion motion)
return motion.EndPoint + basePos; return motion is RapidMove ? motion.EndPoint + basePos : basePos;
} }
return basePos; return basePos;
} }
private static bool StartsWithRapidOrCall(Program pgm)
{
for (var i = 0; i < pgm.Length; i++)
{
if (pgm[i] is SubProgramCall { Program: not null })
return true;
if (pgm[i] is Motion motion)
return motion is RapidMove;
}
return false;
}
private static void Walk( private static void Walk(
Program pgm, Program pgm,
Vector basePos, Vector basePos,
@@ -83,7 +95,7 @@ namespace OpenNest.CNC
results.Add(new Segment(pos, firstPierce)); results.Add(new Segment(pos, firstPierce));
var subPos = holeBase; var subPos = holeBase;
Walk(program, holeBase, ref subPos, skipFirst: true, results); Walk(program, holeBase, ref subPos, skipFirst: StartsWithRapidOrCall(program), results);
pos = subPos; pos = subPos;
} }
else if (code is Motion motion) else if (code is Motion motion)
+25 -2
View File
@@ -134,8 +134,22 @@ namespace OpenNest
double clearance double clearance
) )
{ {
var bb = part.BoundingBox; var cached = part.BoundingBox;
var roundoff = GetBoundsRoundoff(part); var roundoff = GetBoundsRoundoff(part);
// Keep the farther of the cached and converted perimeter extents. The
// perimeter's fine intersection/offset can miss a near-tangent sliver;
// only a line in that narrow outer band needs the conservative box.
var bb = cached;
if (perimeter != null)
{
var converted = perimeter.BoundingBox;
var left = System.Math.Min(cached.Left, converted.Left);
var right = System.Math.Max(cached.Right, converted.Right);
var bottom = System.Math.Min(cached.Bottom, converted.Bottom);
var top = System.Math.Max(cached.Top, converted.Top);
bb = new Box(left, bottom, right - left, top - bottom);
}
var (cachedMin, cachedMax) = AxisBounds(cached, clearance);
var (partMin, partMax) = AxisBounds(bb, clearance); var (partMin, partMax) = AxisBounds(bb, clearance);
var (partStart, partEnd) = CrossAxisBounds(bb, clearance); var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
// Match the planner's representational allowance in BOTH pruning and // Match the planner's representational allowance in BOTH pruning and
@@ -150,6 +164,8 @@ namespace OpenNest
if (perimeter != null) if (perimeter != null)
{ {
if (cutPosition <= cachedMin + roundoff || cutPosition >= cachedMax - roundoff)
return new List<(double Start, double End)> { (partStart, partEnd) };
var perimeterExclusions = IntersectPerimeter( var perimeterExclusions = IntersectPerimeter(
perimeter, perimeter,
cutPosition, cutPosition,
@@ -180,7 +196,14 @@ namespace OpenNest
magnitude = System.Math.Max(magnitude, magnitude = System.Math.Max(magnitude,
System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y))); System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y)));
var step = System.Math.BitIncrement(magnitude) - magnitude; var step = System.Math.BitIncrement(magnitude) - magnitude;
return double.IsFinite(step) ? System.Math.Min(8 * step, Math.Tolerance.Epsilon / 4) : 0; // Imported, nearly tangent arcs can amplify endpoint rounding when
// ConvertProgram fits their centers to a chord. Keep that allowance
// tiny and apply it to both pruning and fallback exclusions above;
// larger refits still require an explicit geometry repair.
var arcFitRoundoff = System.Math.Min(4096 * step, Math.Tolerance.Epsilon / 100000);
return double.IsFinite(step)
? System.Math.Min(System.Math.Max(8 * step, arcFitRoundoff), Math.Tolerance.Epsilon / 4)
: 0;
} }
private List<(double Start, double End)> IntersectPerimeter( private List<(double Start, double End)> IntersectPerimeter(
@@ -113,7 +113,8 @@ public static class PostVerificationAnalyzer
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken); PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
foreach (var pair in overlap.Pairs) foreach (var pair in overlap.Pairs)
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId], findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).")); indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).")
{ Location = pair.Centroid, Overlap = pair });
foreach (var issue in overlap.Issues) foreach (var issue in overlap.Issues)
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId], findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message)); issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
@@ -9,6 +9,10 @@ namespace OpenNest.Diagnostics;
internal static class PostVerificationGeometry internal static class PostVerificationGeometry
{ {
internal const double Epsilon = 1e-8; internal const double Epsilon = 1e-8;
// Matches NominalContourClosure.Tolerance: the largest gap capture admits as a closed ring.
// Ray parity snaps vertex Y comparisons within this band so an admitted seam cannot flip an
// exterior point to interior (see Curve.CrossesRay).
internal const double RaySeamTolerance = 1e-6;
private const double TwoPi = 2 * System.Math.PI; private const double TwoPi = 2 * System.Math.PI;
/// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary> /// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary>
@@ -89,6 +93,30 @@ internal static class PostVerificationGeometry
return inside; return inside;
} }
internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end,
IReadOnlyList<Curve> curves, CancellationToken token)
{
var length = start.DistanceTo(end);
if (length <= Epsilon)
return Array.Empty<Vector>();
var direction = (end - start) * (1 / length);
var distances = new List<double>();
foreach (var curve in curves)
{
token.ThrowIfCancellationRequested();
curve.ContactAfterStart(start, direction, length, distances);
}
distances.Sort();
var points = new List<Vector>();
foreach (var distance in distances)
{
var point = start + direction * distance;
if (points.Count == 0 || points[^1].DistanceTo(point) > Epsilon)
points.Add(point);
}
return points.AsReadOnly();
}
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y; private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X; private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
private static double Normalize(double angle) private static double Normalize(double angle)
@@ -106,8 +134,13 @@ internal static class PostVerificationGeometry
Center = center; Center = center;
Radius = radius; Radius = radius;
Sweep = sweep; Sweep = sweep;
nativeEntity = new Lazy<Entity>(ToEntity);
} }
// Never expose this mutable native wrapper; its geometry is read-only after creation.
// Lazy preserves the old conversion point for unsupported curves and is safe for concurrent checks.
private readonly Lazy<Entity> nativeEntity;
internal Vector Start { get; } internal Vector Start { get; }
internal Vector End { get; } internal Vector End { get; }
private Vector? Center { get; } private Vector? Center { get; }
@@ -138,20 +171,31 @@ internal static class PostVerificationGeometry
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2)) center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
: (Start + End) * 0.5; : (Start + End) * 0.5;
internal bool SameSupport(Curve other) internal bool SameSupport(Curve other) => SameSupport(other, Epsilon);
/// <summary>
/// Support agreement with an explicit positional allowance. Used only where an upstream
/// stage has already admitted a larger nominal discrepancy — currently the inclusive
/// <see cref="NominalContourClosure.Tolerance"/> joint that a replayed ring bridges. It is
/// never a collision tolerance: safety queries keep the exact epsilon.
/// </summary>
internal bool SameSupport(Curve other, double positionTolerance)
{ {
if (Center is { } center) if (Center is { } center)
return other.Center is { } c && center.DistanceTo(c) <= Epsilon return other.Center is { } c && center.DistanceTo(c) <= positionTolerance
&& System.Math.Abs(Radius - other.Radius) <= Epsilon; && System.Math.Abs(Radius - other.Radius) <= positionTolerance;
if (other.Center.HasValue || Length <= Epsilon || other.Length <= Epsilon) if (other.Center.HasValue || Length <= positionTolerance || other.Length <= positionTolerance)
return false; return false;
var direction = (End - Start) * (1 / Length); var direction = (End - Start) * (1 / Length);
return System.Math.Abs(Cross(other.Start - Start, direction)) <= Epsilon return System.Math.Abs(Cross(other.Start - Start, direction)) <= positionTolerance
&& System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon; && System.Math.Abs(Cross(other.End - Start, direction)) <= positionTolerance;
} }
// Directed native arc-length coordinates for contour replay, not collision queries. // Directed native arc-length coordinates for contour replay, not collision queries.
internal bool SameDirection(Curve other) => SameSupport(other) internal bool SameDirection(Curve other) => SameDirection(other, Epsilon);
/// <summary>Directed coverage check with an admitted positional allowance; direction sign stays exact.</summary>
internal bool SameDirection(Curve other, double positionTolerance) => SameSupport(other, positionTolerance)
&& (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep) && (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
: Dot(End - Start, other.End - other.Start) > 0); : Dot(End - Start, other.End - other.Start) > 0);
@@ -170,12 +214,25 @@ internal static class PostVerificationGeometry
: Start + (End - Start) * (distance / Length); : Start + (End - Start) * (distance / Length);
} }
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon; internal bool Contains(Vector point) => Contains(point, Epsilon);
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
internal bool Contains(Vector point, double positionTolerance)
=> nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= positionTolerance;
/// <summary>
/// Filters supporting-circle contacts against the actual finite arc span. The
/// native angular padding is larger and must not turn an arc extension into a
/// self-intersection at an adjacent nominal joint. This is not a radial test.
/// </summary>
internal bool IsOutsideArcSpan(Vector point) => Center.HasValue && !OnArc(point);
internal bool IsFiniteArc => Center.HasValue && System.Math.Abs(Sweep) < TwoPi;
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap) internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
{ {
var entity = ToEntity(); var entity = nativeEntity.Value;
var candidate = other.ToEntity(); var candidate = other.nativeEntity.Value;
// Native arc filters discard NaN supporting-circle intersections. Inspect // Native arc filters discard NaN supporting-circle intersections. Inspect
// both unfiltered queries first: roundoff can differ by operand direction. // both unfiltered queries first: roundoff can differ by operand direction.
// Coincident supports have separate overlap handling below. // Coincident supports have separate overlap handling below.
@@ -281,7 +338,8 @@ internal static class PostVerificationGeometry
// The largest distance from the centre that ContactAfterStart can count as contact. // The largest distance from the centre that ContactAfterStart can count as contact.
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack); private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
internal bool ContactAfterStart(Vector origin, Vector direction, double length) internal bool ContactAfterStart(Vector origin, Vector direction, double length,
List<double> contacts = null)
{ {
if (Center is { } center) if (Center is { } center)
{ {
@@ -294,10 +352,13 @@ internal static class PostVerificationGeometry
if (square < -ContactSlack) if (square < -ContactSlack)
return false; return false;
var offset = System.Math.Sqrt(System.Math.Max(0, square)); var offset = System.Math.Sqrt(System.Math.Max(0, square));
return Hit(projection - offset) || Hit(projection + offset); return contacts == null
? Hit(projection - offset) || Hit(projection + offset)
: Hit(projection - offset) | Hit(projection + offset);
bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length)); && OnArc(origin + direction * System.Math.Clamp(distance, 0, length))
&& Record(distance);
} }
var edge = End - Start; var edge = End - Start;
var relativeStart = Start - origin; var relativeStart = Start - origin;
@@ -310,19 +371,36 @@ internal static class PostVerificationGeometry
var b = Dot(End - origin, direction); var b = Dot(End - origin, direction);
var low = System.Math.Max(0, System.Math.Min(a, b)); var low = System.Math.Max(0, System.Math.Min(a, b));
var high = System.Math.Min(length, System.Math.Max(a, b)); var high = System.Math.Min(length, System.Math.Max(a, b));
return high > Epsilon && low <= high + Epsilon; return high > Epsilon && low <= high + Epsilon && Record(low) && Record(high);
} }
var distanceAlongRapid = Cross(relativeStart, edge) / denominator; var distanceAlongRapid = Cross(relativeStart, edge) / denominator;
var fractionAlongEdge = Cross(relativeStart, direction) / denominator; var fractionAlongEdge = Cross(relativeStart, direction) / denominator;
return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon
&& fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon) && fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon)
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon); && fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon)
&& Record(distanceAlongRapid);
bool Record(double distance)
{
contacts?.Add(System.Math.Clamp(distance, 0, length));
return true;
}
} }
internal bool CrossesRay(Vector point) internal bool CrossesRay(Vector point)
{ {
// Ray parity needs both edges meeting at a shared vertex to agree which side of the ray
// that vertex is on. Rings are only nominally closed: capture admits closure seams and
// PEP-converted or rotated geometry carries sub-micro-inch vertex jogs, so an exact
// comparison can split one vertex across the ray and report far-away exterior points as
// interior — which made LeadPathValidator refuse leads that could touch nothing.
// Snapping within the admitted seam tolerance keeps the decision consistent on both
// edges; real features (notches, tabs, arc spans) are far larger than the tolerance and
// still count. Containment of a point that itself lies within the tolerance of a
// boundary edge stays ambiguous by design, exactly as it was before.
var rayY = point.Y + RaySeamTolerance;
if (Center is not { } center) if (Center is not { } center)
return (Start.Y > point.Y) != (End.Y > point.Y) return (Start.Y > rayY) != (End.Y > rayY)
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X; && Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
// Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same // Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
@@ -343,7 +421,7 @@ internal static class PostVerificationGeometry
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i]; var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a); var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b); var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
if ((ya > point.Y) == (yb > point.Y)) if ((ya > rayY) == (yb > rayY))
continue; continue;
var dy = point.Y - center.Y; var dy = point.Y - center.Y;
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy)); var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics; namespace OpenNest.Diagnostics;
@@ -15,7 +16,15 @@ public enum PostVerificationKind
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary> /// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber, public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber,
int? PartNumber, int? OtherPartNumber, string Message); int? PartNumber, int? OtherPartNumber, string Message)
{
public Vector? Location { get; init; }
public Vector? RapidStart { get; init; }
public Vector? RapidEnd { get; init; }
/// <summary>Rapid boundary contacts in travel order; collinear contact spans use their endpoints.</summary>
public IReadOnlyList<Vector> ContactPoints { get; init; } = Array.Empty<Vector>();
public PlateOverlapPair Overlap { get; init; }
}
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary> /// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
public sealed class PostVerificationReport public sealed class PostVerificationReport
@@ -29,10 +38,12 @@ public sealed class PostVerificationReport
public bool HasWarnings => Findings.Count != 0; public bool HasWarnings => Findings.Count != 0;
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged; public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
public string ToDisplayText() public string ToDisplayText() => ToDisplayText("Pre-post verification");
public string ToDisplayText(string title)
{ {
var text = new StringBuilder(); var text = new StringBuilder();
text.AppendLine("Pre-post verification"); text.AppendLine(title);
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete); var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
Summary(PostVerificationKind.Overlap, "Overlap"); Summary(PostVerificationKind.Overlap, "Overlap");
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins"); Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
@@ -0,0 +1,29 @@
using System;
using System.Linq;
namespace OpenNest.Sequencing
{
/// <summary>Moves one existing part to a one-based cutting sequence position.</summary>
public static class ManualPartSequencing
{
public static bool Move(Plate plate, Part part, int sequenceNumber)
{
ArgumentNullException.ThrowIfNull(plate);
ArgumentNullException.ThrowIfNull(part);
var order = plate.Parts.ToList();
if (sequenceNumber < 1 || sequenceNumber > order.Count)
return false;
var oldIndex = order.FindIndex(p => ReferenceEquals(p, part));
if (oldIndex < 0)
return false;
if (oldIndex == sequenceNumber - 1)
return true;
order.RemoveAt(oldIndex);
order.Insert(sequenceNumber - 1, part);
plate.Parts.Reorder(order);
return true;
}
}
}
+46
View File
@@ -34,4 +34,50 @@ public interface INestRepository
Guid id, NestRecord record, CancellationToken cancellationToken = default); Guid id, NestRecord record, CancellationToken cancellationToken = default);
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>Reads bounded immutable history; unsupported backends fail closed.</summary>
Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support nest version history.");
Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support nest version history.");
Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded metadata edits.");
Task<NestRecord?> GetDeletedMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nests.");
Task<byte[]?> GetDeletedFileAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nests.");
Task<NestPage> QueryDeletedAsync(NestQuery query, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nests.");
Task<NestRevisionPage> GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nest history.");
Task<byte[]?> GetDeletedRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nest history.");
Task<NestRecord> DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded deletion.");
Task<NestRecord> RestoreAsync(Guid id, int sourceRevision, int expectedRevision,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded restoration.");
Task<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded recovery.");
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded saves. Upgrade the nest server and reload.");
} }
+4 -1
View File
@@ -15,7 +15,8 @@ public sealed class NestBrowseSession : IDisposable
private int _generation; private int _generation;
private bool _disposed; private bool _disposed;
public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit) public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit,
NestSortField sort = NestSortField.SavedAt, bool descending = true)
{ {
ArgumentNullException.ThrowIfNull(repository); ArgumentNullException.ThrowIfNull(repository);
if (pageSize < 1 || pageSize > NestQuery.MaxLimit) if (pageSize < 1 || pageSize > NestQuery.MaxLimit)
@@ -23,6 +24,8 @@ public sealed class NestBrowseSession : IDisposable
_repository = repository; _repository = repository;
PageSize = pageSize; PageSize = pageSize;
Sort = Enum.IsDefined(sort) ? sort : NestSortField.SavedAt;
Descending = descending;
} }
public int PageSize { get; } public int PageSize { get; }
+182
View File
@@ -0,0 +1,182 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>
/// URL-bound, bounded history browsing. Historical reads never bind an editable document.
/// A selection change cancels and invalidates previous reads even if a backend ignores cancellation.
/// Mutations use the displayed current revision; an uncertain failure requires an explicit refresh.
/// </summary>
public sealed class NestHistorySession : IDisposable
{
private readonly INestRepository repository;
private CancellationTokenSource? pending;
private int generation;
private bool disposed;
public string ServerUrl { get; }
public Guid NestId { get; private set; }
public bool Deleted { get; private set; }
public NestRecord? Current { get; private set; }
public NestRevisionPage? Page { get; private set; }
public bool IsMutating { get; private set; }
public const int PageSize = 50;
public NestHistorySession(INestRepository repository, string serverUrl)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
ServerUrl = Normalize(serverUrl);
}
public async Task<bool> LoadAsync(Guid id, bool deleted = false, int offset = 0,
CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(disposed, this);
if (IsMutating) throw new InvalidOperationException("Wait for the current operation to finish.");
if (id == Guid.Empty || offset < 0) throw new ArgumentOutOfRangeException(nameof(id));
pending?.Cancel();
var run = ++generation;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
pending = cts;
NestId = id;
Deleted = deleted;
Current = null;
Page = null;
try
{
var record = deleted ? await repository.GetDeletedMetadataAsync(id, cts.Token)
: await repository.GetMetadataAsync(id, cts.Token);
if (record == null) throw new FileNotFoundException("This nest is no longer in this view. Refresh the list.");
ValidateRecord(record, id, deleted);
if (record.Revision < 1) throw new NotSupportedException("Upgrade the nest server to browse or change version history. Current nests can still be opened read-only.");
var page = deleted ? await repository.GetDeletedRevisionsAsync(id, offset, PageSize, cts.Token)
: await repository.GetRevisionsAsync(id, offset, PageSize, cts.Token);
if (page.Offset != offset || page.Items.Count > PageSize || page.Total < offset + page.Items.Count
|| page.Items.Any(r => r.Id != id || r.Revision < 1))
throw new InvalidDataException("The server returned invalid nest history.");
if (page.Items.Any(r => r.Revision > record.Revision))
throw new IOException("The current version changed while loading history. Refresh to review the new current version.");
if (run != generation || cts.IsCancellationRequested || disposed) return false;
Current = record;
Page = page;
return true;
}
catch (Exception) when (run != generation || cts.IsCancellationRequested || disposed) { return false; }
finally { if (ReferenceEquals(pending, cts)) pending = null; }
}
public async Task<byte[]?> DownloadAsync(int revision, string serverUrl, CancellationToken ct = default)
{
EnsureReady(serverUrl);
var id = NestId;
var run = generation;
var snapshot = Page!.Items.SingleOrDefault(r => r.Revision == revision)
?? throw new InvalidOperationException("Select a revision on the displayed page.");
var file = Deleted ? await repository.GetDeletedRevisionFileAsync(id, revision, ct)
: await repository.GetRevisionFileAsync(id, revision, ct);
ct.ThrowIfCancellationRequested();
if (disposed || run != generation) return null;
if (file == null || file.LongLength != snapshot.FileSize)
throw new InvalidDataException("The revision archive is missing or does not match its metadata. Refresh before trying again.");
return file;
}
public async Task<NestRecord> RestoreAsync(int sourceRevision, string serverUrl, CancellationToken ct = default)
{
EnsureReady(serverUrl);
if (Deleted) throw new InvalidOperationException("Recover the deleted nest before restoring an earlier version.");
if (!Page!.Items.Any(r => r.Revision == sourceRevision)) throw new InvalidOperationException("Select a displayed revision.");
return await MutateAsync(() => repository.RestoreAsync(NestId, sourceRevision, Current!.Revision, ct), false, ct);
}
public async Task<NestRecord> RecoverAsync(string serverUrl, CancellationToken ct = default)
{
EnsureReady(serverUrl);
if (!Deleted) throw new InvalidOperationException("This nest is not deleted.");
return await MutateAsync(() => repository.RecoverAsync(NestId, Current!.Revision, ct), false, ct);
}
public async Task<NestRecord> DeleteAsync(string serverUrl, CancellationToken ct = default)
{
EnsureReady(serverUrl);
if (Deleted) throw new InvalidOperationException("This nest is already deleted.");
return await MutateAsync(() => repository.DeleteAsync(NestId, Current!.Revision, ct), true, ct);
}
private async Task<NestRecord> MutateAsync(Func<Task<NestRecord>> write, bool deleted, CancellationToken ct)
{
IsMutating = true;
var expected = Current!.Revision;
try
{
var result = await write();
ValidateRecord(result, NestId, deleted);
if (result.Revision != expected + 1) throw new InvalidDataException("Invalid mutation revision. Refresh to reconcile.");
// Verify the exact target after the write; do not declare success from the response alone.
var verified = deleted ? await repository.GetDeletedMetadataAsync(NestId, ct)
: await repository.GetMetadataAsync(NestId, ct);
if (verified == null || verified.Id != NestId || verified.Revision < result.Revision || verified.Deleted != deleted)
throw new InvalidDataException("Could not verify the new current revision. Refresh to reconcile.");
ct.ThrowIfCancellationRequested();
if (disposed) throw new OperationCanceledException("The history browser was closed. Reopen to reconcile.");
Current = verified;
Deleted = deleted;
Page = null;
return result;
}
catch { Current = null; throw; }
finally { IsMutating = false; }
}
private void EnsureReady(string url)
{
ObjectDisposedException.ThrowIf(disposed, this);
if (Normalize(url) != ServerUrl) throw new InvalidOperationException("The nest server changed. Close this browser and reopen it on the selected server.");
if (Current == null || IsMutating) throw new InvalidOperationException("Refresh the current version before continuing.");
}
private static void ValidateRecord(NestRecord record, Guid id, bool deleted)
{
if (record.Id != id || record.Deleted != deleted) throw new InvalidDataException("The server returned a different nest identity or visibility.");
}
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
/// <summary>Read an immutable current snapshot and ensure metadata did not change during download.</summary>
public static async Task<NestCurrentSnapshot> ReadCurrentAsync(INestRepository repository, Guid id,
string serverUrl, CancellationToken ct = default)
{
var before = await repository.GetMetadataAsync(id, ct)
?? throw new FileNotFoundException("This nest no longer exists on the server.");
ValidateRecord(before, id, false);
var beforeMetadata = JsonSerializer.Serialize(before);
var file = before.Revision > 0 ? await repository.GetRevisionFileAsync(id, before.Revision, ct)
: await repository.GetFileAsync(id, ct);
var after = await repository.GetMetadataAsync(id, ct);
ct.ThrowIfCancellationRequested();
if (after == null || JsonSerializer.Serialize(after) != beforeMetadata
|| file == null || file.LongLength != before.FileSize)
throw new InvalidDataException("The current nest changed or its archive does not match its metadata. Refresh and open it again.");
return new NestCurrentSnapshot(before, file, Normalize(serverUrl));
}
public void Clear()
{
if (IsMutating) throw new InvalidOperationException("Wait for the current operation to finish.");
++generation;
pending?.Cancel();
NestId = Guid.Empty;
Current = null;
Page = null;
}
public void Dispose()
{
disposed = true;
++generation;
pending?.Cancel();
Current = null;
Page = null;
}
}
public sealed record NestCurrentSnapshot(NestRecord Metadata, byte[] Archive, string ServerUrl);
+8
View File
@@ -11,6 +11,14 @@ public sealed class NestRecord
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
/// <summary>Server revision; zero means the server does not support history.</summary>
public int Revision { get; set; }
public bool Deleted { get; set; }
/// <summary>Selected historical revision promoted by an explicit Restore.</summary>
public int? RestoredFromRevision { get; set; }
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public string Customer { get; set; } = ""; public string Customer { get; set; } = "";
+12
View File
@@ -0,0 +1,12 @@
namespace OpenNest.Data;
/// <summary>A bounded page of immutable metadata snapshots, newest first.</summary>
public sealed class NestRevisionPage
{
public const int DefaultLimit = 100;
public const int MaxLimit = 200;
public IReadOnlyList<NestRecord> Items { get; init; } = Array.Empty<NestRecord>();
public int Total { get; init; }
public int Offset { get; init; }
public int Limit { get; init; }
}
+87 -7
View File
@@ -9,6 +9,21 @@ public sealed class NestSaveSession
{ {
public Guid RemoteId { get; private set; } public Guid RemoteId { get; private set; }
public string ServerUrl { get; private set; } = ""; public string ServerUrl { get; private set; } = "";
public int Revision { get; private set; }
public bool RequiresReload { get; private set; }
public Guid PendingCreateId { get; private set; }
public bool PendingCreateWasCopy => PendingCreateId != Guid.Empty && pendingCreateWasCopy;
private string pendingCreateUrl = "";
private byte[]? pendingCreateArchive;
private NestRecord? pendingCreateMetadata;
private bool pendingCreateWasCopy;
public void Bind(Guid id, string serverUrl, int revision)
{
if (revision < 0) throw new ArgumentOutOfRangeException(nameof(revision));
Bind(id, serverUrl);
Revision = revision;
}
public void Bind(Guid id, string serverUrl) public void Bind(Guid id, string serverUrl)
{ {
@@ -16,6 +31,36 @@ public sealed class NestSaveSession
throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id)); throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id));
RemoteId = id; RemoteId = id;
ServerUrl = Normalize(serverUrl); ServerUrl = Normalize(serverUrl);
Revision = 0; // Legacy reads remain viewable, but must not become guarded saves.
RequiresReload = false;
PendingCreateId = Guid.Empty;
pendingCreateArchive = null;
pendingCreateMetadata = null;
pendingCreateWasCopy = false;
pendingCreateUrl = "";
}
/// <summary>Explicitly reconcile an upload whose reply was lost before any retry.</summary>
public async Task<NestRecord?> ReconcilePendingCreateAsync(INestRepository repository,
string serverUrl, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
if (PendingCreateId == Guid.Empty || Normalize(serverUrl) != pendingCreateUrl)
throw new InvalidOperationException("No pending upload on this server to reconcile.");
var record = await repository.GetMetadataAsync(PendingCreateId, cancellationToken);
if (record is null)
{
// A deliberate subsequent Save may retry this SAME ID, never create a second ID.
RequiresReload = false;
return null;
}
var bytes = await repository.GetFileAsync(PendingCreateId, cancellationToken);
if (record.Id != PendingCreateId || record.Revision < 1 || bytes is null
|| !bytes.AsSpan().SequenceEqual(pendingCreateArchive)
|| !SameEditableMetadata(record, pendingCreateMetadata!))
throw new IOException("The pending upload differs from the server record. Reopen the server nest before editing.");
Bind(record.Id, pendingCreateUrl, record.Revision);
return record;
} }
public async Task<NestRecord> SaveAsync( public async Task<NestRecord> SaveAsync(
@@ -30,17 +75,52 @@ public sealed class NestSaveSession
if (normalizedUrl.Length == 0) if (normalizedUrl.Length == 0)
throw new ArgumentException("A database server URL is required.", nameof(serverUrl)); throw new ArgumentException("A database server URL is required.", nameof(serverUrl));
if (PendingCreateId != Guid.Empty && (RequiresReload || normalizedUrl != pendingCreateUrl
|| saveCopy != pendingCreateWasCopy))
throw new IOException("Reconcile the pending upload on its original server, then retry with the same Save or Save As action.");
var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty; var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty;
var record = NestRecordFactory.FromNest(nest, id, archive.LongLength); var creating = PendingCreateId != Guid.Empty || id == Guid.Empty;
var saved = id == Guid.Empty var uploadId = creating ? (PendingCreateId == Guid.Empty ? Guid.NewGuid() : PendingCreateId) : id;
? await repository.UploadAsync(archive, record, cancellationToken) var record = NestRecordFactory.FromNest(nest, uploadId, archive.LongLength);
: await repository.UpdateFileAsync(id, archive, record, cancellationToken); if (!creating && (Revision < 1 || RequiresReload))
if (saved.Id == Guid.Empty || (id != Guid.Empty && saved.Id != id)) throw new IOException("Reload the current nest revision before saving, or Save As a new nest. Upgrade the nest server if version history is unavailable.");
throw new InvalidDataException("Server returned an invalid nest id."); if (creating)
{
PendingCreateId = uploadId;
pendingCreateUrl = normalizedUrl;
pendingCreateArchive = archive.ToArray();
pendingCreateMetadata = record;
pendingCreateWasCopy = saveCopy;
}
NestRecord saved;
try
{
saved = creating
? await repository.UploadAsync(archive, record, cancellationToken)
: await repository.UpdateFileAsync(id, archive, record, Revision, cancellationToken);
}
catch (Exception ex) when (ex is IOException or System.Net.Http.HttpRequestException or OperationCanceledException or System.Text.Json.JsonException)
{
// The request may have committed before its reply was lost. Do not replay
// pending bytes until the operator explicitly reloads/reconciles the record.
RequiresReload = true;
throw;
}
if (saved.Id != uploadId || (!creating && saved.Revision < Revision))
{
RequiresReload = true;
throw new InvalidDataException("Server returned an invalid nest id or revision. Reload to reconcile before saving again.");
}
Bind(saved.Id, normalizedUrl); Bind(saved.Id, normalizedUrl, saved.Revision);
return saved; return saved;
} }
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/'); private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
private static bool SameEditableMetadata(NestRecord a, NestRecord b) =>
a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated
&& a.DateModified == b.DateModified && a.Material == b.Material && a.Thickness == b.Thickness
&& a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount
&& a.Comments == b.Comments && a.MadeBy == b.MadeBy;
} }
+12
View File
@@ -32,6 +32,18 @@ public sealed class NestStorageSettings
public bool IsDatabaseMode => public bool IsDatabaseMode =>
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl); Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
/// <summary>Validate and normalize a nest server base URL without changing storage mode.</summary>
public static bool TryNormalizeServerUrl(string? url, out string normalized)
{
normalized = NormalizeUrl(url);
if (Uri.TryCreate(normalized, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return true;
normalized = "";
return false;
}
/// <summary>%APPDATA%\OpenNest\storage.json.</summary> /// <summary>%APPDATA%\OpenNest\storage.json.</summary>
public static string DefaultPath => Path.Combine( public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+154 -17
View File
@@ -59,7 +59,13 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
return items ?? new List<NestRecord>(); return items ?? new List<NestRecord>();
} }
public async Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default) public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default) =>
QueryCoreAsync(query, false, cancellationToken);
public Task<NestPage> QueryDeletedAsync(NestQuery query, CancellationToken cancellationToken = default) =>
QueryCoreAsync(query, true, cancellationToken);
private async Task<NestPage> QueryCoreAsync(NestQuery query, bool deleted, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(query); ArgumentNullException.ThrowIfNull(query);
var error = query.GetValidationError(); var error = query.GetValidationError();
@@ -67,7 +73,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
throw new ArgumentException(error, nameof(query)); throw new ArgumentException(error, nameof(query));
using var response = await _httpClient.GetAsync( using var response = await _httpClient.GetAsync(
Url("api/nests/query?" + BuildQueryString(query)), cancellationToken); Url((deleted ? "api/nests/deleted/query?" : "api/nests/query?") + BuildQueryString(query)), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) if (response.StatusCode == HttpStatusCode.NotFound)
{ {
throw new IOException( throw new IOException(
@@ -93,18 +99,30 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
+ "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture) + "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture)
+ "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture); + "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture);
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
GetMetadataCoreAsync(id, false, cancellationToken);
public Task<NestRecord?> GetDeletedMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
GetMetadataCoreAsync(id, true, cancellationToken);
private async Task<NestRecord?> GetMetadataCoreAsync(Guid id, bool deleted, CancellationToken cancellationToken)
{ {
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken); using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) if (response.StatusCode == HttpStatusCode.NotFound)
return null; return null;
await EnsureSuccess(response, $"get nest {id}", cancellationToken); await EnsureSuccess(response, $"get nest {id}", cancellationToken);
return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken); return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
} }
public async Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default) public Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default) =>
GetFileCoreAsync(id, false, cancellationToken);
public Task<byte[]?> GetDeletedFileAsync(Guid id, CancellationToken cancellationToken = default) =>
GetFileCoreAsync(id, true, cancellationToken);
private async Task<byte[]?> GetFileCoreAsync(Guid id, bool deleted, CancellationToken cancellationToken)
{ {
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/file"), cancellationToken); using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/file"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) if (response.StatusCode == HttpStatusCode.NotFound)
return null; return null;
await EnsureSuccess(response, $"download nest {id}", cancellationToken); await EnsureSuccess(response, $"download nest {id}", cancellationToken);
@@ -123,26 +141,145 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
public async Task<NestRecord> UpdateFileAsync( public async Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default) Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
{ {
using var content = BuildMultipart(nestFile, record); return await UpdateFileAsync(id, nestFile, record, record.Revision, cancellationToken);
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/file"), content, cancellationToken);
await EnsureSuccess(response, $"update nest {id}", cancellationToken);
return await ReadRecord(response, cancellationToken);
} }
public async Task<NestRecord> UpdateMetadataAsync( public async Task<NestRecord> UpdateMetadataAsync(
Guid id, NestRecord record, CancellationToken cancellationToken = default) Guid id, NestRecord record, CancellationToken cancellationToken = default)
{ {
using var content = new StringContent( return await UpdateMetadataAsync(id, record, record.Revision, cancellationToken);
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/metadata"), content, cancellationToken);
await EnsureSuccess(response, $"update nest metadata {id}", cancellationToken);
return await ReadRecord(response, cancellationToken);
} }
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{ {
using var response = await _httpClient.DeleteAsync(Url($"api/nests/{id}"), cancellationToken); await Task.CompletedTask;
await EnsureSuccess(response, $"delete nest {id}", cancellationToken); throw new IOException("Deletion requires guarded soft-delete support and is not available yet. Upgrade the client before deleting a server nest.");
}
public Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) => GetHistoryAsync(id, offset, limit, false, cancellationToken);
public Task<NestRevisionPage> GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) => GetHistoryAsync(id, offset, limit, true, cancellationToken);
private async Task<NestRevisionPage> GetHistoryAsync(Guid id, int offset, int limit, bool deleted,
CancellationToken cancellationToken)
{
ValidateId(id);
if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
if (limit < 1 || limit > NestRevisionPage.MaxLimit) throw new ArgumentOutOfRangeException(nameof(limit));
using var response = await _httpClient.GetAsync(
Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new IOException("Nest history is unavailable or this nest no longer exists. Upgrade the nest server if it does not support version history, then reload.");
await EnsureSuccess(response, $"read nest history {id}", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<NestRevisionPage>(JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty history page.");
if (page.Items is null || page.Items.Count > limit || page.Offset != offset || page.Limit != limit
|| page.Total < 0 || page.Items.Count > System.Math.Max(0L, (long)page.Total - offset)
|| page.Items.Any(item => item is null || item.Revision < 1 || item.Id != id)
|| page.Items.Zip(page.Items.Skip(1)).Any(pair => pair.First.Revision <= pair.Second.Revision))
throw new IOException("Nest server returned an invalid or unbounded history page.");
return page;
}
public Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) => GetHistoricalFileAsync(id, revisionNumber, false, cancellationToken);
public Task<byte[]?> GetDeletedRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) => GetHistoricalFileAsync(id, revisionNumber, true, cancellationToken);
private async Task<byte[]?> GetHistoricalFileAsync(Guid id, int revisionNumber, bool deleted,
CancellationToken cancellationToken)
{
ValidateId(id);
ValidateRevision(revisionNumber);
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions/{revisionNumber.ToString(CultureInfo.InvariantCulture)}/file"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
await EnsureSuccess(response, $"download nest {id} revision {revisionNumber}", cancellationToken);
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
public async Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default)
{
ValidateId(id);
if (expectedRevision < 1)
throw new IOException("This nest has no known revision. Upgrade the nest server and reload before saving, or Save As a new nest.");
// A legacy server can ignore If-Match and overwrite data. Prove history support
// before sending any mutation; never infer revision 1 from a legacy read.
var history = await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
if (history.Items.Count == 0)
throw new IOException("Nest server returned no revision history. Upgrade the nest server and reload before saving.");
using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/file"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
request.Content = BuildMultipart(nestFile, record);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"update nest {id}; reload the current version or Save As after a conflict", cancellationToken);
var saved = await ReadRecord(response, cancellationToken);
if (saved.Id != id || saved.Revision < expectedRevision)
throw new IOException("Nest server returned an invalid saved revision. Reload to reconcile before saving again.");
return saved;
}
public async Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default)
{
ValidateId(id);
ValidateRevision(expectedRevision);
await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/metadata"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
request.Content = JsonContent.Create(record, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"edit nest {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public async Task<NestRecord> RestoreAsync(Guid id, int sourceRevision, int expectedRevision,
CancellationToken cancellationToken = default)
{
ValidateId(id);
ValidateRevision(sourceRevision);
ValidateRevision(expectedRevision);
await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Post,
Url($"api/nests/{id}/revisions/{sourceRevision.ToString(CultureInfo.InvariantCulture)}/restore"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"restore nest {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public Task<NestRecord> DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
ChangeVisibilityAsync(id, expectedRevision, true, cancellationToken);
public Task<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
ChangeVisibilityAsync(id, expectedRevision, false, cancellationToken);
private async Task<NestRecord> ChangeVisibilityAsync(Guid id, int expectedRevision, bool deleted,
CancellationToken cancellationToken)
{
ValidateId(id);
ValidateRevision(expectedRevision);
// A legacy server cannot be trusted to preserve history on DELETE.
await GetHistoryAsync(id, 0, 1, !deleted, cancellationToken);
using var request = new HttpRequestMessage(deleted ? HttpMethod.Delete : HttpMethod.Post,
Url(deleted ? $"api/nests/{id}" : $"api/nests/deleted/{id}/recover"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"change nest visibility {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
private static void ValidateId(Guid id)
{
if (id == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(id));
}
private static void ValidateRevision(int revisionNumber)
{
if (revisionNumber < 1) throw new ArgumentOutOfRangeException(nameof(revisionNumber));
} }
public void Dispose() public void Dispose()
@@ -0,0 +1,154 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutNfpProposalTests
{
private const double Spacing = 0.25;
[Fact]
public void LargeInsertFitsRoundCutoutAndRemainsValidAfterFrameRotation()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1, RotationPolicy.Fixed(System.Math.PI / 2));
var insert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(0));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.Equal(insert.Id, pose.PartId);
Assert.Equal(0, pose.InstanceIndex);
Assert.True(insert.Rotation.Allows(pose.Rotation));
AssertSquareInRoundHole(pose, 6, Spacing);
var localFrame = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), localFrame,
JobPartGeometry.Read(insert.Geometry), pose, Spacing));
var framePose = new NestJobPlacement(frame.Id, 0, 11, 11, System.Math.PI / 2);
var movedInsert = pose with
{
X = framePose.X - pose.Y,
Y = framePose.Y + pose.X,
Rotation = pose.Rotation + framePose.Rotation,
};
// This insert is fixed at zero for the local proposal; the rotated variant needs
// a correspondingly legal global policy before it can be handed to a job.
var rotatedInsert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(System.Math.PI / 2));
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), framePose,
JobPartGeometry.Read(insert.Geometry), movedInsert, Spacing));
AssertSquareInRoundHole(movedInsert, 6, Spacing, framePose.X, framePose.Y);
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
var job = JobBuilder.Job(new[] { frame, rotatedInsert }, new[] { stock });
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { framePose, movedInsert }) },
new[] { new PartFulfillment(frame.Id, 1, 1, 0), new PartFulfillment(insert.Id, 1, 1, 0) },
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
}
[Fact]
public void LargeInsertFitsSquareCutoutWithoutUsingFillLattice()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
var frame = JobBuilder.Part("square-frame", program, 1);
var insert = JobBuilder.Rectangle("large", 9, 9, 1, RotationPolicy.Fixed(0));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.All(new[] { (0.0, 0.0), (9.0, 0.0), (9.0, 9.0), (0.0, 9.0) }, corner =>
{
Assert.InRange(pose.X + corner.Item1, 5 + Spacing, 15 - Spacing);
Assert.InRange(pose.Y + corner.Item2, 5 + Spacing, 15 - Spacing);
});
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry),
new NestJobPlacement(frame.Id, 0, 0, 0, 0), JobPartGeometry.Read(insert.Geometry), pose, Spacing));
Assert.Null(CutoutNfpProposal.Find(frame, 0, JobBuilder.Rectangle("oversized", 9.6, 9.6, 1),
Spacing));
}
[Fact]
public void ObliqueFixedInsertRotationIsUsedInInnerFit()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
var frame = JobBuilder.Part("frame", program, 1);
var insert = JobBuilder.Rectangle("insert", 3, 2, 1, RotationPolicy.Fixed(System.Math.PI / 4));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.True(insert.Rotation.Allows(pose.Rotation));
foreach (var (x, y) in new[] { (0.0, 0.0), (3.0, 0.0), (3.0, 2.0), (0.0, 2.0) })
{
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
Assert.InRange(rx, 5 + Spacing, 15 - Spacing);
Assert.InRange(ry, 5 + Spacing, 15 - Spacing);
}
}
[Theory]
[InlineData(9.51)]
[InlineData(10.0)]
[InlineData(9.6)]
public void OversizedInsertIsNeverProposed(double side)
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", side, side, 1);
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, Spacing));
}
[Fact]
public void UnsupportedCoordinateScaleFailsClosed()
{
const double scale = 1e15;
var program = TestShapes.Polyline((-scale, -scale), (scale, -scale),
(scale, scale), (-scale, scale));
program.MoveTo(-scale / 2, -scale / 2);
program.LineTo(-scale / 2, scale / 2);
program.LineTo(scale / 2, scale / 2);
program.LineTo(scale / 2, -scale / 2);
program.LineTo(-scale / 2, -scale / 2);
Assert.Null(CutoutNfpProposal.Find(JobBuilder.Part("huge", program, 1), 0,
JobBuilder.Rectangle("insert", 6, 6, 1), Spacing));
}
[Fact]
public void ImpossibleHugeSpacingFailsBeforeAnyOffset()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, 1e12));
}
private static void AssertSquareInRoundHole(NestJobPlacement pose, double side, double spacing,
double centerX = 0, double centerY = 0)
{
var sin = System.Math.Sin(pose.Rotation);
var cos = System.Math.Cos(pose.Rotation);
foreach (var (x, y) in new[] { (0.0, 0.0), (side, 0.0), (side, side), (0.0, side) })
{
var dx = pose.X + x * cos - y * sin - centerX;
var dy = pose.Y + x * sin + y * cos - centerY;
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) <= 5 - spacing + 0.00001,
$"corner ({dx}, {dy}) does not clear the circular wall");
}
}
[Fact]
public void CancellationIsNotReportedAsNoFit()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
Assert.Throws<OperationCanceledException>(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing,
cancelled.Token));
}
}
@@ -0,0 +1,749 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
[CollectionDefinition("Cutout pipeline registry", DisableParallelization = true)]
public sealed class CutoutPipelineRegistryCollection { }
[Collection("Cutout pipeline registry")]
public class CutoutPipelinePreviewTests
{
private static NestPipelineRequest Request(string engine = "Irregular")
{
var frame = new Drawing("frame", TestShapes.Ring(20, 10));
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
return new NestPipelineRequest(engine,
new[] { new NestItem { Drawing = frame, Quantity = 1 },
new NestItem { Drawing = insert, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
}
[Theory]
[InlineData("Irregular")]
[InlineData("Default")]
public void RegisteredEngineExpandsOnlyPhysicalOriginalDrawings(string engine)
{
var request = Request(engine);
var result = NestPipeline.RunCutoutPreview(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.True(result.CanKeep);
var plate = Assert.Single(result.Plates);
Assert.Equal(2, plate.Parts.Count);
Assert.Same(request.Items[0].Drawing, plate.Parts[0].BaseDrawing);
Assert.Same(request.Items[1].Drawing, plate.Parts[1].BaseDrawing);
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates[0].Placements.Select(p => p.PartId));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 1, 0), (f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(0, request.Items[0].Drawing.Quantity.Nested);
Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested);
}
private sealed class Stub(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => solve(job);
}
private static NestJobResult Result(NestJob job, params NestJobPlacement[] poses)
{
var placed = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count());
var complete = job.Parts.All(p => placed.GetValueOrDefault(p.Id) == p.Quantity);
return new NestJobResult(complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
complete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound,
poses.Length == 0 ? Array.Empty<NestJobPlateResult>() :
new[] { new NestJobPlateResult(0, job.Plates[0], poses) },
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity,
placed.GetValueOrDefault(p.Id), p.Quantity - placed.GetValueOrDefault(p.Id))),
new[] { new StockUsage(job.Plates[0].Id, poses.Length == 0 ? 0 : 1,
job.Plates[0].Quantity - (poses.Length == 0 ? 0 : 1)) });
}
[Fact]
public void SingleSolveReceivesOneProxyAndNoDuplicatedInsertDemand()
{
var called = 0;
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
called++;
var proxy = Assert.Single(job.Parts);
Assert.StartsWith("__cutout-proxy-", proxy.Id);
Assert.True(proxy.Rotation.Allows(0));
Assert.False(proxy.Rotation.Allows(System.Math.PI / 2));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request);
Assert.Equal(1, called);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Single().Parts.Count);
}
[Fact]
public void IncompleteEngineDoesNotBindReservedInsertEvenWithInvalidConsent()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job => Result(job)), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("did not complete"));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 0, 1),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested);
AssertCannotCommit(result, request);
}
[Fact]
public void ForgedFulfillmentOrDuplicateIndexDoesNotBindComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var id = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")).Id;
var pose = new NestJobPlacement(id, 1, 11, 11, 0);
return Result(job, pose);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
}
[Fact]
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
{
var request = Request("stub");
request.Items[1].Quantity = 2;
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } };
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var residual = job.Parts.Single(p => p.Id == "part-1");
Assert.Equal(1, residual.Quantity);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement(residual.Id, 0, 22, 1, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.Single().Placements
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
Assert.All(result.Plates.Single().Parts.Where(p => p.BaseDrawing == request.Items[1].Drawing),
p => Assert.Same(request.Items[1].Drawing, p.BaseDrawing));
}
[Fact]
public void UntrustedStockOverdrawOrRotationCannotBindAComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11,
System.Math.PI / 2));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("rotation constraint"));
var overdraw = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, job.Plates[0], new[] {
new NestJobPlacement(job.Parts[0].Id, 0, 11, 11, 0) }),
new NestJobPlateResult(1, job.Plates[0], new[] {
new NestJobPlacement(job.Parts[0].Id, 1, 11, 11, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>())), "stub", request);
Assert.False(overdraw.CanKeep);
Assert.Empty(overdraw.Plates);
Assert.Contains(overdraw.Violations, v => v.Contains("only 1 are available"));
}
[Fact]
public void CancellationBeforePreparationDoesNotCallEngine()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
var engine = new Stub(_ => throw new Exception("Must not call Solve"));
Assert.Throws<OperationCanceledException>(() =>
NestPipeline.RunCutoutPreview(engine, "stub", Request("stub"), token: cts.Token));
}
[Fact]
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
{
var request = Request("stub");
request.Items[1].Quantity = 2;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement("part-1", 0, 11, 11, 0));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("required spacing"));
AssertCannotCommit(result, request);
}
private static void AssertCannotCommit(NestPipelineResult result, NestPipelineRequest request)
{
var nest = new Nest();
foreach (var item in request.Items)
nest.Drawings.Add(item.Drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
Assert.Throws<InvalidOperationException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
}
private sealed class CollectProgress : IProgress<NestJobProgress>
{
public List<NestJobProgress> Events { get; } = new();
public void Report(NestJobProgress value) => Events.Add(value);
}
[Fact]
public void EvaluationProgressSurvivesButUnverifiedCommitsNeverEscape()
{
var request = Request("stub");
var progress = new CollectProgress();
var result = NestPipeline.RunCutoutPreview(new ProgressStub(), "stub", request, progress);
Assert.False(result.CanKeep);
Assert.Single(progress.Events);
Assert.Equal(NestJobStage.EvaluatingCandidate, progress.Events[0].Stage);
Assert.Equal(0, progress.Events[0].CommittedPlates);
Assert.Equal(0, progress.Events[0].CommittedParts);
}
private sealed class ProgressStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
job.Plates[0].Id, 0, 1, 1));
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
job.Plates[0].Id, 0, 1, 1));
return Result(job);
}
}
[Fact]
public void ReorderedButContiguousEngineIndicesAreAccepted()
{
var request = Request("stub");
request.Items[1].Quantity = 3;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) } };
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
Assert.Equal(2, job.Parts.Single(p => p.Id == "part-1").Quantity);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement("part-1", 1, 22, 1, 0),
new NestJobPlacement("part-1", 0, 22, 9, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { 0, 1, 2 }, result.Raw.Plates.Single().Placements
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ForgedFulfillmentOrStockMetadataCannotBind(bool fulfillment)
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var good = Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
return new NestJobResult(good.Status, good.StopReason, good.Plates,
fulfillment ? new[] { new PartFulfillment(proxy.Id, 1, 0, 1) } : good.Fulfillment,
fulfillment ? good.StockUsage : new[] { new StockUsage("sheet", 0, 1) });
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
}
[Fact]
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
{
var initial = Request("stub");
var request = initial with
{
Items = new[] { initial.Items[0], initial.Items[1],
new NestItem { Drawing = initial.Items[1].Drawing, Quantity = 1 } },
Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) }
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var residual = job.Parts.Where(p => p.Id != proxy.Id)
.Select((p, i) => new NestJobPlacement(p.Id, 0, 22, 1 + 5 * i, 0));
return Result(job, new[] { new NestJobPlacement(proxy.Id, 0, 11, 11, 0) }
.Concat(residual).ToArray());
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1", "part-2" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
Assert.Same(result.Plates.Single().Parts[1].BaseDrawing,
result.Plates.Single().Parts[2].BaseDrawing);
}
[Fact]
public void RepeatedFrameReservationsExpandOnTwoPhysicalSheets()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with
{
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
Options = new NestJobOptions(maxPlates: 2)
};
var calls = 0;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
calls++;
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
Assert.Equal(2, proxies.Length);
Assert.Equal(2, job.Parts.Count);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in proxies)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.Equal(1, calls);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Raw.Plates.Count);
Assert.All(result.Raw.Plates, plate => Assert.Equal(new[] { "part-0", "part-1" },
plate.Placements.Select(p => p.PartId)));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 2, 0),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
}
[Fact]
public void UnplacedFrameReleasesInsertDemandWithoutABindablePartialProposal()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } };
var calls = 0;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
calls++;
var first = job.Parts.Single(p => p.Id.EndsWith("-0"));
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[0], new[] { (first.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.NoPlacementFound);
}), "stub", request);
Assert.Equal(1, calls);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Equal(NestJobStatus.Incomplete, result.Raw.Status);
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 1, 1),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId));
AssertCannotCommit(result, request);
}
[Fact]
public void DifferentFrameRequirementsShareScarceInsertsByIdentity()
{
var initial = Request("stub");
var frame2 = new Drawing("second-frame", TestShapes.Ring(18, 10));
initial.Items[1].Quantity = 2;
initial.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
var request = initial with
{
Items = new[] { initial.Items[0],
new NestItem { Drawing = frame2, Quantity = 1 }, initial.Items[1] },
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
Assert.Equal(2, proxies.Length);
Assert.Equal(2, job.Parts.Count);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in proxies)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-2", "part-1", "part-2" },
result.Raw.Plates.SelectMany(p => p.Placements).Select(p => p.PartId));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
.Where(p => p.PartId == "part-2").Select(p => p.InstanceIndex));
}
[Fact]
public void FixedQuarterTurnComposesLocalInsertRotationAndTranslation()
{
var frame = TestShapes.Rectangle(20, 20);
frame.MoveTo(5, 5);
frame.LineTo(5, 15);
frame.LineTo(15, 15);
frame.LineTo(15, 5);
frame.LineTo(5, 5);
var quarter = System.Math.PI / 2;
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 1,
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1,
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
Assert.True(proxy.Rotation.Allows(quarter));
Assert.False(proxy.Rotation.Allows(0));
return Result(job, new NestJobPlacement(proxy.Id, 0, 21, 1, quarter));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var poses = result.Raw.Plates.Single().Placements;
Assert.Equal(quarter, poses[0].Rotation, 8);
Assert.Equal(quarter, poses[1].Rotation, 8);
// Independent square-hole oracle, not the production clearance predicate.
var localX = poses[1].Y - poses[0].Y;
var localY = -(poses[1].X - poses[0].X);
foreach (var dx in new[] { 0.0, 3.0 })
foreach (var dy in new[] { 0.0, 3.0 })
{
Assert.InRange(localX + dx, 5.25, 14.75);
Assert.InRange(localY + dy, 5.25, 14.75);
}
}
[Fact]
public void HeterogeneousStockUsesConservativeSpacingAndOriginalQuadrant()
{
var request = Request("stub") with
{
Stock = new[] {
new NestPlateStock("tight", new Size(22, 22), 1, 0.25),
new NestPlateStock("wide", new Size(22, 22), 1, 0.6, quadrant: 3) }
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[1], new[] { (proxy.Id, -11.0, -11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal("wide", result.Raw.Plates.Single().StockId);
Assert.Equal(3, result.Plates.Single().Stock.Quadrant);
Assert.Equal(0.6, result.Plates.Single().Stock.PartSpacing);
Assert.Equal(2, result.Plates.Single().Parts.Count);
}
[Fact]
public void RegisteredPluginRunsTheSamePhysicalExpansionBoundary()
{
var name = "CutoutPlugin-" + Guid.NewGuid().ToString("N");
var calls = 0;
NestingEngineRegistry.Register(name, "test plugin", () => new Stub(job =>
{
calls++;
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}));
try
{
var result = NestPipeline.RunCutoutPreview(Request(name));
Assert.Equal(1, calls);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
}
finally
{
var registry = Assert.IsType<List<NestingEngineInfo>>(NestingEngineRegistry.AvailableEngines);
Assert.Equal(1, registry.RemoveAll(info => info.Name == name));
}
}
[Fact]
public void TwoCutoutsReserveDifferentCopiesWithinOneFrame()
{
var program = TestShapes.Rectangle(20, 20);
foreach (var origin in new[] { 2.0, 12.0 })
{
program.MoveTo(origin, origin);
program.LineTo(origin, origin + 6);
program.LineTo(origin + 6, origin + 6);
program.LineTo(origin + 6, origin);
program.LineTo(origin, origin);
}
var frame = new Drawing("two-hole-frame", program);
var insert = new Drawing("insert", TestShapes.Rectangle(4, 4));
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = frame, Quantity = 1 },
new NestItem { Drawing = insert, Quantity = 2 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 1, 1, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var inserts = result.Raw.Plates.Single().Placements.Skip(1).ToArray();
Assert.Equal(2, inserts.Length);
Assert.All(inserts, p =>
{
var localX = p.X - 1;
var localY = p.Y - 1;
Assert.Contains(new[] { 2.0, 12.0 }, origin =>
localX >= origin + 0.25 && localX + 4 <= origin + 5.75
&& localY >= origin + 0.25 && localY + 4 <= origin + 5.75);
});
}
[Fact]
public void DifferentPrioritiesRemainIndependentInsteadOfElevatingAnInsert()
{
var request = Request("stub");
request.Items[1].Priority = 3;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job);
}), "stub", request);
Assert.True(result.CanKeep);
Assert.Empty(result.Plates);
}
[Theory]
[InlineData(1, 11.0, 11.0)]
[InlineData(2, -11.0, 11.0)]
[InlineData(3, -11.0, -11.0)]
[InlineData(4, 11.0, -11.0)]
public void AllQuadrantsKeepPhysicalStockAndExpansion(int quadrant, double x, double y)
{
var request = Request("stub") with
{
Stock = new[] {
new NestPlateStock("sheet", new Size(22, 22), 1, 0.25,
new Spacing(0.5, 0.5), quadrant) },
Options = new NestJobOptions(maxPlates: 1)
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, x, y, 0))),
"stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(quadrant, result.Plates.Single().Stock.Quadrant);
Assert.Equal((x, y), (result.Raw.Plates.Single().Placements[0].X,
result.Raw.Plates.Single().Placements[0].Y));
}
[Fact]
public void CancellationAfterAnEngineIgnoresStopDiscardsEveryBundle()
{
var request = Request("stub");
using var cts = new CancellationTokenSource();
Assert.Throws<OperationCanceledException>(() => NestPipeline.RunCutoutPreview(
new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
cts.Cancel();
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request, token: cts.Token));
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
}
[Fact]
public void CompleteStatusWithMissingProxyIsUntrustedAndNotBindable()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
NestJobStatus.Complete, NestJobStopReason.Completed,
Array.Empty<NestJobPlateResult>(),
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)),
job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)))), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
AssertCannotCommit(result, request);
}
[Fact]
public void SuccessfulCompositeReportsOnlyValidatedPhysicalCommitCounts()
{
var request = Request("stub");
var progress = new CollectProgress();
var result = NestPipeline.RunCutoutPreview(new ReportingStub(), "stub", request, progress);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { NestJobStage.EvaluatingCandidate, NestJobStage.PlateCommitted },
progress.Events.Select(e => e.Stage));
Assert.Equal(2, progress.Events.Last().CommittedParts);
Assert.Equal(1, progress.Events.Last().CommittedPlates);
}
private sealed class ReportingStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var proxy = Assert.Single(job.Parts);
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
job.Plates[0].Id, 0, 0, 0));
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
job.Plates[0].Id, 0, 1, 1));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}
}
[Fact]
public void AutomaticBundleRetainsStockFeasibleQuarterTurn()
{
var program = TestShapes.Rectangle(20, 10);
program.MoveTo(2, 2);
program.LineTo(2, 8);
program.LineTo(18, 8);
program.LineTo(18, 2);
program.LineTo(2, 2);
var request = new NestPipelineRequest("Irregular", new[] {
new NestItem { Drawing = new Drawing("long-frame", program), Quantity = 1 },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1 } },
new[] { new NestPlateStock("narrow", new Size(22, 12), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var frame = result.Raw.Plates.Single().Placements[0];
Assert.Equal(System.Math.PI / 2, frame.Rotation, 7);
Assert.Equal(new[] { "part-0", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
}
[Fact]
public void ChangedLiveInsertDuringSolveNeverBindsUncheckedComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
request.Items[1].Drawing.Program = TestShapes.Rectangle(40, 40);
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("insert"));
AssertCannotCommit(result, request);
}
[Fact]
public void NondefaultOptionsPassUnchangedAndPlateCapRejectsOtherwiseValidOutput()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
var options = new NestJobOptions("Strip", maxPlates: 1,
salvageRate: 0.4, minimumSalvageDimension: 2);
request = request with
{
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
Options = options
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.Same(options, job.Options);
Assert.Equal("Strip", job.Options.PlacementStrategy);
Assert.Equal(0.4, job.Options.SalvageRate);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in job.Parts)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("MaxPlates"));
AssertCannotCommit(result, request);
}
[Fact]
public void ObliqueFramePreservesLegalGlobalInsertRotationAndWallClearance()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
const double angle = 0.3;
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", program), Quantity = 1,
StepAngle = 1, RotationStart = angle, RotationEnd = angle },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)),
Quantity = 1, StepAngle = 1, RotationStart = angle, RotationEnd = angle } },
new[] { new NestPlateStock("sheet", new Size(40, 40), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, 15, 5, angle))),
"stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var poses = result.Raw.Plates.Single().Placements;
Assert.Equal(angle, poses[1].Rotation, 8);
var dx = poses[1].X - poses[0].X;
var dy = poses[1].Y - poses[0].Y;
var cosine = System.Math.Cos(angle);
var sine = System.Math.Sin(angle);
var localX = dx * cosine + dy * sine;
var localY = -dx * sine + dy * cosine;
foreach (var x in new[] { 0.0, 3.0 })
foreach (var y in new[] { 0.0, 3.0 })
{
Assert.InRange(localX + x, 5.25, 14.75);
Assert.InRange(localY + y, 5.25, 14.75);
}
}
[Fact]
public void LargeDemandLeavesExcessFramesAndInsertsIndependentAfterBoundedPreparation()
{
var frame = TestShapes.Rectangle(20, 20);
frame.MoveTo(5, 5);
frame.LineTo(5, 15);
frame.LineTo(15, 15);
frame.LineTo(15, 5);
frame.LineTo(5, 5);
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 40,
StepAngle = 1, RotationStart = 0, RotationEnd = 0 },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(6, 6)),
Quantity = 40, StepAngle = 1, RotationStart = 0, RotationEnd = 0 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 40, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.Equal(32, job.Parts.Count(p => p.Id.StartsWith("__cutout-proxy-")));
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-0").Quantity);
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-1").Quantity);
return Result(job);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((40, 0, 40),
(f.Requested, f.Placed, f.Unplaced)));
}
[Fact]
public void OrdinaryPipelineIsStillUnchanged()
{
var request = Request("stub");
var result = NestPipeline.Run(new Stub(job =>
{
Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job);
}), "stub", request);
Assert.True(result.CanKeep);
Assert.Empty(result.Plates);
}
}
@@ -0,0 +1,228 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutRouterTests
{
private static NestJobPart Frame()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
return JobBuilder.Part("frame", program, 1);
}
[Fact]
public void SmallLatticeAndLargeResidualKeepTheirOriginalRequirementIds()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var small = JobBuilder.Part("small", TestShapes.Rectangle(1, 1), 3);
var large = JobBuilder.Rectangle("large", 3, 3, 1, RotationPolicy.Fixed(0));
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25,
(pose, lattice) => origins.Add((pose, lattice)));
Assert.Equal(3, poses.Count(p => p.PartId == small.Id));
Assert.Single(poses, p => p.PartId == large.Id);
Assert.Contains(origins, row => row.Pose.PartId == small.Id && row.Lattice);
Assert.Contains(origins, row => row.Pose.PartId == large.Id && !row.Lattice);
Assert.Equal(poses, origins.Select(row => row.Pose));
Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == small.Id).Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { small, large }, poses);
}
[Fact]
public void ImpossibleRemainderNeverDisplacesCertifiedCopies()
{
var frame = Frame();
var small = JobBuilder.Rectangle("small", 1, 1, 3, RotationPolicy.Fixed(0));
var impossible = JobBuilder.Rectangle("impossible", 9.6, 9.6, 1, RotationPolicy.Fixed(0));
var together = CutoutRouter.Fill(frame, 0, new[] { small, impossible }, 0.25);
Assert.Equal(3, together.Count);
Assert.All(together, p => Assert.Equal(small.Id, p.PartId));
Assert.Equal(Enumerable.Range(0, 3), together.Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { small, impossible }, together);
}
[Fact]
public void PriorCopiesAndQuantityCannotBeBypassedByResidualSearch()
{
var frame = Frame();
var small = JobBuilder.Rectangle("small", 2, 2, 4, RotationPolicy.Fixed(0));
var large = JobBuilder.Rectangle("large", 7, 7, 1, RotationPolicy.Fixed(0));
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25);
Assert.InRange(poses.Count(p => p.PartId == small.Id), 0, small.Quantity);
Assert.InRange(poses.Count(p => p.PartId == large.Id), 0, large.Quantity);
AssertPhysical(frame, new[] { small, large }, poses);
}
[Fact]
public void ResidualNfpDoesNotOverlapTheFirstLargeInsert()
{
var frame = Frame();
var insert = JobBuilder.Rectangle("large", 6, 6, 2, RotationPolicy.Fixed(0));
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Single(poses);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void NfpAddsOneMoreCopyToPartialShiftedLattice()
{
// Neutral 8-inch round hole, 2x1 insert, quarter-inch spacing: the shifted
// lattice keeps 11; an independently certified NFP pose occupies its leftover.
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12);
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25,
(pose, lattice) => origins.Add((pose, lattice)));
Assert.Equal(12, poses.Count);
Assert.Equal(11, origins.Count(row => row.Lattice));
Assert.Single(origins, row => !row.Lattice);
Assert.Equal(poses, origins.Select(row => row.Pose));
Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void MiddleRatioChoosesTwoLatticeCopiesOverOneNfpCopy()
{
// 4x4 / (pi*5^2) = 0.204. A hard NFP-only threshold at 0.20 loses one copy.
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(4, 4), 3);
Assert.InRange(16 / (System.Math.PI * 25), CutoutRouter.SmallRatio, CutoutRouter.LargeRatio);
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Equal(2, poses.Count);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void LargeRatioKeepsAnNfpPoseWhenFillCannotProduceALattice()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
var insert = JobBuilder.Rectangle("insert", 4.5, 4.5, 3, RotationPolicy.Fixed(0));
Assert.True(20.25 / (System.Math.PI * 16) > CutoutRouter.LargeRatio);
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, insert, 3, 0.25));
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Single(poses);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void DuplicateRequirementIdsAreRejectedBeforePlacement()
{
var frame = Frame();
var insert = JobBuilder.Rectangle("same", 1, 1, 3);
Assert.Throws<ArgumentException>(() => CutoutRouter.Fill(frame, 0,
new[] { insert, insert }, 0.25));
}
[Fact]
public void OccupiedAwareLatticeFillsAfterAnEarlierCopyBlocksItsFirstPose()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var first = JobBuilder.Part("first", TestShapes.Rectangle(1, 1), 1);
var more = JobBuilder.Part("more", TestShapes.Rectangle(1, 1), 3);
var early = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25);
Assert.Equal(3, early.Count);
var blocked = early[0] with { PartId = first.Id };
var occupied = new[] { (JobPartGeometry.Read(first.Geometry), blocked) };
var later = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25,
CutoutLatticeFill.DefaultShiftSteps, occupied, default);
Assert.Equal(3, later.Count);
Assert.All(later, p => Assert.True(NestLayoutCheck.Clears(occupied[0].Item1, blocked,
JobPartGeometry.Read(more.Geometry), p, 0.25)));
}
[Fact]
public void TinyInsertDoesNotAllocateAnUnboundedLattice()
{
var frame = Frame();
var tiny = JobBuilder.Rectangle("tiny", 0.001, 0.001, 3, RotationPolicy.Fixed(0));
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, tiny, 3, 0));
var poses = CutoutRouter.Fill(frame, 0, new[] { tiny }, 0);
Assert.Equal(3, poses.Count);
}
[Fact]
public void CancellationIsNotNoFit()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.Throws<OperationCanceledException>(() => CutoutRouter.Fill(Frame(), 0,
new[] { JobBuilder.Rectangle("small", 1, 1, 3) }, 0.25, cts.Token));
}
private static void AssertPhysical(NestJobPart frame, NestJobPart[] inserts,
IReadOnlyList<NestJobPlacement> poses)
{
var stock = JobBuilder.Stock("sheet", 22, 22, 0.25);
var job = JobBuilder.Job(new[] { frame }.Concat(inserts).ToArray(), new[] { stock });
var frameBounds = JobPartGeometry.Read(frame.Geometry).Bounds;
var offsetX = 1 - frameBounds.Left;
var offsetY = 1 - frameBounds.Bottom;
var placements = new[] { new NestJobPlacement(frame.Id, 0, offsetX, offsetY, 0) }
.Concat(poses.Select(p => p with { X = p.X + offsetX, Y = p.Y + offsetY })).ToArray();
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, placements) },
new[] { new PartFulfillment(frame.Id, 1, 1, 0) }
.Concat(inserts.Select(part => new PartFulfillment(part.Id, part.Quantity,
poses.Count(p => p.PartId == part.Id), part.Quantity - poses.Count(p => p.PartId == part.Id))))
.ToArray(),
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
foreach (var pose in poses)
{
var geometry = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry);
Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, 0.25));
// Independent square-wall oracle: every corner of each rectangular insert clears the hole.
var bounds = geometry.Bounds;
foreach (var (x, y) in new[] { (bounds.Left, bounds.Bottom), (bounds.Right, bounds.Bottom),
(bounds.Right, bounds.Top), (bounds.Left, bounds.Top) })
{
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
if (frameBounds.Left >= 0)
{
Assert.InRange(rx, 5.25, 14.75);
Assert.InRange(ry, 5.25, 14.75);
}
else
{
var radius = System.Math.Abs(frameGeometry.Cutouts[0].BoundingBox.Right);
Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= radius - 0.25 + 0.00001);
}
}
}
// Independent rectangle-vs-rectangle distance, not the production Clears predicate.
for (var i = 0; i < poses.Count; i++)
for (var j = i + 1; j < poses.Count; j++)
{
var a = Bounds(poses[i]);
var b = Bounds(poses[j]);
var dx = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right));
var dy = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top));
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.25 - 0.00001,
$"independent material gap too small: {poses[i]} vs {poses[j]}");
}
(double Left, double Right, double Bottom, double Top) Bounds(NestJobPlacement pose)
{
var box = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry).Bounds;
var corners = new[] { (box.Left, box.Bottom), (box.Right, box.Bottom),
(box.Right, box.Top), (box.Left, box.Top) };
var x = corners.Select(c => pose.X + c.Item1 * System.Math.Cos(pose.Rotation)
- c.Item2 * System.Math.Sin(pose.Rotation)).ToArray();
var y = corners.Select(c => pose.Y + c.Item1 * System.Math.Sin(pose.Rotation)
+ c.Item2 * System.Math.Cos(pose.Rotation)).ToArray();
return (x.Min(), x.Max(), y.Min(), y.Max());
}
}
}
+11 -4
View File
@@ -9,9 +9,9 @@ public class NestJobCostTests
public static IEnumerable<object[]> EdgeCases() public static IEnumerable<object[]> EdgeCases()
{ {
foreach (var rate in new[] { 0.0, 0.5, 1.0 }) foreach (var rate in new[] { 0.0, 0.5, 1.0 })
foreach (var edge in new[] { "bottom", "top", "left", "right" }) foreach (var edge in new[] { "bottom", "top", "left", "right" })
foreach (var quadrant in new[] { 1, 2, 3, 4 }) foreach (var quadrant in new[] { 1, 2, 3, 4 })
yield return new object[] { rate, edge, quadrant }; yield return new object[] { rate, edge, quadrant };
} }
[Theory] [Theory]
@@ -84,7 +84,14 @@ public class NestJobCostTests
TestDrawingFactory.Rectangle(4, 3)), 5); TestDrawingFactory.Rectangle(4, 3)), 5);
var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options); var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options);
foreach (var sheet in result.Plates) foreach (var sheet in result.Plates)
Assert.Equal(LegacyNestJobCost.EstimateNetArea(cleanJob, sheet), NestJobCost.NetSheetArea(job, sheet)); {
var area = LegacyNestJobCost.EstimateNetArea(cleanJob, sheet);
Assert.Equal(area, NestJobCost.NetSheetArea(job, sheet));
var priced = new NestPlateStock(sheet.Stock.Id, sheet.Stock.Size, sheet.Stock.Quantity,
sheet.Stock.PartSpacing, sheet.Stock.EdgeSpacing, sheet.Stock.Quadrant, 37);
var pricedSheet = new NestJobPlateResult(sheet.PlateIndex, priced, sheet.Placements);
Assert.Equal(37 * (area / sheet.Stock.Area), NestJobCost.NetSheetCost(job, pricedSheet));
}
Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job)); Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job));
Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000, Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000,
NestJobCost.Evaluate(job, result)); NestJobCost.Evaluate(job, result));
@@ -75,6 +75,73 @@ public class NestPipelineCommitTests
Assert.Equal(2, Assert.Single(applied).Parts.Count); Assert.Equal(2, Assert.Single(applied).Parts.Count);
} }
[Fact]
public void DrawingChangedAfterValidationIsRefusedBeforeCommitEvenWithConsent()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new PreMutationStub(), "stub",
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
drawing.Program = TestDrawingFactory.Rectangle(100, 100);
Assert.Throws<InvalidOperationException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.Equal(0, drawing.Quantity.Nested);
}
private sealed class PreMutationStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[0], new[] { (job.Parts[0].Id, 1.0, 1.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}
}
private sealed class CancelDuringSnapshot(double x, double y, CancellationTokenSource source)
: OpenNest.CNC.LinearMove(x, y)
{
public override OpenNest.CNC.CodeType Type
{
get
{
source.Cancel();
return base.Type;
}
}
}
[Fact]
public void CancellationDuringFreshnessScanDoesNotMutateNest()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new PreMutationStub(), "stub",
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
Assert.True(result.IsValid);
using var cts = new CancellationTokenSource();
var proposed = Assert.Single(Assert.Single(result.Plates).Parts);
proposed.Program.Codes[1] = new CancelDuringSnapshot(10, 0, cts);
Assert.Throws<OperationCanceledException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, token: cts.Token));
Assert.True(cts.IsCancellationRequested);
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.Equal(0, drawing.Quantity.Nested);
}
[Fact] [Fact]
public void CancelledCommitDoesNotCreateAnyPlate() public void CancelledCommitDoesNotCreateAnyPlate()
{ {
@@ -258,6 +258,44 @@ public class NestPipelineTests
); );
} }
[Fact]
public void DrawingChangedDuringSolveCannotBindGeometryOtherThanTheValidatedSnapshot()
{
var item = Item("bracket", 1);
var result = NestPipeline.Run(new StubEngine(job =>
{
item.Drawing.Program = TestDrawingFactory.Rectangle(100, 100);
return OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0));
}), "Mutator", Request("Mutator", item));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("bracket"));
}
private sealed class TransientCloneMove(double x, double y) : LinearMove(x, y)
{
public override ICode Clone()
{
var before = EndPoint;
EndPoint = new Vector(100, 100);
try { return base.Clone(); }
finally { EndPoint = before; }
}
}
[Fact]
public void BindingTransientDrawingEditCannotReturnOrCommitUncheckedGeometry()
{
var item = Item("bracket", 1);
item.Drawing.Program.Codes[^1] = new TransientCloneMove(0, 0);
var result = NestPipeline.Run(new StubEngine(job =>
OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0))),
"Transient", Request("Transient", item));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("Bound part") && v.Contains("bracket"));
}
[Fact] [Fact]
public void CancellationPropagatesWithoutAResult() public void CancellationPropagatesWithoutAResult()
{ {
@@ -0,0 +1,165 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class StockCostTests
{
public static IEnumerable<object[]> Engines()
{
foreach (var engine in new[] { "Rectangles", "Irregular", "StockLadder", "Default", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" })
foreach (var factor in new[] { 1e-12, 1.0, 100.0 })
yield return new object[] { engine, factor };
}
private static NestPlateStock Stock(string id, double cost, int quantity = 2, double size = 10) =>
new(id, new Size(size, size), quantity, 0.1, default, 1, cost);
private static NestJob Job(params NestPlateStock[] stocks) => new(
new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 1) }, stocks);
[Fact]
public void ValidationRejectsForgedStockPrice()
{
var job = Job(Stock("offered", 10));
var forged = Stock("offered", 1);
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, forged, new[] { new NestJobPlacement("part", 0, 0, 0, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.NotEmpty(NestLayoutCheck.Violations(job, result));
}
[Fact]
public void UnavailablePriceDoesNotChangeAreaModeOrLegacyPenalty()
{
var job = Job(new NestPlateStock("available", new Size(10, 10)), Stock("unavailable", 10000, 0));
Assert.False(NestJobCost.UsesExplicitCosts(job));
Assert.Equal(100, NestJobCost.UnplacedPartPenalty(job));
}
[Fact]
public void FixedStrategyUsesSalvageOnlyForPricedStock()
{
var poses = new[] { new NestJobPlacement("part", 0, 0, 0, 0) };
var candidate = new PlateCandidate(poses);
var priced = Job(Stock("small", 10, size: 5), Stock("large", 12, size: 10));
var options = new NestJobOptions(salvageRate: 1, minimumSalvageDimension: 1);
var pricedJob = new NestJob(priced.Parts, priced.Plates, options);
Assert.True(new NestJobCandidateComparer(pricedJob).Compare(candidate, priced.Plates[1], 1,
candidate, priced.Plates[0], 0) > 0);
var unpriced = new NestJob(priced.Parts, new[] { new NestPlateStock("small", new Size(5, 5)),
new NestPlateStock("large", new Size(10, 10)) }, options);
Assert.True(new NestJobCandidateComparer(unpriced).Compare(candidate, unpriced.Plates[0], 0,
candidate, unpriced.Plates[1], 1) > 0);
}
[Theory]
[InlineData(0, 20, 0)]
[InlineData(0.5, 14, 6)]
public void SummarySeparatesGrossCreditAndNet(double rate, double net, double credit)
{
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(4, 4));
var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20);
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, new[] { stock },
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 6)));
Assert.Empty(result.Violations);
var summary = NestCostSummary.FromAccepted(result);
Assert.Equal("supplied-cost", summary.Basis);
Assert.Equal(20, summary.GrossTotal);
Assert.Equal(net, summary.NetScore, 10);
Assert.Equal(credit, summary.SalvageCredit, 10);
Assert.Equal(1, Assert.Single(summary.Stock).Used);
}
[Theory]
[MemberData(nameof(Engines))]
public void PricesReverseSelectionWithoutChangingFulfillment(string engine, double factor)
{
foreach (var cheap in new[] { "small", "large" })
{
var job = Job(Stock("small", (cheap == "small" ? 1 : 10) * factor),
Stock("large", (cheap == "large" ? 1 : 10) * factor, size: 12));
var result = NestingEngineRegistry.Create(engine).Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Assert.Equal(cheap, Assert.Single(result.Plates).StockId);
Assert.Equal(factor, NestJobCost.Evaluate(job, result));
}
}
[Theory]
[InlineData("Rectangles")]
[InlineData("Irregular")]
[InlineData("StockLadder")]
[InlineData("Default")]
[InlineData("Fill")]
public void SameSizeStockRetainsIndependentFiniteInventory(string engine)
{
var stocks = new[] { Stock("expensive", 10, 2, 5), Stock("cheap", 1, 1, 5) };
var job = new NestJob(new[] { new NestJobPart("part",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 3) }, stocks);
var result = NestingEngineRegistry.Create(engine).Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Assert.Equal("cheap", result.Plates[0].StockId);
Assert.Single(result.Plates.Where(p => p.StockId == "cheap"));
Assert.Equal(21, NestJobCost.Evaluate(job, result));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void ExplicitCostsMustBeFiniteAndPositive(double cost) =>
Assert.Throws<ArgumentOutOfRangeException>(() => Stock("bad", cost));
[Fact]
public void MixedModeRejectsOnlyAvailableStock()
{
Assert.Throws<ArgumentException>(() => Job(Stock("priced", 2), new NestPlateStock("missing", new Size(10, 10))));
var job = Job(Stock("priced", 2), new NestPlateStock("unavailable", new Size(10, 10), 0));
NestJobValidator.Validate(job);
Assert.Equal(2, NestJobCost.UnplacedPartPenalty(job));
}
[Theory]
[InlineData(0, 5, 20)]
[InlineData(0.5, 0, 20)]
[InlineData(0.5, 7, 20)]
[InlineData(0.5, 6, 14)]
public void PricesCreditSalvageProportionally(double rate, double minimum, double expected)
{
var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20);
var job = new NestJob(Job(stock).Parts, new[] { stock }, new NestJobOptions(salvageRate: rate, minimumSalvageDimension: minimum));
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) });
Assert.Equal(expected, NestJobCost.NetSheetCost(job, sheet), 10);
Assert.Equal(expected, NestJobCost.NetSheetCost(job.Options, stock, new Box(0, 0, 4, 4)), 10);
Assert.Equal(expected * 5, NestJobCost.NetSheetArea(job, sheet), 10);
}
[Fact]
public void AggregateOverflowFailsClearly()
{
var stock = Stock("huge", double.MaxValue);
var job = new NestJob(new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 2) }, new[] { stock });
var builder = new NestJobResultBuilder(job);
builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) });
builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) });
Assert.Throws<OverflowException>(() => NestJobCost.Evaluate(job, builder.Build(NestJobStopReason.Completed)));
}
[Fact]
public void BuilderMapsLegacyZeroAndSnapshotsPositiveCosts()
{
var option = new PlateOption { Width = 10, Length = 10 };
Assert.Null(Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option })).Cost);
option.Cost = 12;
var stock = Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option }));
option.Cost = 50;
Assert.Equal(12, stock.Cost);
Assert.Throws<ArgumentException>(() => NestStockBuilder.FromTemplate(new Plate(10, 10),
new[] { option, new PlateOption { Width = 12, Length = 12 } }));
}
}
@@ -28,7 +28,7 @@ internal static class BestEffortCuttingPlan
var done = new HashSet<int>(); var done = new HashSet<int>();
var position = snapshot.StartPoint; var position = snapshot.StartPoint;
var distance = 0.0; var distance = 0.0;
var checker = new ReleasedContourState(); var checker = new ReleasedContourState(reportMissingLeadIns: false);
try try
{ {
while (order.Count < snapshot.Placements.Count) while (order.Count < snapshot.Placements.Count)
@@ -31,8 +31,8 @@ public sealed record ContourFeasibilityVerdict(ContourFeasibilityStatus Status,
/// candidate contour through the S06 diagnostic seam, reads it at the placement position, /// candidate contour through the S06 diagnostic seam, reads it at the placement position,
/// and certifies the emitted lead-in and lead-out against ALL placed material. It is not a /// and certifies the emitted lead-in and lead-out against ALL placed material. It is not a
/// new collision implementation and not a plan approval: a Clear verdict certifies this /// new collision implementation and not a plan approval: a Clear verdict certifies this
/// contour's emitted leads only — the missing-lead (NoLeadIn) check still runs later on the /// contour's emitted leads only — NoLeadIn is supported, and the complete plan must
/// complete plan, and rapids/pierce clearance belong to their existing checkers. A /// still pass rapid travel and contour-accounting checks. A
/// candidate rejected here is not proven infeasible by anything else: this adapter only /// candidate rejected here is not proven infeasible by anything else: this adapter only
/// reports what the validator reported. /// reports what the validator reported.
/// One instance is one captured planning attempt: verdicts cache per exact choice and node /// One instance is one captured planning attempt: verdicts cache per exact choice and node
@@ -43,6 +43,10 @@ public sealed class CuttingPlanBatch
/// </summary> /// </summary>
public const int ExpansionsPerPart = 400; public const int ExpansionsPerPart = 400;
// Extra bounded work on hard/refused plates; the full-cap search still gets its whole budget.
internal const int FirstPassBudget = 1000;
internal const int FirstPassEntries = 8;
private readonly Entry[] entries; private readonly Entry[] entries;
private readonly CuttingParameters ownedParameters; private readonly CuttingParameters ownedParameters;
@@ -142,7 +146,7 @@ public sealed class CuttingPlanBatch
if (entry.Reorder != null) if (entry.Reorder != null)
{ {
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering)); progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
reorder = CuttingPlanService.Plan(entry.Reorder, token); reorder = PlanReorder(entry.Reorder, token);
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget) if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
{ {
plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap); plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap);
@@ -156,6 +160,22 @@ public sealed class CuttingPlanBatch
return new(plans, ownedParameters); return new(plans, ownedParameters);
} }
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
int firstPassBudget = FirstPassBudget)
{
// A failed first pass is not a proof of impossibility: retain all original
// full-cap alternatives and the complete original budget for the retry.
if (snapshot.Failure == null && snapshot.Regeneration && snapshot.MaxEntries > FirstPassEntries
&& snapshot.ExpansionBudget > firstPassBudget)
{
var first = CuttingPlanService.Plan(snapshot.WithSearchLimits(firstPassBudget, FirstPassEntries), token);
if ((first.Status == CuttingPlanStatus.Ready && first.IndependentlyReplayed)
|| first.Status == CuttingPlanStatus.Cancelled)
return first;
}
return CuttingPlanService.Plan(snapshot, token);
}
private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token) private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token)
{ {
if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null) if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null)
@@ -216,6 +236,10 @@ public sealed class CuttingPlanPlateResult
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed); Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
} }
/// <summary>A diagnostic tied to captured plate and source part positions, not display text.</summary>
public sealed record CuttingPlanDiagnosticRow(int PlateIndex, int PlateNumber, int? PartNumber,
int? OtherPartNumber, string Message);
/// <summary> /// <summary>
/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance. /// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance.
/// </summary> /// </summary>
@@ -243,6 +267,35 @@ public sealed class CuttingPlanProposal
public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply; public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply;
/// <summary>All findings, including ones hidden by a blocked plate's abbreviated summary.</summary>
public IReadOnlyList<CuttingPlanDiagnosticRow> DiagnosticRows()
{
var rows = new List<CuttingPlanDiagnosticRow>();
for (var index = 0; index < Plates.Count; index++)
{
var plate = Plates[index];
if (plate.Overlap != null)
{
foreach (var pair in plate.Overlap.Pairs)
rows.Add(new(index, plate.PlateNumber, pair.PartAId + 1, pair.PartBId + 1,
$"Part {pair.PartAId + 1}{Name(pair.PartAName)} overlaps part "
+ $"{pair.PartBId + 1}{Name(pair.PartBName)}."));
foreach (var issue in plate.Overlap.Issues)
rows.Add(new(index, plate.PlateNumber, issue.PartAId + 1,
issue.PartBId is int other ? other + 1 : null,
$"Overlap check incomplete for part {issue.PartAId + 1}"
+ (issue.PartBId is int second ? $" and part {second + 1}" : string.Empty)
+ $": {issue.Message}"));
}
foreach (var finding in plate.Result.Findings)
rows.Add(new(index, plate.PlateNumber,
finding.SourceOrdinal is int part ? part + 1 : null,
finding.OtherSourceOrdinal is int other ? other + 1 : null,
DescribeFinding(finding)));
}
return rows;
}
/// <summary> /// <summary>
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the /// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy /// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
@@ -296,7 +349,7 @@ public sealed class CuttingPlanProposal
public IReadOnlyList<string> Describe(string unit) public IReadOnlyList<string> Describe(string unit)
{ {
var lines = new List<string>(); var lines = new List<string>();
var ready = Plates.Count(p => p.IsReady); var blocked = Plates.Count(p => !p.CanApplyWithWarnings);
if (IsCancelled) if (IsCancelled)
lines.Add("Planning was cancelled. Nothing has changed."); lines.Add("Planning was cancelled. Nothing has changed.");
else if (CanApply) else if (CanApply)
@@ -307,7 +360,7 @@ public sealed class CuttingPlanProposal
lines.Add("Best-effort plan available. Review the warnings and accept the unverified plan to apply. " lines.Add("Best-effort plan available. Review the warnings and accept the unverified plan to apply. "
+ "This is not approval to cut or post CNC output."); + "This is not approval to cut or post CNC output.");
else else
lines.Add($"Apply is unavailable: {Plates.Count - ready} of {Count(Plates.Count, "plate")} could not " lines.Add($"Apply is unavailable: {blocked} of {Count(Plates.Count, "plate")} could not "
+ "be planned. No plate changes until every plate is ready."); + "be planned. No plate changes until every plate is ready.");
foreach (var plate in Plates) foreach (var plate in Plates)
@@ -93,6 +93,13 @@ public sealed class CuttingPlanSnapshot
public int ExpansionBudget { get; } public int ExpansionBudget { get; }
internal CuttingPlanStatus? Failure { get; } internal CuttingPlanStatus? Failure { get; }
internal IReadOnlyList<CuttingPlanFinding> Findings { get; } internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
// A worker-only search variant over the same owned capture. Never recapture the live plate:
// both proposals retain the identical freshness record and confirmed settings.
internal CuttingPlanSnapshot WithSearchLimits(int expansionBudget, int maxEntries) =>
new(Placements, StartPoint, expansionBudget, Failure, Findings, Regeneration,
PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters,
Dependencies, BestEffort);
} }
public sealed class FixedProgramPlacement public sealed class FixedProgramPlacement
@@ -241,7 +241,7 @@ public static class CuttingPlanService
if (placement.Prepared != null) if (placement.Prepared != null)
continue; // An eligible old crossing is precisely what regeneration may repair. continue; // An eligible old crossing is precisely what regeneration may repair.
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked. // Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
var findings = new ReleasedContourState().Check(placement.Execution, null, var findings = new ReleasedContourState(reportMissingLeadIns: false).Check(placement.Execution, null,
placement.SourceOrdinal + 1, placement.IsCutOff, token); placement.SourceOrdinal + 1, placement.IsCutOff, token);
fixedFindings.AddRange(Map(snapshot, findings)); fixedFindings.AddRange(Map(snapshot, findings));
} }
@@ -279,7 +279,7 @@ public static class CuttingPlanService
if (snapshot.Dependencies.FirstViolation(order) is { } violation) if (snapshot.Dependencies.FirstViolation(order) is { } violation)
return new(CuttingPlanStatus.InvalidInput, return new(CuttingPlanStatus.InvalidInput,
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions); findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
var checker = new ReleasedContourState(); var checker = new ReleasedContourState(reportMissingLeadIns: false);
var position = snapshot.StartPoint; var position = snapshot.StartPoint;
var distance = 0.0; var distance = 0.0;
var findings = new List<PostVerificationFinding>(); var findings = new List<PostVerificationFinding>();
@@ -313,7 +313,7 @@ public static class CuttingPlanService
if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation) if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation)
return new(CuttingPlanStatus.InvalidInput, return new(CuttingPlanStatus.InvalidInput,
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions); findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
var checker = new ReleasedContourState(); var checker = new ReleasedContourState(reportMissingLeadIns: false);
var position = snapshot.StartPoint; var position = snapshot.StartPoint;
var distance = 0.0; var distance = 0.0;
var findings = new List<CuttingPlanFinding>(); var findings = new List<CuttingPlanFinding>();
@@ -0,0 +1,92 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Owner-thread lifetime of a read-only cutting proposal. Capture before starting the worker;
/// complete on the owner thread. A close request never releases ownership while work is running.
/// </summary>
public sealed class CuttingProposalInspection
{
private CancellationTokenSource cancellation;
private Task<CuttingPlanProposal> worker;
public bool IsRunning => cancellation != null;
public bool CloseRequested { get; private set; }
public CuttingPlanProposal Proposal { get; private set; }
public Plate Preview { get; private set; }
public Task<CuttingPlanProposal> BeginPlanning(CuttingPlanBatch captured,
Action<CancellationToken> beforePlan = null, IProgress<CuttingPlanProgress> progress = null)
{
ArgumentNullException.ThrowIfNull(captured);
if (IsRunning || CloseRequested)
throw new InvalidOperationException("This inspection cannot start another worker.");
Proposal = null;
Preview = null;
cancellation = new CancellationTokenSource();
var token = cancellation.Token;
worker = Task.Run(() =>
{
beforePlan?.Invoke(token);
token.ThrowIfCancellationRequested();
return captured.Plan(progress, token);
});
return worker;
}
/// <summary>True only when the panel can release its input lock and be removed.</summary>
public bool RequestClose()
{
CloseRequested = true;
Preview = null;
Proposal = null;
cancellation?.Cancel();
return !IsRunning;
}
public void CompletePlanning(Task<CuttingPlanProposal> completed)
{
if (!ReferenceEquals(worker, completed) || !completed.IsCompleted)
throw new InvalidOperationException("Only the completed current worker can release ownership.");
cancellation.Dispose();
cancellation = null;
worker = null;
if (CloseRequested || !completed.IsCompletedSuccessfully)
return;
Proposal = completed.Result;
RefreshPreview();
}
/// <summary>Recheck exact freshness before displaying, including every subsequent paint.</summary>
private int previewIndex = -1;
public Plate RefreshPreview(int plateIndex = 0)
{
if (plateIndex != previewIndex)
{
Preview = null;
previewIndex = plateIndex;
}
if (Proposal == null || CloseRequested || plateIndex < 0 || plateIndex >= Proposal.Plates.Count)
return Preview = null;
// BuildPreview is the authority; never clone a rejected program for presentation.
if (Proposal.Plates[plateIndex].Result.Snapshot?.PlateState?.IsCurrent() != true)
return Preview = null;
return Preview ??= Proposal.BuildPreview(plateIndex);
}
/// <summary>Zero-based proposed position for a one-based source diagnostic number.</summary>
public static int? ProposedPosition(CuttingPlanProposal proposal, int plateIndex, int sourceNumber)
{
if (proposal == null || plateIndex < 0 || plateIndex >= proposal.Plates.Count || sourceNumber < 1)
return null;
var order = proposal.Plates[plateIndex].Result.ProposedOrder;
for (var i = 0; i < order.Count; ++i)
if (order[i].SourceOrdinal == sourceNumber - 1)
return i;
return null;
}
}
@@ -16,7 +16,7 @@ internal static class FixedProgramSearch
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token) internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
{ {
var stack = new Stack<Frame>(); var stack = new Stack<Frame>();
stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState())); stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false)));
var rejected = new HashSet<PostVerificationFinding>(); var rejected = new HashSet<PostVerificationFinding>();
var expansions = 0; var expansions = 0;
while (stack.Count != 0) while (stack.Count != 0)
@@ -24,6 +24,7 @@ internal static class JointCuttingPlanSearch
/// along its order before it gives up and learns from the part that blocked it. /// along its order before it gives up and learns from the part that blocked it.
/// </summary> /// </summary>
internal const int StallExpansionsPerEntry = 8; internal const int StallExpansionsPerEntry = 8;
private const int MaximumRecoveryHoles = 16;
/// <summary>LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.</summary> /// <summary>LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.</summary>
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order, internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
@@ -46,6 +47,9 @@ internal static class JointCuttingPlanSearch
var preferred = walk.Follow(keptSequence, stall, null, preferredOnly: true); var preferred = walk.Follow(keptSequence, stall, null, preferredOnly: true);
if (preferred.Order != null) if (preferred.Order != null)
return walk.Ready(preferred.Order); return walk.Ready(preferred.Order);
var recovered = walk.Follow(keptSequence, stall, null, contourRecovery: true);
if (recovered.Order != null)
return walk.Ready(recovered.Order);
} }
var kept = walk.Follow(keptSequence, null, null); var kept = walk.Follow(keptSequence, null, null);
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted(); return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
@@ -65,6 +69,9 @@ internal static class JointCuttingPlanSearch
var preferred = walk.Follow(sequence, stall, resume, preferredOnly: true); var preferred = walk.Follow(sequence, stall, resume, preferredOnly: true);
if (preferred.Order != null) if (preferred.Order != null)
return walk.Ready(preferred.Order); return walk.Ready(preferred.Order);
var recovered = walk.Follow(sequence, stall, null, contourRecovery: true);
if (recovered.Order != null)
return walk.Ready(recovered.Order);
} }
var attempt = walk.Follow(sequence, stall, resume); var attempt = walk.Follow(sequence, stall, resume);
if (attempt.Order != null) if (attempt.Order != null)
@@ -73,6 +80,12 @@ internal static class JointCuttingPlanSearch
{ {
// "Cut before" rules are a heuristic: a part blocked straight after another can // "Cut before" rules are a heuristic: a part blocked straight after another can
// still be reachable via a third. Search every order with what budget remains. // still be reachable via a third. Search every order with what budget remains.
if (preferEndpoints)
{
var recovery = walk.Follow(null, stall, null, contourRecovery: true);
if (recovery.Order != null)
return walk.Ready(recovery.Order);
}
var full = walk.Follow(null, null, null); var full = walk.Follow(null, null, null);
return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted(); return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted();
} }
@@ -189,36 +202,56 @@ internal static class JointCuttingPlanSearch
/// With a stall limit the attempt ends once that many expansions pass without getting /// With a stall limit the attempt ends once that many expansions pass without getting
/// further along the order; it never backtracks behind its starting node. /// further along the order; it never backtracks behind its starting node.
/// </summary> /// </summary>
internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false) internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false, bool contourRecovery = false)
{ {
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(), null, null); var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null);
var attempt = new Attempt(sequence); var attempt = new Attempt(sequence);
var progressExpansions = Expansions; var progressExpansions = Expansions;
var stack = new Stack<Frame>(); var stack = new Stack<Frame>();
stack.Push(new(root)); stack.Push(new(root));
while (stack.Count != 0) try
{ {
token.ThrowIfCancellationRequested(); while (stack.Count != 0)
var frame = stack.Peek();
var node = frame.Node;
if (node.Order.Length == snapshot.Placements.Count)
{ {
attempt.Order = node.Order; token.ThrowIfCancellationRequested();
return attempt; var frame = stack.Peek();
var node = frame.Node;
if (node.Order.Length == snapshot.Placements.Count)
{
attempt.Order = node.Order;
return attempt;
}
if (attempt.Advance(node))
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
{
if (sequence != null || !contourRecovery)
return attempt;
// Diversify the first whole part rather than spending the remainder
// on endpoints of a stalled root's already completed prefix.
while (stack.Count > 1)
stack.Pop().Children?.Dispose();
attempt.Restart();
progressExpansions = Expansions;
continue;
}
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly, contourRecovery).GetEnumerator();
if (!frame.Children.MoveNext())
{
stack.Pop().Children.Dispose();
continue;
}
stack.Push(new(frame.Children.Current.Node));
} }
if (attempt.Advance(node)) return attempt;
progressExpansions = Expansions; }
else if (stall is int limit && Expansions - progressExpansions > limit) finally
return attempt; {
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly); // Ready, stalled, cancelled and budget-exhausted searches can all leave
if (frame.Next == frame.Children.Length) // suspended siblings. Release their captured prefixes on every exit.
{ foreach (var frame in stack)
stack.Pop(); frame.Children?.Dispose();
continue;
}
stack.Push(new(frame.Children[frame.Next++].Node));
} }
return attempt;
} }
/// <summary> /// <summary>
@@ -259,9 +292,16 @@ internal static class JointCuttingPlanSearch
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone /// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order. /// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
/// </summary> /// </summary>
private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly) private IEnumerable<Edge> OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly, bool contourRecovery)
{ {
var edges = Expand(node, sequence, attempt, preferredOnly).ToList(); // A prescribed next part (or an active part's next contour) has a single
// source. Expand already yields its contour/entry rank order, so emit and
// validate a sibling only when DFS reaches it. Free whole-part selection
// still needs every source's distances before it can rank them. Recovery
// instead visits dependency-ready sources lazily by their material centres.
if (sequence != null || node.Active != null || contourRecovery)
return Expand(node, sequence, attempt, preferredOnly, contourRecovery);
var edges = Expand(node, sequence, attempt, preferredOnly, contourRecovery).ToList();
if (edges.Count <= 1) if (edges.Count <= 1)
return edges.ToArray(); return edges.ToArray();
// Stable source order: the minimum incremental rapid per source, ties ordinal. // Stable source order: the minimum incremental rapid per source, ties ordinal.
@@ -279,7 +319,7 @@ internal static class JointCuttingPlanSearch
.ToArray(); .ToArray();
} }
private IEnumerable<Edge> Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly) private IEnumerable<Edge> Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly, bool contourRecovery)
{ {
IEnumerable<FixedProgramPlacement> sources; IEnumerable<FixedProgramPlacement> sources;
if (node.Active is { } active) if (node.Active is { } active)
@@ -293,6 +333,8 @@ internal static class JointCuttingPlanSearch
: snapshot.Dependencies.IsReady(sequence[node.Order.Length], finished) : snapshot.Dependencies.IsReady(sequence[node.Order.Length], finished)
? [snapshot.Placements[sequence[node.Order.Length]]] : []; ? [snapshot.Placements[sequence[node.Order.Length]]] : [];
} }
if (contourRecovery)
sources = sources.OrderBy(p => Centre(p).DistanceTo(node.Position)).ThenBy(p => p.SourceOrdinal);
foreach (var source in sources) foreach (var source in sources)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -306,6 +348,12 @@ internal static class JointCuttingPlanSearch
continue; continue;
} }
var prepared = source.Prepared; var prepared = source.Prepared;
if (contourRecovery && prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles && node.Active == null)
{
foreach (var edge in RecoverPart(node, sequence, source, attempt))
yield return edge;
continue;
}
if (prepared.Count > 1 && node.Active == null) if (prepared.Count > 1 && node.Active == null)
{ {
// Selecting an endpoint does not cut it. Each endpoint owns a separate // Selecting an endpoint does not cut it. Each endpoint owns a separate
@@ -334,8 +382,10 @@ internal static class JointCuttingPlanSearch
// First try one ranked hole chain per endpoint. A later-part failure then // First try one ranked hole chain per endpoint. A later-part failure then
// changes the endpoint before replaying all earlier hole combinations. // changes the endpoint before replaying all earlier hole combinations.
// The retained pass below still searches every entry and hole order. // The retained pass below still searches every entry and hole order.
if (preference != null)
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).ThenBy(c => c);
if (preferredOnly && preference != null) if (preferredOnly && preference != null)
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1); contours = contours.Take(1);
foreach (var contour in contours) foreach (var contour in contours)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -352,12 +402,18 @@ internal static class JointCuttingPlanSearch
.FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1); .FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1);
var target = downstream < 0 ? preference.PerimeterPierce var target = downstream < 0 ? preference.PerimeterPierce
: preference.Pierces[downstream]; : preference.Pierces[downstream];
entries = SelectEntries(source, contour, target, node.Position - source.Location); // The coarse pass already checked one owned, lead-feasible entry
// Preference only reorders retained candidates. Neither a failed // for this hole. Do not prepare every alternate before trying it.
// preferred entry nor an alternate hole order prunes this branch. // The subsequent full pass still enumerates and checks them all.
if (preference.Entries.TryGetValue(contour, out var preferred)) if (preferredOnly && preference.Entries.TryGetValue(contour, out var first))
entries = entries.OrderBy(e => e.Point.DistanceTo(preferred.Point) entries = [first];
<= PostVerificationGeometry.Epsilon ? 0 : 1).ToArray(); else
{
entries = SelectEntries(source, contour, target, node.Position - source.Location);
if (preference.Entries.TryGetValue(contour, out var preferred))
entries = entries.OrderBy(e => e.Point.DistanceTo(preferred.Point)
<= PostVerificationGeometry.Epsilon ? 0 : 1).ToArray();
}
} }
} }
else else
@@ -402,6 +458,92 @@ internal static class JointCuttingPlanSearch
} }
} }
// Recovery only: solve directed contour transitions rather than assuming
// that a centre-to-centre hole tour can cross the already released material.
// One complete checked chain per outside endpoint; original DFS remains fallback.
private IEnumerable<Edge> RecoverPart(Node node, int[] sequence,
FixedProgramPlacement source, Attempt attempt)
{
var prepared = source.Prepared;
var target = LookAheadCentre(node, sequence, source) - source.Location;
// Placement origins are authored program coordinates, not a downstream cut.
// Use the same material-centre look-ahead as the retained entry search.
var perimeters = prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal, token: token)
.OrderBy(c => c.Choice.Point.DistanceTo(target ?? node.Position - source.Location))
.Where(c => Evaluate(source, c).Status == ContourFeasibilityStatus.Clear).Select(c => c.Choice).ToArray();
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal)
.Select(c => prepared.AutomaticEntryCandidatesWithFallbacks(c, token: token)
.Where(e => Evaluate(source, e).Status == ContourFeasibilityStatus.Clear).Select(e => e.Choice).ToArray()).ToArray();
var motions = new Dictionary<ContourChoice, OwnedExecution>();
var stop = Expansions + (long)StallExpansionsPerEntry * snapshot.MaxEntries * prepared.Count;
OwnedExecution Motion(ContourChoice choice)
{
if (motions.TryGetValue(choice, out var known)) return known;
CountExpansion(source);
return motions[choice] = ExecutionMotionReader.Read(
prepared.EmitCandidateForValidation(choice), source.Location, null, token);
}
for (var endpoint = 0; endpoint < perimeters.Length; endpoint++)
{
token.ThrowIfCancellationRequested();
if (Expansions >= stop) yield break;
var outside = perimeters[endpoint];
var chain = new List<ContourChoice>();
// This dominance heuristic is confined to recovery: emitted
// seams can differ for the same released subset. Never prune full DFS.
var failed = new HashSet<(int Mask, long X, long Y)>();
if (!Solve(0, node.Position, node.Checker)) continue;
var program = prepared.Emit(chain);
var execution = ExecutionMotionReader.Read(program, source.Location, node.Position, token);
var checker = node.Checker.Copy();
CountExpansion(source);
if (!Check(source, execution, node.Position, checker, attempt)) continue;
var proposal = source.Propose(program, execution, chain, token);
yield return new(new([.. node.Order, proposal], execution.DeparturePoint, checker, null, node),
execution.RapidDistanceFrom(node.Position), source.SourceOrdinal, -1, endpoint, endpoint);
bool Solve(int mask, Vector position, ReleasedContourState before)
{
token.ThrowIfCancellationRequested();
if (Expansions >= stop) return false;
var key = (mask, BitConverter.DoubleToInt64Bits(position.X),
BitConverter.DoubleToInt64Bits(position.Y));
if (failed.Contains(key)) return false;
if (mask == (1 << prepared.PerimeterOrdinal) - 1)
{
CountExpansion(source);
var findings = before.Copy().Check(Motion(outside), position, source.SourceOrdinal + 1, token);
rejected.AddRange(Map(snapshot, findings));
attempt.NoteCrossings(source.SourceOrdinal, findings);
if (findings.Count != 0) { failed.Add(key); return false; }
chain.Add(outside);
return true;
}
var candidates = Enumerable.Range(0, holes.Length).Where(c => (mask & (1 << c)) == 0)
.SelectMany(c => holes[c])
.OrderBy(c => Motion(c).Motions[0].End.DistanceTo(position));
foreach (var choice in candidates)
{
token.ThrowIfCancellationRequested();
if (Expansions >= stop) return false;
CountExpansion(source);
var execution = Motion(choice);
var after = before.Copy();
var findings = after.Check(execution, position, source.SourceOrdinal + 1, token);
rejected.AddRange(Map(snapshot, findings));
attempt.NoteCrossings(source.SourceOrdinal, findings);
if (findings.Count != 0) continue;
chain.Add(choice);
if (Solve(mask | (1 << choice.ContourOrdinal), execution.DeparturePoint, after)) return true;
chain.RemoveAt(chain.Count - 1);
}
failed.Add(key);
return false;
}
}
}
/// <summary> /// <summary>
/// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates /// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates
/// converted exactly once: rank the (fallback-complemented) catalogue toward the /// converted exactly once: rank the (fallback-complemented) catalogue toward the
@@ -558,6 +700,13 @@ internal static class JointCuttingPlanSearch
return true; return true;
} }
internal void Restart()
{
progress = -1;
Deepest = null;
Crossed.Clear();
}
internal void NoteCrossings(int ordinal, IEnumerable<PostVerificationFinding> findings) internal void NoteCrossings(int ordinal, IEnumerable<PostVerificationFinding> findings)
{ {
if (Blocked != ordinal) if (Blocked != ordinal)
@@ -593,7 +742,6 @@ internal static class JointCuttingPlanSearch
private sealed class Frame(Node node) private sealed class Frame(Node node)
{ {
internal Node Node { get; } = node; internal Node Node { get; } = node;
internal Edge[] Children { get; set; } internal IEnumerator<Edge> Children { get; set; }
internal int Next { get; set; }
} }
} }
@@ -22,20 +22,24 @@ namespace OpenNest.Engine.Jobs.Cutouts;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement. /// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement.
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Not wired into /// Poses are in the frame's own coordinates: frame at the origin, unrotated. Used by the
/// any engine or pipeline yet: placing parts in cutouts waits on containment-aware cutting order. /// internal pipeline cutout preview, but not enabled for normal whole-job calls;
/// production placement waits on cutting/post safety.
/// </remarks> /// </remarks>
internal static class CutoutLatticeFill internal static class CutoutLatticeFill
{ {
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary> /// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
internal const int DefaultShiftSteps = 8; internal const int DefaultShiftSteps = 8;
private const double FlattenTolerance = 0.001; internal const double FlattenTolerance = 0.001;
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary> /// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
private const double Margin = FlattenTolerance + 0.001; private const double Margin = FlattenTolerance + 0.001;
private const int Precision = NestTolerances.ClipperPrecision; private const int Precision = NestTolerances.ClipperPrecision;
// Fill materializes an entire grid before quantity trimming. Bound work even for a
// tiny insert with demand of only three, rather than allocating millions of clones.
private const int MaxLatticePositions = 1000;
/// <summary>Returns up to <paramref name="maxQuantity"/> insert poses inside the cutout, /// <summary>Returns up to <paramref name="maxQuantity"/> insert poses inside the cutout,
/// or none when no copy fits.</summary> /// or none when no copy fits.</summary>
@@ -45,9 +49,17 @@ internal static class CutoutLatticeFill
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex, internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token) NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token)
=> Fill(frame, cutoutIndex, insert, maxQuantity, spacing, shiftSteps,
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token);
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps,
IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, CancellationToken token)
{ {
ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(insert); ArgumentNullException.ThrowIfNull(insert);
ArgumentNullException.ThrowIfNull(occupied);
token.ThrowIfCancellationRequested();
ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps); ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps);
if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0) if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0)
return Array.Empty<NestJobPlacement>(); return Array.Empty<NestJobPlacement>();
@@ -69,6 +81,10 @@ internal static class CutoutLatticeFill
var bounds = cutout.BoundingBox; var bounds = cutout.BoundingBox;
var insertBounds = insertGeometry.Bounds; var insertBounds = insertGeometry.Bounds;
var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing; var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing;
var estimated = (System.Math.Ceiling(bounds.Length / step) + 4)
* (System.Math.Ceiling(bounds.Width / step) + 4);
if (!(step > 0) || !double.IsFinite(estimated) || estimated > MaxLatticePositions)
return Array.Empty<NestJobPlacement>();
var lattice = Lattice(insert, bounds, step, spacing, token); var lattice = Lattice(insert, bounds, step, spacing, token);
if (lattice.Count == 0) if (lattice.Count == 0)
@@ -83,7 +99,8 @@ internal static class CutoutLatticeFill
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var (dx, dy) = Shift(step, shiftSteps, i, j); var (dx, dy) = Shift(step, shiftSteps, i, j);
var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy)); var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy)
&& ClearsOccupied(p, dx, dy));
if (Better(count, i, j, best)) if (Better(count, i, j, best))
best = (count, i, j); best = (count, i, j);
} }
@@ -91,13 +108,33 @@ internal static class CutoutLatticeFill
return Array.Empty<NestJobPlacement>(); return Array.Empty<NestJobPlacement>();
var (sx, sy) = Shift(step, shiftSteps, best.I, best.J); var (sx, sy) = Shift(step, shiftSteps, best.I, best.J);
var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy)) var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy)
&& ClearsOccupied(p, sx, sy))
.Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8), .Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8),
System.Math.Round(p.Y + sy, 8), p.Rotation)) System.Math.Round(p.Y + sy, 8), p.Rotation))
.OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation) .OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation)
.ToList(); .ToList();
return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity) return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity)
.Select((p, index) => p with { InstanceIndex = index }).ToArray(); .Select((p, index) => p with { InstanceIndex = index }).ToArray();
bool ClearsOccupied(NestJobPlacement pose, double dx, double dy)
{
token.ThrowIfCancellationRequested();
if (occupied.Count == 0)
return true;
var moved = pose with
{
X = System.Math.Round(pose.X + dx, 8),
Y = System.Math.Round(pose.Y + dy, 8)
};
foreach (var other in occupied)
{
token.ThrowIfCancellationRequested();
if (!NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, moved, spacing))
return false;
}
return true;
}
} }
/// <summary>Fill over the cutout's bounds grown by one step on every side, in frame /// <summary>Fill over the cutout's bounds grown by one step on every side, in frame
@@ -134,7 +171,7 @@ internal static class CutoutLatticeFill
/// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline /// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline
/// never meets the cutout boundary (t outside boundary + reflected outline). /// never meets the cutout boundary (t outside boundary + reflected outline).
/// </summary> /// </summary>
private static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing) internal static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
{ {
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList(); var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
foreach (var entity in entities) foreach (var entity in entities)
@@ -203,7 +240,7 @@ internal static class CutoutLatticeFill
} }
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary> /// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
private static bool Inside(PathsD region, double x, double y) internal static bool Inside(PathsD region, double x, double y)
{ {
var point = new PointD(x, y); var point = new PointD(x, y);
var inside = false; var inside = false;
@@ -218,7 +255,7 @@ internal static class CutoutLatticeFill
return inside; return inside;
} }
private static PathD Positive(PathD path) internal static PathD Positive(PathD path)
{ {
if (path.Count >= 3 && !Clipper.IsPositive(path)) if (path.Count >= 3 && !Clipper.IsPositive(path))
path.Reverse(); path.Reverse();
@@ -0,0 +1,112 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>Test-only single-insert inner-fit proposal; never changes a job or a live plate.</summary>
internal static class CutoutNfpProposal
{
// Fixed chord tolerance in the shared round offset otherwise makes extremely large
// spacing values allocate an unbounded number of arc vertices. This conservative
// test-only kernel declines scales it cannot process safely.
private const double MaxCoordinate = 1e8;
private const double MaxSpacing = 100;
/// <summary>Returns one frame-local pose inside a closed cutout, or null if no pose is certified.
/// A null result is not a proof that the insert cannot fit elsewhere in this cutout.</summary>
internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert,
double spacing, CancellationToken token = default) =>
Find(frame, cutoutIndex, insert, spacing,
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token);
/// <summary>Find a pose clearing already accepted inserts as well as the enclosing frame.</summary>
internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert,
double spacing, IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied,
CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(insert);
ArgumentNullException.ThrowIfNull(occupied);
token.ThrowIfCancellationRequested();
if (!double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing)
return null;
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
var insertGeometry = JobPartGeometry.TryRead(insert.Geometry);
if (frameGeometry == null || insertGeometry == null
|| !WithinRange(frameGeometry.Bounds) || !WithinRange(insertGeometry.Bounds))
return null;
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
var cutout = frameGeometry.Cutouts[cutoutIndex];
if (!cutout.IsClosed() || !WithinRange(cutout.BoundingBox)
|| spacing * 2 >= System.Math.Min(cutout.BoundingBox.Length, cutout.BoundingBox.Width))
return null;
var hole = CutoutLatticeFill.Positive(ClipperBridge.ToPath(
ClipperBridge.Flatten(cutout, CutoutLatticeFill.FlattenTolerance, circumscribe: false),
new Vector()));
if (hole.Count < 3)
return null;
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
foreach (var rotation in insert.Rotation.EnumerateAngles())
{
token.ThrowIfCancellationRequested();
var region = CutoutLatticeFill.InnerFit(insertGeometry, rotation, hole, spacing);
token.ThrowIfCancellationRequested();
foreach (var (x, y) in Candidates(region))
{
token.ThrowIfCancellationRequested();
if (!CutoutLatticeFill.Inside(region, x, y))
continue;
var pose = new NestJobPlacement(insert.Id, 0, x, y, rotation);
if (!NestLayoutCheck.Clears(frameGeometry, framePose, insertGeometry, pose, spacing))
continue;
var clears = true;
foreach (var other in occupied)
{
token.ThrowIfCancellationRequested();
if (NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, pose, spacing))
continue;
clears = false;
break;
}
token.ThrowIfCancellationRequested();
if (clears)
return pose;
}
}
token.ThrowIfCancellationRequested();
return null;
}
private static bool WithinRange(Box box) =>
double.IsFinite(box.Left) && double.IsFinite(box.Right)
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
&& System.Math.Abs(box.Left) <= MaxCoordinate && System.Math.Abs(box.Right) <= MaxCoordinate
&& System.Math.Abs(box.Bottom) <= MaxCoordinate && System.Math.Abs(box.Top) <= MaxCoordinate;
/// <summary>Sample component interiors, then points just inside each boundary toward
/// its component's center. This bounded search is not a complete inner-fit solver.</summary>
private static IEnumerable<(double X, double Y)> Candidates(PathsD region)
{
foreach (var path in region.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
{
var bounds = Clipper.GetBounds(new PathsD { path });
var cx = (bounds.left + bounds.right) / 2;
var cy = (bounds.top + bounds.bottom) / 2;
yield return (cx, cy);
foreach (var point in path)
yield return (point.x * 0.99 + cx * 0.01, point.y * 0.99 + cy * 0.01);
// Bounded interior samples let a residual insert land beside a partial lattice;
// a center/vertex-only sample often lands directly on an earlier copy.
for (var y = 1; y <= 9; y++)
for (var x = 1; x <= 9; x++)
yield return (bounds.left + (bounds.right - bounds.left) * x / 10,
bounds.top + (bounds.bottom - bounds.top) * y / 10);
}
}
}
@@ -0,0 +1,242 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>Internal-only frame bundles. No normal pipeline caller enables this.</summary>
internal sealed class CutoutPipelinePrepass
{
// A job with thousands of repeats still offers the residual as ordinary demand;
// do not multiply expensive Clipper/Fill preparation by every physical instance.
private const int MaxBundlesPerJob = 32;
private sealed record Bundle(string FrameId, IReadOnlyList<NestJobPlacement> LocalInserts);
private readonly IReadOnlyDictionary<string, Bundle> bundles;
private CutoutPipelinePrepass(NestJob engineJob, IReadOnlyDictionary<string, Bundle> bundles)
{
EngineJob = engineJob;
this.bundles = bundles;
}
internal NestJob EngineJob { get; }
internal static CutoutPipelinePrepass? Prepare(NestJob original, CancellationToken token)
{
token.ThrowIfCancellationRequested();
if (original.Plates.Count == 0)
return null;
// One reservation vector works for every offered stock. Current jobs cannot
// represent stock-conditional insert quantities; use the largest spacing.
var spacing = original.Plates.Max(p => p.PartSpacing);
var remaining = original.Parts.ToDictionary(p => p.Id, p => p.Quantity);
var geometry = original.Parts.ToDictionary(p => p.Id, p => JobPartGeometry.TryRead(p.Geometry));
var bundles = new Dictionary<string, Bundle>(StringComparer.Ordinal);
var byFrame = new Dictionary<string, List<NestJobPart>>(StringComparer.Ordinal);
foreach (var frame in original.Parts)
{
token.ThrowIfCancellationRequested();
if (bundles.Count >= MaxBundlesPerJob)
break;
var profile = geometry[frame.Id];
if (profile == null || profile.Cutouts.Count == 0)
continue;
var proxies = new List<NestJobPart>();
byFrame[frame.Id] = proxies;
for (var instance = 0; instance < frame.Quantity; instance++)
{
token.ThrowIfCancellationRequested();
if (bundles.Count >= MaxBundlesPerJob)
break;
IReadOnlyList<NestJobPlacement>? chosen = null;
double frameAngle = 0;
// Outer-perimeter symmetry cannot deduplicate asymmetric cutouts.
foreach (var angle in frame.Rotation.EnumerateAngles(maxSamples: 16))
{
token.ThrowIfCancellationRequested();
if (!FitsOfferedStock(frame, angle, original.Plates))
continue;
var local = new List<NestJobPlacement>();
var available = new Dictionary<string, int>(remaining);
for (var hole = 0; hole < profile.Cutouts.Count; hole++)
{
token.ThrowIfCancellationRequested();
// Flat bundles: frames cannot simultaneously be inserts in another bundle.
var candidates = original.Parts
.Where(p => p.Id != frame.Id && available[p.Id] > 0
&& p.Priority == frame.Priority
&& geometry[p.Id] is { Cutouts.Count: 0 })
.Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id],
p.Priority, LocalRotation(p.Rotation, angle)))
.ToArray();
if (candidates.Length == 0)
break;
foreach (var pose in CutoutRouter.Fill(frame, hole, candidates, spacing, token))
{
token.ThrowIfCancellationRequested();
var source = geometry[pose.PartId]!;
var policy = original.Parts.Single(p => p.Id == pose.PartId).Rotation;
if (!policy.Allows(angle + pose.Rotation)
|| local.Any(other => !NestLayoutCheck.Clears(
geometry[other.PartId]!, other, source, pose, spacing)))
continue;
local.Add(pose);
available[pose.PartId]--;
}
}
if (local.Count <= (chosen?.Count ?? 0))
continue;
chosen = local.ToArray();
frameAngle = angle;
}
if (chosen == null || chosen.Count == 0)
break;
var proxyId = $"__cutout-proxy-{frame.Id}-{instance}";
if (remaining.ContainsKey(proxyId) || bundles.ContainsKey(proxyId))
throw new InvalidOperationException("Cutout proxy ID collides with a requirement.");
bundles.Add(proxyId, new Bundle(frame.Id, chosen));
proxies.Add(new NestJobPart(proxyId, frame.Geometry, 1, frame.Priority,
RotationPolicy.Fixed(frameAngle)));
remaining[frame.Id]--;
foreach (var pose in chosen)
remaining[pose.PartId]--;
}
}
if (bundles.Count == 0)
return null;
var transformed = new List<NestJobPart>();
foreach (var part in original.Parts)
{
if (byFrame.TryGetValue(part.Id, out var proxies))
transformed.AddRange(proxies);
if (remaining[part.Id] > 0)
transformed.Add(new NestJobPart(part.Id, part.Geometry, remaining[part.Id],
part.Priority, part.Rotation));
}
return new CutoutPipelinePrepass(new NestJob(transformed, original.Plates,
original.Options), bundles);
}
private static RotationPolicy LocalRotation(RotationPolicy original, double frameAngle) =>
original.Kind switch
{
RotationPolicyKind.Automatic => RotationPolicy.Automatic,
RotationPolicyKind.Fixed => RotationPolicy.Fixed(original.Start - frameAngle,
original.Allow180Equivalent),
_ => RotationPolicy.BoundedSweep(original.Start - frameAngle,
original.End - frameAngle, original.Step, original.Allow180Equivalent),
};
private static bool FitsOfferedStock(NestJobPart frame, double angle,
IReadOnlyList<NestPlateStock> stock)
{
var part = new Part(DrawingJobMapper.CreateDrawing(frame));
part.Rotate(angle);
var bounds = NestLayoutCheck.MaterialBounds(part);
return stock.Any(s => s.Quantity != 0 && s.Fits(bounds.Length, bounds.Width,
NestTolerances.WorkAreaSlack));
}
/// <summary>Reject untrusted accounting before expansion. A failed trial is never bindable,
/// including with allowInvalid. Only actually placed proxies consume reserved inserts.</summary>
internal bool TryExpand(NestJob original, NestJobResult result, CancellationToken token,
out NestJobResult? expanded,
out string failure)
{
expanded = null;
failure = "Invalid cutout engine accounting";
var parts = EngineJob.Parts.ToDictionary(p => p.Id);
var counts = parts.ToDictionary(p => p.Key, _ => 0);
var indices = parts.ToDictionary(p => p.Key, _ => new HashSet<int>());
var stockById = EngineJob.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal);
var used = stockById.ToDictionary(s => s.Key, _ => 0, StringComparer.Ordinal);
var expectedIndex = 0;
foreach (var sheet in result.Plates)
{
token.ThrowIfCancellationRequested();
if (sheet.PlateIndex != expectedIndex++ || sheet.Placements.Count == 0
|| !used.ContainsKey(sheet.StockId))
return false;
used[sheet.StockId]++;
foreach (var pose in sheet.Placements)
{
if (pose.PartId == null || !parts.ContainsKey(pose.PartId)
|| pose.InstanceIndex < 0 || !indices[pose.PartId].Add(pose.InstanceIndex)
|| ++counts[pose.PartId] > parts[pose.PartId].Quantity)
return false;
}
}
foreach (var row in indices)
{
token.ThrowIfCancellationRequested();
if (row.Value.Any(index => index >= counts[row.Key]))
return false;
}
if (result.Fulfillment.Count != parts.Count || result.StockUsage.Count != used.Count)
return false;
var seenParts = new HashSet<string>(StringComparer.Ordinal);
foreach (var row in result.Fulfillment)
{
token.ThrowIfCancellationRequested();
if (row.PartId == null || !parts.TryGetValue(row.PartId, out var part)
|| !seenParts.Add(row.PartId)
|| row.Requested != part.Quantity || row.Placed != counts[row.PartId]
|| row.Unplaced != part.Quantity - counts[row.PartId])
return false;
}
var seenStock = new HashSet<string>(StringComparer.Ordinal);
foreach (var row in result.StockUsage)
{
token.ThrowIfCancellationRequested();
if (row.StockId == null || !stockById.TryGetValue(row.StockId, out var stock)
|| !seenStock.Add(row.StockId)
|| row.Used != used[row.StockId]
|| row.Remaining != stock.Quantity - row.Used)
return false;
}
var complete = parts.Values.All(p => counts[p.Id] == p.Quantity);
if (result.Status != (complete ? NestJobStatus.Complete : NestJobStatus.Incomplete)
|| (complete && result.StopReason != NestJobStopReason.Completed)
|| (!complete && result.StopReason == NestJobStopReason.Completed))
return false;
var builder = new NestJobResultBuilder(original);
foreach (var sheet in result.Plates)
{
token.ThrowIfCancellationRequested();
var poses = new List<(string PartId, double X, double Y, double Rotation)>();
foreach (var pose in sheet.Placements)
{
if (!bundles.TryGetValue(pose.PartId, out var bundle))
{
poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation));
continue;
}
poses.Add((bundle.FrameId, pose.X, pose.Y, pose.Rotation));
var sine = System.Math.Sin(pose.Rotation);
var cosine = System.Math.Cos(pose.Rotation);
foreach (var local in bundle.LocalInserts)
poses.Add((local.PartId,
pose.X + local.X * cosine - local.Y * sine,
pose.Y + local.X * sine + local.Y * cosine,
pose.Rotation + local.Rotation));
}
// Canonical stock reference, not an engine-supplied object with altered settings.
var stock = original.Plates.Single(s => s.Id == sheet.StockId);
try
{
builder.AddSheet(stock, poses);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return false;
}
}
token.ThrowIfCancellationRequested();
expanded = builder.Build(result.StopReason);
return true;
}
}
@@ -0,0 +1,144 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>Internal frame-local cutout proposal. Never changes demand, stock or a live plate.</summary>
/// <remarks>Area ratio selects search order, not placement permission. The small-insert path
/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search;
/// the large-insert path tries NFP first. All accepted poses are clearance-checked.
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
internal static class CutoutRouter
{
// Calibrated as search-order hints, not hard geometry cutoffs. Round-hole neutral tests
// show a Fill win at 0.204, so the original proposed >0.20 NFP-only rule loses copies.
internal const double SmallRatio = 0.10;
internal const double LargeRatio = 0.35;
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing, CancellationToken token = default) =>
Fill(frame, cutoutIndex, inserts, spacing, null, token);
/// <summary>Optional internal diagnostic receives only poses from the chosen proposal,
/// after indexing; true marks a retained lattice seed, false a residual NFP pose.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing,
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(inserts);
token.ThrowIfCancellationRequested();
if (!double.IsFinite(spacing) || spacing < 0 || spacing > 100)
return Array.Empty<NestJobPlacement>();
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
if (frameGeometry == null)
return Array.Empty<NestJobPlacement>();
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
var hole = frameGeometry.Cutouts[cutoutIndex];
if (!hole.IsClosed() || !Supported(frameGeometry.Bounds) || !Supported(hole.BoundingBox))
return Array.Empty<NestJobPlacement>();
if (inserts.Any(p => p == null || !SupportedGeometry(p)))
return Array.Empty<NestJobPlacement>();
if (inserts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != inserts.Count
|| inserts.Any(p => p.Id == frame.Id))
throw new ArgumentException("Requirement IDs must be distinct from the frame and each other.",
nameof(inserts));
var accepted = new List<NestJobPlacement>();
var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
var holeArea = System.Math.Abs(hole.Area());
// Preserve caller requirement order: priorities are a whole-job concern, not inferred here.
foreach (var part in inserts)
{
token.ThrowIfCancellationRequested();
var geometry = JobPartGeometry.Read(part.Geometry);
var ratio = geometry.MaterialArea / holeArea;
// Unsupported or degenerate material cannot establish a useful routing ratio;
// NFP retains its own fail-closed checks and the bounded lattice is not tried.
var tryLattice = part.Quantity >= 3 && double.IsFinite(ratio) && ratio > 0;
var nfpFirst = !tryLattice || ratio >= LargeRatio;
var compareBoth = tryLattice && ratio >= SmallRatio && ratio < LargeRatio;
var nfp = nfpFirst ? Nfp(Array.Empty<NestJobPlacement>()) : null;
var lattice = tryLattice && (!nfpFirst || nfp!.Count < part.Quantity)
? CutoutLatticeFill.Fill(frame, cutoutIndex, part, part.Quantity, spacing,
CutoutLatticeFill.DefaultShiftSteps, occupied, token)
: Array.Empty<NestJobPlacement>();
IReadOnlyList<NestJobPlacement> chosen;
var retainedLattice = 0;
if (compareBoth || (nfpFirst && lattice.Count > 0))
{
nfp ??= Nfp(Array.Empty<NestJobPlacement>());
var fromLattice = Complete(lattice);
chosen = fromLattice.Count > nfp.Count ? fromLattice : nfp;
if (ReferenceEquals(chosen, fromLattice))
retainedLattice = lattice.Count;
}
else if (nfpFirst)
chosen = nfp!;
else
{
// A single Fill copy ties one NFP pose; prefer NFP's stable candidate
// order, but retain the lattice copy if bounded NFP found nothing.
var seed = lattice.Count > 1 ? lattice : Array.Empty<NestJobPlacement>();
chosen = Complete(seed);
if (chosen.Count == 0 && lattice.Count == 1)
chosen = lattice;
retainedLattice = ReferenceEquals(chosen, lattice) ? lattice.Count : seed.Count;
}
var localIndex = 0;
foreach (var pose in chosen)
{
token.ThrowIfCancellationRequested();
var indexed = pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) };
accepted.Add(indexed);
occupied.Add((geometry, indexed));
observeAccepted?.Invoke(indexed, localIndex++ < retainedLattice);
}
IReadOnlyList<NestJobPlacement> Complete(IReadOnlyList<NestJobPlacement> seed)
{
if (seed.Count >= part.Quantity)
return seed;
var result = new List<NestJobPlacement>(seed);
result.AddRange(Nfp(seed));
return result;
}
IReadOnlyList<NestJobPlacement> Nfp(IReadOnlyList<NestJobPlacement> seed)
{
var result = new List<NestJobPlacement>();
var local = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>(occupied);
local.AddRange(seed.Select(p => (geometry, p)));
while (seed.Count + result.Count < part.Quantity)
{
token.ThrowIfCancellationRequested();
var pose = CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, local, token);
if (pose == null)
break;
result.Add(pose);
local.Add((geometry, pose));
}
return result;
}
}
token.ThrowIfCancellationRequested();
return accepted.ToArray();
}
private static bool SupportedGeometry(NestJobPart part)
{
var geometry = JobPartGeometry.TryRead(part.Geometry);
return geometry != null && Supported(geometry.Bounds);
}
private static bool Supported(OpenNest.Geometry.Box box) =>
double.IsFinite(box.Left) && double.IsFinite(box.Right)
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
&& System.Math.Abs(box.Left) <= 1e8 && System.Math.Abs(box.Right) <= 1e8
&& System.Math.Abs(box.Bottom) <= 1e8 && System.Math.Abs(box.Top) <= 1e8;
}
+39
View File
@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
public sealed record NestStockCost(string StockId, int Used, double? Cost, double GrossSubtotal);
/// <summary>Costs of validated physical sheets, excluding unplaced-demand penalties.</summary>
public sealed record NestCostSummary(string Basis, IReadOnlyList<NestStockCost> Stock,
double GrossTotal, double SalvageCredit, double NetScore)
{
public static NestCostSummary FromAccepted(NestPipelineResult result)
{
if (!result.CanKeep || !result.IsValid)
throw new ArgumentException("Cost reporting requires a validated proposal.", nameof(result));
var stock = result.Job.Plates.Select(s =>
{
var used = result.Plates.Count(p => p.Stock.Id == s.Id);
return new NestStockCost(s.Id, used, s.Cost,
used == 0 ? 0 : NestJobCost.RequireFinite(used * NestJobCost.GrossSheetCost(s)));
}).ToArray();
var gross = NestJobCost.RequireFinite(stock.Sum(s => s.GrossSubtotal));
var net = NestJobCost.RequireFinite(result.Plates.Sum(sheet =>
{
if (sheet.Parts.Count == 0 || result.Job.Options.SalvageRate == 0
|| result.Job.Options.MinimumSalvageDimension <= 0)
return NestJobCost.GrossSheetCost(sheet.Stock);
var bounds = sheet.Parts.Select(NestLayoutCheck.MaterialBounds).ToArray();
var left = bounds.Min(b => b.Left);
var bottom = bounds.Min(b => b.Bottom);
return NestJobCost.NetSheetCost(result.Job.Options, sheet.Stock,
new Box(left, bottom, bounds.Max(b => b.Right) - left, bounds.Max(b => b.Top) - bottom));
}));
return new NestCostSummary(NestJobCost.UsesExplicitCosts(result.Job)
? "supplied-cost" : "area", stock, gross, NestJobCost.RequireFinite(gross - net), net);
}
}
+1
View File
@@ -16,6 +16,7 @@ public sealed class NestJob
Parts = Own(parts); Parts = Own(parts);
Plates = Own(plates); Plates = Own(plates);
Options = options ?? new NestJobOptions(); Options = options ?? new NestJobOptions();
NestJobValidator.ValidateStockCosts(Plates);
if ( if (
Parts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Parts.Count Parts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Parts.Count
|| Plates.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Plates.Count || Plates.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Plates.Count
@@ -4,10 +4,13 @@ using System.Linq;
namespace OpenNest.Engine.Jobs; namespace OpenNest.Engine.Jobs;
/// <summary>Ranks independent plate trials: priority fulfillment, sheet area, placement envelope, then input order.</summary> /// <summary>Ranks independent plate trials: priority fulfillment, sheet cost, placement envelope, then input order.</summary>
public sealed class NestJobCandidateComparer public sealed class NestJobCandidateComparer
{ {
private readonly IReadOnlyList<NestJobPart> parts; private readonly IReadOnlyList<NestJobPart> parts;
private readonly NestJob job;
public NestJobCandidateComparer(NestJob job) : this(job.Parts) => this.job = job;
public NestJobCandidateComparer(IReadOnlyList<NestJobPart> parts) public NestJobCandidateComparer(IReadOnlyList<NestJobPart> parts)
{ {
@@ -36,7 +39,7 @@ public sealed class NestJobCandidateComparer
return leftCount.CompareTo(rightCount); return leftCount.CompareTo(rightCount);
} }
var area = Area(rightStock).CompareTo(Area(leftStock)); var area = Score(right, rightStock).CompareTo(Score(left, leftStock));
if (area != 0) if (area != 0)
return area; return area;
@@ -52,7 +55,10 @@ public sealed class NestJobCandidateComparer
parts.First(part => part.Id == placement.PartId).Priority == priority parts.First(part => part.Id == placement.PartId).Priority == priority
); );
private static double Area(NestPlateStock stock) => stock.Size.Width * stock.Size.Length; private double Score(PlateCandidate candidate, NestPlateStock stock) =>
job != null && stock.Cost.HasValue
? NestJobCost.NetSheetCost(job, new NestJobPlateResult(0, stock, candidate.Placements))
: NestJobCost.GrossSheetCost(stock);
private static double Envelope(PlateCandidate candidate) private static double Envelope(PlateCandidate candidate)
{ {
+26 -6
View File
@@ -1,10 +1,11 @@
using System;
using System.Linq; using System.Linq;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs; namespace OpenNest.Engine.Jobs;
/// <summary>Benchmark scoring primitives. Lower costs represent less sheet consumption.</summary> /// <summary>Generic per-sheet scoring; unspecified costs retain legacy area scores.</summary>
public static class NestJobCost public static class NestJobCost
{ {
/// <summary> /// <summary>
@@ -66,16 +67,35 @@ public static class NestJobCost
return area - options.SalvageRate * salvage; return area - options.SalvageRate * salvage;
} }
/// <summary>Largest offered sheet area, charged by the benchmark per unplaced part.</summary> public static bool UsesExplicitCosts(NestJob job) =>
job.Plates.Any(s => s.Quantity != 0 && s.Cost.HasValue);
public static double RequireFinite(double value) => double.IsFinite(value) ? value
: throw new OverflowException("Stock cost arithmetic exceeded the finite scoring range.");
public static double GrossSheetCost(NestPlateStock stock) => RequireFinite(stock.Cost ?? stock.Area);
public static double NetSheetCost(NestJob job, NestJobPlateResult sheet) =>
PriceArea(sheet.Stock, NetSheetArea(job, sheet));
public static double NetSheetCost(NestJobOptions options, NestPlateStock stock, Box envelope) =>
PriceArea(stock, NetSheetArea(options, stock, envelope));
private static double PriceArea(NestPlateStock stock, double area) =>
RequireFinite(stock.Cost is double cost ? cost * (area / stock.Area) : area);
/// <summary>Largest offered sheet score, charged per unplaced part.</summary>
public static double UnplacedPartPenalty(NestJob job) => public static double UnplacedPartPenalty(NestJob job) =>
job.Plates.Count == 0 ? 0 : job.Plates.Max(stock => stock.Size.Width * stock.Size.Length); job.Plates.Count == 0 ? 0 : UsesExplicitCosts(job)
? job.Plates.Where(s => s.Quantity != 0).Max(GrossSheetCost)
: job.Plates.Max(s => RequireFinite(s.Area));
/// <summary> /// <summary>
/// Sum of net sheet areas plus unplaced quantity times the largest offered sheet area. /// Sum of net sheet costs plus unplaced quantity times the largest offered sheet score.
/// Matches the benchmark for a valid run; this method does not validate the result. /// Matches the benchmark for a valid run; this method does not validate the result.
/// </summary> /// </summary>
public static double Evaluate(NestJob job, NestJobResult result) => public static double Evaluate(NestJob job, NestJobResult result) =>
result.Plates.Sum(sheet => NetSheetArea(job, sheet)) RequireFinite(result.Plates.Sum(sheet => NetSheetCost(job, sheet))
+ System.Math.Max(0, job.Parts.Sum(part => part.Quantity) + System.Math.Max(0, job.Parts.Sum(part => part.Quantity)
- result.Plates.Sum(sheet => sheet.Placements.Count)) * UnplacedPartPenalty(job); - result.Plates.Sum(sheet => sheet.Placements.Count)) * UnplacedPartPenalty(job));
} }
+1 -1
View File
@@ -38,7 +38,7 @@ public sealed class NestJobRunner : INestingEngine
var placed = job.Parts.ToDictionary(part => part.Id, _ => 0, StringComparer.Ordinal); var placed = job.Parts.ToDictionary(part => part.Id, _ => 0, StringComparer.Ordinal);
var parts = job.Parts.ToDictionary(part => part.Id, StringComparer.Ordinal); var parts = job.Parts.ToDictionary(part => part.Id, StringComparer.Ordinal);
var used = job.Plates.ToDictionary(stock => stock.Id, _ => 0, StringComparer.Ordinal); var used = job.Plates.ToDictionary(stock => stock.Id, _ => 0, StringComparer.Ordinal);
var comparer = new NestJobCandidateComparer(job.Parts); var comparer = new NestJobCandidateComparer(job);
var nester = var nester =
job.Parts.Count == 0 job.Parts.Count == 0
? null ? null
+11 -1
View File
@@ -10,11 +10,13 @@ public static class NestJobValidator
public static void Validate(NestJob job) public static void Validate(NestJob job)
{ {
ArgumentNullException.ThrowIfNull(job); ArgumentNullException.ThrowIfNull(job);
ValidateStockCosts(job.Plates);
foreach (var stock in job.Plates) foreach (var stock in job.Plates)
{ {
var edges = stock.EdgeSpacing; var edges = stock.EdgeSpacing;
if ( if (
!Positive(stock.Size.Width) !Positive(stock.Area)
|| !Positive(stock.Size.Width)
|| !Positive(stock.Size.Length) || !Positive(stock.Size.Length)
|| !Nonnegative(stock.PartSpacing) || !Nonnegative(stock.PartSpacing)
|| !Nonnegative(edges.Left) || !Nonnegative(edges.Left)
@@ -61,6 +63,14 @@ public static class NestJobValidator
} }
} }
public static void ValidateStockCosts(IEnumerable<NestPlateStock> stocks)
{
var available = stocks.Where(s => s.Quantity != 0).ToArray();
if (available.Any(s => s.Cost.HasValue) && available.Any(s => !s.Cost.HasValue))
throw new ArgumentException("Specify a positive cost for every available stock row or omit all costs. Missing: "
+ string.Join(", ", available.Where(s => !s.Cost.HasValue).Select(s => s.Id)));
}
internal static void ValidateCandidate( internal static void ValidateCandidate(
PlateCandidate candidate, PlateCandidate candidate,
NestPlateStock stock, NestPlateStock stock,
+2 -1
View File
@@ -231,7 +231,8 @@ public static class NestLayoutCheck
private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) => private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) =>
ReferenceEquals(expected, actual) ReferenceEquals(expected, actual)
|| ( || (
expected.Size.Equals(actual.Size) expected.Cost == actual.Cost
&& expected.Size.Equals(actual.Size)
&& expected.PartSpacing.IsEqualTo(actual.PartSpacing) && expected.PartSpacing.IsEqualTo(actual.PartSpacing)
&& expected.EdgeSpacing.Left.IsEqualTo(actual.EdgeSpacing.Left) && expected.EdgeSpacing.Left.IsEqualTo(actual.EdgeSpacing.Left)
&& expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right) && expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right)
+102 -4
View File
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Cutouts;
namespace OpenNest.Engine.Jobs; namespace OpenNest.Engine.Jobs;
@@ -36,7 +37,8 @@ public sealed class NestPipelineResult
IReadOnlyList<string> violations, IReadOnlyList<string> violations,
bool canKeep, bool canKeep,
TimeSpan solveTime, TimeSpan solveTime,
TimeSpan validationTime TimeSpan validationTime,
IReadOnlyDictionary<string, Drawing> drawingsByPartId
) )
{ {
EngineName = engineName; EngineName = engineName;
@@ -47,6 +49,7 @@ public sealed class NestPipelineResult
CanKeep = canKeep; CanKeep = canKeep;
SolveTime = solveTime; SolveTime = solveTime;
ValidationTime = validationTime; ValidationTime = validationTime;
DrawingsByPartId = drawingsByPartId;
} }
public string EngineName { get; } public string EngineName { get; }
@@ -62,6 +65,7 @@ public sealed class NestPipelineResult
public NestJobStopReason StopReason => Raw.StopReason; public NestJobStopReason StopReason => Raw.StopReason;
public TimeSpan SolveTime { get; } public TimeSpan SolveTime { get; }
public TimeSpan ValidationTime { get; } public TimeSpan ValidationTime { get; }
internal IReadOnlyDictionary<string, Drawing> DrawingsByPartId { get; }
} }
/// <summary> /// <summary>
@@ -72,6 +76,15 @@ public sealed class NestPipelineResult
/// </summary> /// </summary>
public static class NestPipeline public static class NestPipeline
{ {
private sealed class PreviewProgress(IProgress<NestJobProgress> inner) : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value)
{
if (value != null && value.Stage == NestJobStage.EvaluatingCandidate)
inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 });
}
}
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through /// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>. /// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
/// Engine cancellation propagates unchanged.</summary> /// Engine cancellation propagates unchanged.</summary>
@@ -93,6 +106,32 @@ public static class NestPipeline
NestPipelineRequest request, NestPipelineRequest request,
IProgress<NestJobProgress> progress = null, IProgress<NestJobProgress> progress = null,
CancellationToken token = default CancellationToken token = default
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false);
/// <summary>Internal test-only integration trial. Normal Run never prepares cutouts;
/// this entry point must not be called by production front ends before cutting/post gates.</summary>
internal static NestPipelineResult RunCutoutPreview(
INestingEngine engine,
string engineName,
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: true);
internal static NestPipelineResult RunCutoutPreview(
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
) => RunCutoutPreview(NestingEngineRegistry.Create(request.EngineName),
request.EngineName, request, progress, token);
private static NestPipelineResult RunCore(
INestingEngine engine,
string engineName,
NestPipelineRequest request,
IProgress<NestJobProgress> progress,
CancellationToken token,
bool cutoutPreview
) )
{ {
ArgumentNullException.ThrowIfNull(engine); ArgumentNullException.ThrowIfNull(engine);
@@ -115,10 +154,15 @@ public static class NestPipeline
var job = new NestJob(parts, request.Stock, request.Options); var job = new NestJob(parts, request.Stock, request.Options);
NestJobValidator.Validate(job); NestJobValidator.Validate(job);
var prepass = cutoutPreview ? CutoutPipelinePrepass.Prepare(job, token) : null;
var engineJob = prepass?.EngineJob ?? job;
if (prepass != null)
NestJobValidator.Validate(engineJob);
var clock = Stopwatch.StartNew(); var clock = Stopwatch.StartNew();
var raw = var raw =
engine.Solve(job, progress, token) engine.Solve(engineJob, prepass == null || progress == null
? progress : new PreviewProgress(progress), token)
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result."); ?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
var solveTime = clock.Elapsed; var solveTime = clock.Elapsed;
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -129,8 +173,36 @@ public static class NestPipeline
kv => kv.Value.Name ?? kv.Key, kv => kv.Value.Name ?? kv.Key,
StringComparer.Ordinal StringComparer.Ordinal
); );
var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep); var checkedNames = prepass == null ? names : engineJob.Parts.ToDictionary(p => p.Id,
p => names.GetValueOrDefault(p.Id, p.Id), StringComparer.Ordinal);
var violations = NestLayoutCheck.Violations(engineJob, raw, checkedNames, out var canKeep).ToList();
if (prepass != null)
{
// A compound may be physically invalid even when its envelope is legal.
// Refuse every unverified trial, including with explicit invalid-result consent.
var failure = "Cutout engine output failed reconciliation";
if (violations.Count == 0 && prepass.TryExpand(job, raw, token, out var expanded, out failure))
{
raw = expanded;
violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep));
if (raw.Status != NestJobStatus.Complete)
violations.Add("Cutout engine did not complete the transformed job; unplaced frames' reserved inserts remain unplaced in the original job");
canKeep = physicalKeep && violations.Count == 0;
}
else
{
if (violations.Count == 0)
violations.Add(failure);
canKeep = false;
}
}
var freshness = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId);
violations.AddRange(freshness);
if (freshness.Count > 0)
canKeep = false;
var validationTime = clock.Elapsed; var validationTime = clock.Elapsed;
if (canKeep && violations.Count == 0)
_ = NestJobCost.Evaluate(job, raw);
var plates = canKeep var plates = canKeep
? raw.Plates.Select(sheet => new ProposedPlate( ? raw.Plates.Select(sheet => new ProposedPlate(
@@ -141,6 +213,31 @@ public static class NestPipeline
.ToList() .ToList()
: new List<ProposedPlate>(); : new List<ProposedPlate>();
// Binding clones the caller's current Program; a concurrent edit during binding
// must not return parts whose bytes differ from the already validated snapshot.
if (canKeep)
{
var changes = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId).ToList();
changes.AddRange(NestPipelineDrawingFreshness.BoundChanges(job, raw, plates, drawingsByPartId));
if (changes.Count > 0)
{
violations.AddRange(changes);
plates.Clear();
canKeep = false;
}
}
token.ThrowIfCancellationRequested();
if (prepass != null && canKeep)
{
var count = 0;
for (var i = 0; i < raw.Plates.Count; i++)
{
count += raw.Plates[i].Placements.Count;
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
raw.Plates[i].StockId, i, i + 1, count));
}
}
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
return new NestPipelineResult( return new NestPipelineResult(
engineName, engineName,
@@ -150,7 +247,8 @@ public static class NestPipeline
violations, violations,
canKeep, canKeep,
solveTime, solveTime,
validationTime validationTime,
drawingsByPartId
); );
} }
} }
@@ -20,6 +20,11 @@ public static class NestPipelineCommit
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (!result.CanKeep || (!result.IsValid && !allowInvalid)) if (!result.CanKeep || (!result.IsValid && !allowInvalid))
throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations."); throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations.");
if (NestPipelineDrawingFreshness.Changes(result.Job, result.DrawingsByPartId).Count > 0
|| NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
result.Plates, result.DrawingsByPartId).Count > 0)
throw new InvalidOperationException("A drawing or proposed part changed after the nesting proposal was validated; run Auto Nest again.");
token.ThrowIfCancellationRequested();
var applied = new List<Plate>(); var applied = new List<Plate>();
// Commit is synchronous on the caller's owning thread. Cancellation is checked // Commit is synchronous on the caller's owning thread. Cancellation is checked
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
/// <summary>Refuse a proposal when a caller drawing no longer matches its checked snapshot.</summary>
internal static class NestPipelineDrawingFreshness
{
internal static IReadOnlyList<string> Changes(NestJob job,
IReadOnlyDictionary<string, Drawing> drawings)
{
var changed = new List<string>();
foreach (var requirement in job.Parts)
{
if (!drawings.TryGetValue(requirement.Id, out var drawing)
|| drawing?.Program == null)
{
changed.Add($"Drawing for '{requirement.Id}' changed after the nesting snapshot");
continue;
}
try
{
var current = PartGeometrySnapshot.FromProgram(drawing.Program);
if (Same(requirement.Geometry, current))
continue;
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
or InvalidOperationException)
{
// A now-unsupported program is not the geometry that was checked.
}
changed.Add($"Drawing '{drawing.Name ?? requirement.Id}' changed after the nesting snapshot");
}
return changed;
}
/// <summary>Check the actual cloned programs, not only the drawing before/after
/// binding. A transient edit during Clone may leave the drawing unchanged.</summary>
internal static IReadOnlyList<string> BoundChanges(NestJob job, NestJobResult raw,
IReadOnlyList<ProposedPlate> plates, IReadOnlyDictionary<string, Drawing> drawings)
{
var changed = new List<string>();
var requirements = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
if (plates.Count != raw.Plates.Count)
return new[] { "Bound plate count changed after validation" };
for (var sheetIndex = 0; sheetIndex < plates.Count; sheetIndex++)
{
var poses = raw.Plates[sheetIndex].Placements;
var bound = plates[sheetIndex].Parts;
if (bound.Count != poses.Count)
return new[] { "Bound part count changed after validation" };
for (var index = 0; index < poses.Count; index++)
{
var pose = poses[index];
var actual = bound[index];
if (pose.PartId == null || !requirements.TryGetValue(pose.PartId, out var requirement)
|| !drawings.TryGetValue(pose.PartId, out var drawing)
|| !ReferenceEquals(actual.BaseDrawing, drawing))
return new[] { "A bound part no longer matches its requirement" };
try
{
var expected = new Part(DrawingJobMapper.CreateDrawing(requirement));
expected.Rotate(pose.Rotation);
expected.Location = new Vector(pose.X, pose.Y);
if (Same(PartGeometrySnapshot.FromProgram(expected.Program),
PartGeometrySnapshot.FromProgram(actual.Program))
&& Bits(expected.Location.X) == Bits(actual.Location.X)
&& Bits(expected.Location.Y) == Bits(actual.Location.Y)
&& Bits(expected.Rotation) == Bits(actual.Rotation))
continue;
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
or InvalidOperationException)
{
// A changed/unsupported program cannot represent the checked pose.
}
changed.Add($"Bound part for '{drawing.Name ?? requirement.Id}' changed after validation");
}
}
return changed;
}
private static bool Same(PartGeometrySnapshot a, PartGeometrySnapshot b) =>
a.Mode == b.Mode && a.Motions.Count == b.Motions.Count
&& a.Motions.Zip(b.Motions).All(pair =>
pair.First.Type == pair.Second.Type
&& Bits(pair.First.X) == Bits(pair.Second.X)
&& Bits(pair.First.Y) == Bits(pair.Second.Y)
&& Bits(pair.First.CenterX) == Bits(pair.Second.CenterX)
&& Bits(pair.First.CenterY) == Bits(pair.Second.CenterY)
&& pair.First.Rotation == pair.Second.Rotation
&& pair.First.Layer == pair.Second.Layer
&& pair.First.Suppressed == pair.Second.Suppressed);
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
}
+9
View File
@@ -16,7 +16,14 @@ public sealed class NestPlateStock
Spacing edgeSpacing = default, Spacing edgeSpacing = default,
int quadrant = 1 int quadrant = 1
) )
: this(id, size, quantity, partSpacing, edgeSpacing, quadrant, null) { }
public NestPlateStock(string id, Size size, int? quantity, double partSpacing,
Spacing edgeSpacing, int quadrant, double? cost)
{ {
if (cost.HasValue && (!double.IsFinite(cost.Value) || cost.Value <= 0))
throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null.");
Cost = cost;
ArgumentException.ThrowIfNullOrWhiteSpace(id); ArgumentException.ThrowIfNullOrWhiteSpace(id);
if (quantity < 0) if (quantity < 0)
throw new ArgumentOutOfRangeException(nameof(quantity)); throw new ArgumentOutOfRangeException(nameof(quantity));
@@ -36,6 +43,8 @@ public sealed class NestPlateStock
); );
} }
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
public double? Cost { get; }
public string Id { get; } public string Id { get; }
public Size Size { get; } public Size Size { get; }
+3 -1
View File
@@ -29,9 +29,11 @@ public static class NestStockBuilder
quantityPerOption, quantityPerOption,
template.PartSpacing, template.PartSpacing,
template.EdgeSpacing, template.EdgeSpacing,
template.Quadrant template.Quadrant,
option.Cost == 0 ? null : option.Cost
)); ));
} }
NestJobValidator.ValidateStockCosts(stock);
return stock.AsReadOnly(); return stock.AsReadOnly();
} }
@@ -9,7 +9,7 @@ namespace OpenNest.Engine.Jobs;
/// <summary> /// <summary>
/// Caller-stock-only allocation followed by bounded adjacent-sheet repacking. All replacements /// 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. /// must reproduce exactly the removed demand and reduce net sheet cost; inventory is transactional.
/// This is a deterministic heuristic, not an optimality or geometric impossibility proof. /// This is a deterministic heuristic, not an optimality or geometric impossibility proof.
/// </summary> /// </summary>
public sealed class StockLadderNestingEngine : INestingEngine public sealed class StockLadderNestingEngine : INestingEngine
@@ -41,12 +41,12 @@ public sealed class StockLadderNestingEngine : INestingEngine
// Probe actual validated single-part placements, not bounding-box fit assertions. // Probe actual validated single-part placements, not bounding-box fit assertions.
foreach (var part in job.Parts) foreach (var part in job.Parts)
foreach (var stock in job.Plates.Where(s => s.Quantity != 0)) foreach (var stock in job.Plates.Where(s => s.Quantity != 0))
{ {
var probe = Trial(stock, new[] { WithQuantity(part, 1) }); var probe = Trial(stock, new[] { WithQuantity(part, 1) });
if (probe.Placements.Count != 0) if (probe.Placements.Count != 0)
feasible[part.Id].Add(stock.Id); feasible[part.Id].Add(stock.Id);
} }
var ordered = job var ordered = job
.Parts.OrderBy(p => p.Priority) .Parts.OrderBy(p => p.Priority)
.ThenBy(p => feasible[p.Id].Count) .ThenBy(p => feasible[p.Id].Count)
@@ -97,8 +97,8 @@ public sealed class StockLadderNestingEngine : INestingEngine
// Initial construction only: material area, never raw part counts. Repacking below // Initial construction only: material area, never raw part counts. Repacking below
// compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill. // compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill.
var value = var value =
NestJobCost.NetSheetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]); NestJobCost.RequireFinite(NestJobCost.NetSheetCost(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]));
if (value < score - 1e-9) if (value < score - (stock.Cost.HasValue ? 0 : 1e-9))
{ {
winner = sheet; winner = sheet;
score = value; score = value;
@@ -182,55 +182,55 @@ public sealed class StockLadderNestingEngine : INestingEngine
var changed = false; var changed = false;
// Single downgrade and adjacent pair merge only: bounded local search, no combinatorial tree. // Single downgrade and adjacent pair merge only: bounded local search, no combinatorial tree.
for (var index = 0; index < sheets.Count; index++) for (var index = 0; index < sheets.Count; index++)
for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--) 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 => NestJobCost.NetSheetArea(job, s));
NestJobPlateResult replacement = null;
foreach (var stock in job.Plates)
{ {
token.ThrowIfCancellationRequested(); var old = sheets.Skip(index).Take(count).ToList();
var returned = old.Count(s => s.StockId == stock.Id); var demand = old.SelectMany(s => s.Placements)
if (stock.Quantity is int limit && used[stock.Id] - returned >= limit) .GroupBy(p => p.PartId)
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()); .ToDictionary(g => g.Key, g => g.Count());
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value)) var baseline = NestJobCost.RequireFinite(old.Sum(s => NestJobCost.NetSheetCost(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 =
NestJobCost.GrossSheetCost(stock) * (1 - job.Options.SalvageRate);
if (lowerBound >= baseline - (stock.Cost.HasValue ? 0 : 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 = NestJobCost.NetSheetCost(job, trial);
if (cost >= baseline - (stock.Cost.HasValue ? 0 : 1e-9))
continue;
baseline = cost;
replacement = trial;
}
if (replacement == null)
continue; continue;
var trial = new NestJobPlateResult(index, stock, candidate.Placements); // No accounting changes until the entire equivalent-demand candidate is valid.
var cost = NestJobCost.NetSheetArea(job, trial); foreach (var sheet in old)
if (cost >= baseline - 1e-9) used[sheet.StockId]--;
continue; used[replacement.StockId]++;
baseline = cost; sheets.RemoveRange(index, count);
replacement = trial; sheets.Insert(index, replacement);
changed = true;
} }
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; return changed;
} }
} }
@@ -100,7 +100,7 @@ public sealed class DefaultNestingEngine : INestingEngine
} }
} }
private sealed record Scored(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order) private sealed record Scored(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order, bool Priced)
{ {
public static Scored Of(NestJob job, NestJobResult result, int order) public static Scored Of(NestJob job, NestJobResult result, int order)
{ {
@@ -109,7 +109,7 @@ public sealed class DefaultNestingEngine : INestingEngine
var unplaced = System.Math.Max(0, job.Parts.Sum(p => p.Quantity) - placed); var unplaced = System.Math.Max(0, job.Parts.Sum(p => p.Quantity) - placed);
// Cost needs every placement to name a known part, which only a valid result guarantees. // Cost needs every placement to name a known part, which only a valid result guarantees.
var cost = valid ? NestJobCost.Evaluate(job, result) : double.PositiveInfinity; var cost = valid ? NestJobCost.Evaluate(job, result) : double.PositiveInfinity;
return new Scored(result, valid, unplaced, cost, order); return new Scored(result, valid, unplaced, cost, order, NestJobCost.UsesExplicitCosts(job));
} }
public bool IsBetterThan(Scored other) public bool IsBetterThan(Scored other)
@@ -118,6 +118,8 @@ public sealed class DefaultNestingEngine : INestingEngine
return Valid; return Valid;
if (Unplaced != other.Unplaced) if (Unplaced != other.Unplaced)
return Unplaced < other.Unplaced; return Unplaced < other.Unplaced;
if (Priced && Cost != other.Cost)
return Cost < other.Cost;
var scale = System.Math.Max(1, System.Math.Max(System.Math.Abs(Cost), System.Math.Abs(other.Cost))); var scale = System.Math.Max(1, System.Math.Max(System.Math.Abs(Cost), System.Math.Abs(other.Cost)));
if (double.IsFinite(Cost) && double.IsFinite(other.Cost) && System.Math.Abs(Cost - other.Cost) > 1e-9 * scale) if (double.IsFinite(Cost) && double.IsFinite(other.Cost) && System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
return Cost < other.Cost; return Cost < other.Cost;
@@ -14,7 +14,7 @@ namespace OpenNest.Engine.NestingEngines.Irregular;
/// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places /// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places
/// either the largest part that fills a gap behind the packing front, or the part that advances /// either the largest part that fills a gap behind the packing front, or the part that advances
/// the front least per unit of area covered. Across sheets, every available stock size is /// the front least per unit of area covered. Across sheets, every available stock size is
/// trial-packed and the one with the lowest estimated whole-job cost (its own net area plus the /// trial-packed and the one with the lowest estimated whole-job cost (its own net cost plus the
/// remaining demand at the best efficiency seen) is committed. A handful of deterministic /// remaining demand at the best efficiency seen) is committed. A handful of deterministic
/// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins. /// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins.
/// ///
@@ -120,7 +120,7 @@ public sealed class IrregularNestingEngine : INestingEngine
var reason = run.Reason; var reason = run.Reason;
if (unplaced > 0 && reason == NestJobStopReason.Completed) if (unplaced > 0 && reason == NestJobStopReason.Completed)
reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold. reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold.
return new Plan(run.Sheets, run.Net + unplaced * Penalty, unplaced, reason); return new Plan(run.Sheets, NestJobCost.RequireFinite(run.Net + unplaced * Penalty), unplaced, reason, NestJobCost.UsesExplicitCosts(job));
} }
/// <summary> /// <summary>
@@ -138,7 +138,7 @@ public sealed class IrregularNestingEngine : INestingEngine
var prefix = sheets.Take(sheets.Count - k).ToList(); var prefix = sheets.Take(sheets.Count - k).ToList();
var tail = sheets.Skip(sheets.Count - k).ToList(); var tail = sheets.Skip(sheets.Count - k).ToList();
var tailParts = tail.Sum(s => s.Parts.Count); var tailParts = tail.Sum(s => s.Parts.Count);
var tailNet = tail.Sum(s => NetArea(job.Options, s)); var tailNet = tail.Sum(s => NetCost(job.Options, s));
var tailDemand = new int[types.Count]; var tailDemand = new int[types.Count];
foreach (var part in tail.SelectMany(s => s.Parts)) foreach (var part in tail.SelectMany(s => s.Parts))
tailDemand[part.Orientation.TypeIndex]++; tailDemand[part.Orientation.TypeIndex]++;
@@ -148,7 +148,7 @@ public sealed class IrregularNestingEngine : INestingEngine
int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null; int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null;
Run? bestRun = null; Run? bestRun = null;
var bestNet = tailNet - 1e-9 * System.Math.Max(1, tailNet); var bestNet = tailNet - (NestJobCost.UsesExplicitCosts(job) ? 0 : 1e-9 * System.Math.Max(1, tailNet));
foreach (var (axis, beta) in Variants) foreach (var (axis, beta) in Variants)
foreach (var first in job.Plates) foreach (var first in job.Plates)
{ {
@@ -245,7 +245,7 @@ public sealed class IrregularNestingEngine : INestingEngine
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock)); var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
var fill = packer.Fill(remaining, token); var fill = packer.Fill(remaining, token);
if (fill.Parts.Count > 0) if (fill.Parts.Count > 0)
trials.Add((fill, NetArea(job.Options, fill))); trials.Add((fill, NetCost(job.Options, fill)));
} }
if (trials.Count == 0) if (trials.Count == 0)
@@ -260,14 +260,14 @@ public sealed class IrregularNestingEngine : INestingEngine
var remainingArea = types.Sum(t => remaining[t.Index] * t.Area); var remainingArea = types.Sum(t => remaining[t.Index] * t.Area);
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12)); var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12));
var chosen = trials var chosen = trials
.Select((t, order) => (t.Fill, t.Net, order, Estimate: t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio)) .Select((t, order) => (t.Fill, t.Net, order, Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio)))
.OrderBy(t => t.Estimate) .OrderBy(t => t.Estimate)
.ThenByDescending(t => t.Fill.Parts.Count) .ThenByDescending(t => t.Fill.Parts.Count)
.ThenBy(t => t.order) .ThenBy(t => t.order)
.First(); .First();
sheets.Add(chosen.Fill); sheets.Add(chosen.Fill);
net += chosen.Net; net = NestJobCost.RequireFinite(net + chosen.Net);
used[chosen.Fill.Stock.Id]++; used[chosen.Fill.Stock.Id]++;
foreach (var part in chosen.Fill.Parts) foreach (var part in chosen.Fill.Parts)
remaining[part.Orientation.TypeIndex]--; remaining[part.Orientation.TypeIndex]--;
@@ -289,21 +289,23 @@ public sealed class IrregularNestingEngine : INestingEngine
return builder.Build(plan.Reason); return builder.Build(plan.Reason);
} }
private static double NetArea(NestJobOptions options, SheetFill fill) private static double NetCost(NestJobOptions options, SheetFill fill)
{ {
if (fill.Parts.Count == 0) return fill.Stock.Area; if (fill.Parts.Count == 0) return NestJobCost.GrossSheetCost(fill.Stock);
var left = fill.Parts.Min(p => p.Left); var left = fill.Parts.Min(p => p.Left);
var bottom = fill.Parts.Min(p => p.Bottom); var bottom = fill.Parts.Min(p => p.Bottom);
return NestJobCost.NetSheetArea(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom, return NestJobCost.NetSheetCost(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom,
fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom)); fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom));
} }
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason) private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason, bool Priced)
{ {
public bool IsBetterThan(Plan other) public bool IsBetterThan(Plan other)
{ {
if (Unplaced != other.Unplaced) if (Unplaced != other.Unplaced)
return Unplaced < other.Unplaced; return Unplaced < other.Unplaced;
if (Priced && Cost != other.Cost)
return Cost < other.Cost;
var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost)); var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost));
if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale) if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
return Cost < other.Cost; return Cost < other.Cost;
@@ -18,8 +18,8 @@ namespace OpenNest.Engine.NestingEngines.Rectangles;
/// ///
/// Sheet by sheet, each available stock is packed under several free-space scoring rules and /// Sheet by sheet, each available stock is packed under several free-space scoring rules and
/// two pick modes (best-fitting box anywhere, or largest type first). The candidate sheet with /// two pick modes (best-fitting box anywhere, or largest type first). The candidate sheet with
/// the lowest estimated whole-job cost wins: its salvage-credited net area (NestJobCost) plus the /// the lowest estimated whole-job cost wins: its salvage-credited net cost (NestJobCost) plus the
/// remaining demand priced at the best net-area-per-part-area ratio seen among the candidates. /// remaining demand priced at the best net-cost-per-part-area ratio seen among the candidates.
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token. /// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
/// </summary> /// </summary>
public sealed class RectanglesNestingEngine : INestingEngine public sealed class RectanglesNestingEngine : INestingEngine
@@ -78,7 +78,7 @@ public sealed class RectanglesNestingEngine : INestingEngine
{ {
var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token); var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token);
if (plan.Parts.Count > 0) if (plan.Parts.Count > 0)
trials.Add((plan, NetArea(job, plan))); trials.Add((plan, NetCost(job, plan)));
} }
} }
@@ -101,7 +101,7 @@ public sealed class RectanglesNestingEngine : INestingEngine
/// <summary> /// <summary>
/// Picks the sheet with the lowest estimated whole-job cost. Remaining demand is priced at the /// Picks the sheet with the lowest estimated whole-job cost. Remaining demand is priced at the
/// best net-area-per-material ratio any candidate achieved, so a sheet that finishes the job /// best net-cost-per-material ratio any candidate achieved, so a sheet that finishes the job
/// competes fairly with a denser partial one. Ties: more material placed, then enumeration order. /// competes fairly with a denser partial one. Ties: more material placed, then enumeration order.
/// </summary> /// </summary>
private static SheetPlan Choose( private static SheetPlan Choose(
@@ -111,7 +111,7 @@ public sealed class RectanglesNestingEngine : INestingEngine
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Plan.MaterialArea, 1e-12)); var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Plan.MaterialArea, 1e-12));
return trials return trials
.Select((t, order) => (t.Plan, order, .Select((t, order) => (t.Plan, order,
Estimate: t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio)) Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio)))
.OrderBy(t => PriorityDebt(types, remaining, t.Plan)) .OrderBy(t => PriorityDebt(types, remaining, t.Plan))
.ThenBy(t => t.Estimate) .ThenBy(t => t.Estimate)
.ThenByDescending(t => t.Plan.MaterialArea) .ThenByDescending(t => t.Plan.MaterialArea)
@@ -135,10 +135,10 @@ public sealed class RectanglesNestingEngine : INestingEngine
return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed; return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed;
} }
private static double NetArea(NestJob job, SheetPlan plan) => private static double NetCost(NestJob job, SheetPlan plan) =>
plan.Envelope is { } envelope plan.Envelope is { } envelope
? NestJobCost.NetSheetArea(job.Options, plan.Stock, envelope) ? NestJobCost.NetSheetCost(job.Options, plan.Stock, envelope)
: plan.Stock.Area; : NestJobCost.GrossSheetCost(plan.Stock);
private static bool FitsStock(NestPlateStock stock, BoxOrientation o) private static bool FitsStock(NestPlateStock stock, BoxOrientation o)
{ {
@@ -0,0 +1,105 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.Mcp;
using OpenNest.Mcp.Tools;
namespace OpenNest.FrontEnd.Tests;
[Collection("FrontEndRegistry")]
public class McpMixReportingTests
{
private static Drawing Rectangle(string name, double width, double length)
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(width, 0)));
shape.Entities.Add(new Line(new Vector(width, 0), new Vector(width, length)));
shape.Entities.Add(new Line(new Vector(width, length), new Vector(0, length)));
shape.Entities.Add(new Line(new Vector(0, length), new Vector(0, 0)));
return new Drawing(name, OpenNest.Converters.ConvertGeometry.ToProgram(shape));
}
private static NestSession Session()
{
var session = new NestSession();
session.Drawings.Add(Rectangle("A", 2, 2));
session.Drawings.Add(Rectangle("B", 20, 20));
session.Plates.Add(new Plate(10, 10));
return session;
}
[Fact]
public void PackReportsZeroForUnfitDrawingAndCountsOnlyNewParts()
{
var session = Session();
var plate = session.GetPlate(0);
plate.Parts.Add(new Part(session.Drawings[0]));
var text = new NestingTools(session).PackPlate(0, "A,B", "10,10");
Assert.Contains("A: requested=10", text);
Assert.Contains("B: requested=10, placed=0, remaining=10", text);
Assert.Contains("remaining demand", text);
Assert.DoesNotContain("ratio not met", text);
Assert.Contains($"A: requested=10, placed={plate.Parts.Count - 1}, remaining={11 - plate.Parts.Count}", text);
Assert.True(plate.Parts.Count > 1);
}
[Fact]
public void AutoNestReportsSkewedAndZeroProgressByRequirement()
{
var session = Session();
var engine = "MixStub-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "test", () => new Stub());
var tool = new NestingTools(session);
var text = tool.AutoNestPlate(0, "A,B", "2,2", engine);
Assert.Contains("A: requested=2, placed=1, remaining=1", text);
Assert.Contains("B: requested=2, placed=0, remaining=2", text);
Assert.Contains("remaining demand", text);
Assert.DoesNotContain("ratio not met", text);
// Duplicate tokens must be refused before the engine can mutate a fresh target.
var fresh = Session();
Assert.Contains("duplicate", new NestingTools(fresh).AutoNestPlate(0, "A,A", "2,2", engine), StringComparison.OrdinalIgnoreCase);
Assert.Empty(fresh.GetPlate(0).Parts);
Assert.Single(session.GetPlate(0).Parts);
}
[Fact]
public void PackZeroProgressAndAmbiguousNamesAreExplicit()
{
var session = Session();
session.GetPlate(0).Size = new Size(1, 1);
var text = new NestingTools(session).PackPlate(0, "A,B", "2,2");
Assert.Contains("A: requested=2, placed=0, remaining=2", text);
Assert.Contains("B: requested=2, placed=0, remaining=2", text);
Assert.Contains("zero progress", text);
Assert.Empty(session.GetPlate(0).Parts);
session.Drawings.Add(Rectangle("A", 1, 1));
Assert.Contains("ambiguous drawing name", new NestingTools(session).PackPlate(0, "A,B", "2,2"));
Assert.Empty(session.GetPlate(0).Parts);
}
[Fact]
public void ZeroDemandHasZeroRemainingAndNegativeDemandIsRejected()
{
var session = Session();
var tools = new NestingTools(session);
var text = tools.PackPlate(0, "A", "0");
Assert.Contains("A: requested=0, placed=0, remaining=0", text);
Assert.Contains("zero progress", text);
Assert.Contains("negative", tools.PackPlate(0, "A", "-1"), StringComparison.OrdinalIgnoreCase);
Assert.Empty(session.GetPlate(0).Parts);
}
private sealed class Stub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var placed = new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) };
return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, job.Plates[0], placed)],
[], []);
}
}
}
@@ -0,0 +1,112 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Mcp;
using OpenNest.Mcp.Tools;
namespace OpenNest.FrontEnd.Tests;
public class McpPlateToolsTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-mcp-plates-" + Guid.NewGuid());
public McpPlateToolsTests() => Directory.CreateDirectory(directory);
public void Dispose() => Directory.Delete(directory, true);
[Fact]
public void NegativeIndexOnLoadedNestIsNotAPlate()
{
var session = new NestSession { Nest = new Nest() };
var loaded = new Plate(10, 20);
session.Nest.Plates.Add(loaded);
var appended = new Plate(30, 40);
session.Plates.Add(appended);
Assert.Null(session.GetPlate(-1));
Assert.Equal("Error: plate -1 not found", new SetupTools(session).ClearPlate(-1));
Assert.Same(loaded, session.GetPlate(0));
Assert.Same(appended, session.GetPlate(1));
Assert.Null(session.GetPlate(2));
Assert.Single(session.Nest.Plates);
Assert.Single(session.Plates);
var sessionOnly = new NestSession();
sessionOnly.Plates.Add(appended);
Assert.Null(sessionOnly.GetPlate(-1));
Assert.Same(appended, sessionOnly.GetPlate(0));
Assert.Null(sessionOnly.GetPlate(1));
}
private static string Delete(SetupTools tools, int index) => tools.DeletePlate(index);
[Fact]
public void DeleteEmptyLoadedMiddlePlateRenumbersAppendedAndPersists()
{
var session = new NestSession { Nest = new Nest("fixture") };
var drawing = new Drawing("square", new Program());
session.Nest.Drawings.Add(drawing);
var occupied = new Plate(20, 30) { Quantity = 2 };
occupied.Parts.Add(new Part(drawing));
var unused = new Plate(40, 50);
var appended = new Plate(60, 70) { PartSpacing = 1.25, Quantity = 3 };
session.Nest.Plates.Add(occupied);
session.Nest.Plates.Add(unused);
session.Plates.Add(appended);
var tools = new SetupTools(session);
var result = Delete(tools, 1);
Assert.Contains("Deleted plate 1", result);
Assert.Contains("2 -> 1", result);
Assert.Single(session.Nest.Plates);
Assert.Same(appended, session.GetPlate(1));
Assert.Null(session.GetPlate(2));
Assert.Contains("60.0 x 70.0", new InspectionTools(session).GetPlateInfo(1));
Assert.Equal(2, drawing.Quantity.Nested);
var path = Path.Combine(directory, "cleaned.nest");
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
var reloaded = new NestReader(path).Read();
// NestWriter already omits empty plates; the deletion is session cleanup,
// and the saved file must still retain the occupied plate and its counts.
var saved = Assert.Single(reloaded.Plates);
Assert.Single(saved.Parts);
Assert.Equal(20, saved.Size.Width);
Assert.Equal(2, saved.Quantity);
Assert.Equal(2, reloaded.Drawings.Single().Quantity.Nested);
}
[Fact]
public void DeleteRefusesOccupiedCutoffAndInvalidIndicesWithoutMutation()
{
var session = new NestSession { Nest = new Nest("fixture") };
var drawing = new Drawing("square", new Program());
session.Nest.Drawings.Add(drawing);
var occupied = new Plate(20, 30) { Quantity = 4 };
var part = new Part(drawing);
occupied.Parts.Add(part);
var cutoff = new Plate(40, 50);
var definition = new CutOff(new Vector(10, 10), CutOffAxis.Vertical);
cutoff.CutOffs.Add(definition);
session.Nest.Plates.Add(occupied);
session.Plates.Add(cutoff);
var tools = new SetupTools(session);
Assert.Contains("occupied", Delete(tools, 0));
Assert.Contains("cutoff", Delete(tools, 1), StringComparison.OrdinalIgnoreCase);
Assert.Contains("not found", Delete(tools, -1));
Assert.Contains("not found", Delete(tools, 2));
Assert.Same(occupied, session.GetPlate(0));
Assert.Same(cutoff, session.GetPlate(1));
Assert.Same(part, Assert.Single(occupied.Parts));
Assert.Same(definition, Assert.Single(cutoff.CutOffs));
Assert.Equal(4, drawing.Quantity.Nested);
Assert.Single(session.Nest.Plates);
Assert.Single(session.Plates);
// The session-only collection must also delete without requiring a loaded nest.
var standalone = new NestSession();
standalone.Plates.Add(new Plate(12, 13));
Assert.Contains("Deleted plate 0", Delete(new SetupTools(standalone), 0));
Assert.Empty(standalone.Plates);
}
}
+506
View File
@@ -0,0 +1,506 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Mcp;
using OpenNest.Mcp.Tools;
namespace OpenNest.FrontEnd.Tests;
[Collection("FrontEndRegistry")]
public class McpWholeJobTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-mcp-job-" + Guid.NewGuid());
public McpWholeJobTests() => Directory.CreateDirectory(directory);
public void Dispose() => Directory.Delete(directory, true);
private static Drawing Square(string name, double size = 2)
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(size, 0)));
shape.Entities.Add(new Line(new Vector(size, 0), new Vector(size, size)));
shape.Entities.Add(new Line(new Vector(size, size), new Vector(0, size)));
shape.Entities.Add(new Line(new Vector(0, size), new Vector(0, 0)));
return new Drawing(name, OpenNest.Converters.ConvertGeometry.ToProgram(shape));
}
private static NestSession Session()
{
var session = new NestSession { Nest = new Nest("job") };
session.Nest.Drawings.Add(Square("A"));
session.Nest.Drawings.Add(Square("B"));
session.Nest.Plates.Add(new Plate(10, 10) { PartSpacing = 0.25, Quantity = 1 });
return session;
}
private static string Call(NestingTools tools, int plateIndex, string names, string quantities,
string engine, bool noNew = false, CancellationToken token = default) =>
tools.AutoNestJob(plateIndex, names, quantities, engine, noNew, token);
[Fact]
public void AcceptedPricesAndRequirementsSurviveSaveWithoutLosingMetadata()
{
var session = Session();
session.Nest.Customer = "Cost customer";
session.Nest.Notes = "Keep these notes";
session.Nest.Units = Units.Inches;
session.Nest.SalvageRate = 0.25;
var output = new NestingTools(session).AutoNestJob(0, "A,B", "1,1", "Rectangles", sheets:
[new SheetStockInput { Width = 10, Length = 10, Quantity = 2, Cost = 50 },
new SheetStockInput { Width = 12, Length = 12, Quantity = 2, Cost = 7 }]);
Assert.Contains("Whole job complete", output);
Assert.Contains("gross total=7", output);
Assert.Contains("net score=7", output);
Assert.Contains("supplied-cost", output);
Assert.All(session.AllDrawings(), d => Assert.Equal(1, d.Quantity.Required));
var path = Path.Combine(directory, "priced.nest");
new InputTools(session).SaveNest(path);
var reopened = new NestReader(path).Read();
Assert.Equal("Cost customer", reopened.Customer);
Assert.Equal("Keep these notes", reopened.Notes);
Assert.Equal(Units.Inches, reopened.Units);
Assert.Equal(0.25, reopened.SalvageRate);
Assert.Equal(new[] { 50d, 7d }, reopened.PlateOptions.Select(o => o.Cost));
Assert.All(reopened.Drawings, d => Assert.Equal(1, d.Quantity.Required));
Assert.Equal(2, reopened.Plates.Sum(p => p.Parts.Count));
}
[Theory]
[InlineData(null)]
[InlineData(0d)]
[InlineData(-1d)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void InvalidCostsDoNotMutateSession(double? cost)
{
var session = Session();
var quantities = session.AllDrawings().Select(d => d.Quantity.Required).ToArray();
var output = new NestingTools(session).AutoNestJob(0, "A,B", "1,1", "Rectangles", sheets:
[new SheetStockInput { Width = 10, Length = 10, Quantity = 1, Cost = 5 },
new SheetStockInput { Width = 12, Length = 12, Quantity = 1, Cost = cost }]);
Assert.Contains("Error:", output);
Assert.Contains("sheets[1]", output);
Assert.Empty(session.GetPlate(0).Parts);
Assert.Empty(session.Nest.PlateOptions);
Assert.Equal(quantities, session.AllDrawings().Select(d => d.Quantity.Required));
}
[Fact]
public void FiniteSheetInputsReachEngineWithTemplateSettings()
{
var session = Session();
var template = session.GetPlate(0);
template.Size = new Size(60, 120);
template.EdgeSpacing = new Spacing(1, 2, 3, 4);
template.Quadrant = 3;
var engine = "StockMap-" + Guid.NewGuid();
var called = false;
NestingEngineRegistry.Register(engine, "inspect finite stock", () => new InspectStub(job =>
{
called = true;
Assert.Equal(2, job.Plates.Count);
Assert.Equal(["stock-0", "stock-1"], job.Plates.Select(s => s.Id));
Assert.Equal([new Size(60, 120), new Size(48, 96)], job.Plates.Select(s => s.Size));
Assert.Equal([1, 2], job.Plates.Select(s => s.Quantity));
Assert.All(job.Plates, stock =>
{
Assert.Equal(0.25, stock.PartSpacing);
Assert.Equal(template.EdgeSpacing, stock.EdgeSpacing);
Assert.Equal(3, stock.Quadrant);
});
Assert.Equal(2, job.Options.MaxPlates); // capped by demand, not plate.Quantity
return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.StockExhausted, [], [], []);
}));
var rows = new[] { new SheetStockInput { Width = 60, Length = 120, Quantity = 1 },
new SheetStockInput { Width = 48, Length = 96, Quantity = 2 } };
var tool = new NestingTools(session);
Assert.Contains("incomplete", tool.AutoNestJob(0, "A,B", "1,1", engine, sheets: rows));
Assert.True(called);
called = false;
rows[1].Width = double.NaN;
Assert.Contains("sheets[1]", tool.AutoNestJob(0, "A,B", "1,1", engine, sheets: rows));
Assert.False(called);
Assert.Single(session.AllPlates());
Assert.Empty(template.Parts);
Assert.Equal(1, template.Quantity); // no inventory drawn from the template quantity
}
private sealed class InspectStub(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => solve(job);
}
[Fact]
public void FiniteMixedSheetsCommitByStockIdAndRoundTrip()
{
var session = Session();
var template = session.GetPlate(0);
template.Size = new Size(6, 6);
template.Quantity = 9; // repeat count is not inventory
var small = session.Nest.Drawings.Single(d => d.Name == "B");
var large = Square("large", 4); // four-by-four cannot fit a three-by-three sheet
session.Nest.Drawings.Add(large);
var rows = new[] { new SheetStockInput { Width = 6, Length = 6, Quantity = 1 },
new SheetStockInput { Width = 3, Length = 3, Quantity = 2 } };
var engine = "MixedStock-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "two different sheets", () => new InspectStub(job =>
{
Assert.Equal(2, job.Options.MaxPlates);
Assert.Equal([1, 2], job.Plates.Select(s => s.Quantity));
// Small stock first: result ordinal must not resize/reuse the large template.
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, job.Plates[1], [new NestJobPlacement(job.Parts[1].Id, 0, 0.5, 0.5, 0)]),
new NestJobPlateResult(1, job.Plates[0], [new NestJobPlacement(job.Parts[0].Id, 1, 0.5, 0.5, 0)])], [], []);
}));
var text = new NestingTools(session).AutoNestJob(0, "large,B", "1,1", engine, sheets: rows);
Assert.Contains("Whole job complete", text);
Assert.Contains("stock-0 (6x6): used=1, remaining=0, reused=1, created=0", text);
Assert.Contains("stock-1 (3x3): used=1, remaining=1, reused=0, created=1", text);
Assert.Contains("plate 0 -> stock-0", text);
Assert.Contains("plate 1 -> stock-1", text);
Assert.Same(template, session.GetPlate(0));
Assert.Equal(new Size(6, 6), template.Size);
Assert.Same(large, Assert.Single(template.Parts).BaseDrawing);
Assert.Same(small, Assert.Single(session.GetPlate(1).Parts).BaseDrawing);
Assert.Equal(1, template.Quantity);
var path = Path.Combine(directory, "mixed.nest");
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
var reloaded = new NestReader(path).Read();
Assert.Equal([new Size(6, 6), new Size(3, 3)], reloaded.Plates.Select(p => p.Size));
Assert.All(reloaded.Plates, p => Assert.Equal(1, p.Quantity));
Assert.Equal(1, reloaded.Drawings.Single(d => d.Name == "large").Quantity.Nested);
Assert.Equal(1, reloaded.Drawings.Single(d => d.Name == "B").Quantity.Nested);
}
[Fact]
public void FiniteStockOverdrawIsRejectedBeforeAnySessionMutation()
{
var session = Session();
var plate = session.GetPlate(0);
plate.Size = new Size(3, 3);
var engine = "Overdraw-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "overdraw stock", () => new InspectStub(job =>
new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, job.Plates[0], [new NestJobPlacement(job.Parts[0].Id, 0, 0.5, 0.5, 0)]),
new NestJobPlateResult(1, job.Plates[0], [new NestJobPlacement(job.Parts[1].Id, 1, 0.5, 0.5, 0)])], [], [])));
var text = new NestingTools(session).AutoNestJob(0, "A,B", "1,1", engine,
sheets: [new SheetStockInput { Width = 3, Length = 3, Quantity = 1 },
new SheetStockInput { Width = 6, Length = 6, Quantity = 2 }]);
Assert.Contains("stock-0", text);
Assert.Contains("invalid", text, StringComparison.OrdinalIgnoreCase);
Assert.Single(session.AllPlates());
Assert.Empty(plate.Parts);
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
}
[Fact]
public void FiniteStockRejectsInvalidInventoryBeforeSolving()
{
var session = Session();
var plate = session.GetPlate(0);
var engine = "NeverSolve-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "should not be called", () => new InspectStub(_ =>
throw new Xunit.Sdk.XunitException("invalid input reached solver")));
var tools = new NestingTools(session);
var valid = new SheetStockInput { Width = 10, Length = 10, Quantity = 1 };
Assert.Contains("at least one", tools.AutoNestJob(0, "A", "1", engine, sheets: []));
Assert.Contains("sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
sheets: [new SheetStockInput { Width = 10, Length = 10, Quantity = 0 }]));
Assert.Contains("sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
sheets: [new SheetStockInput { Width = double.PositiveInfinity, Length = 10, Quantity = 1 }]));
Assert.Contains("sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
sheets: [new SheetStockInput { Width = 0, Length = 10, Quantity = 1 }]));
Assert.Contains("sheets[1] duplicates sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
sheets: [valid, new SheetStockInput { Width = 10, Length = 10, Quantity = 2 }]));
Assert.Contains("sheet inventory exceeds", tools.AutoNestJob(0, "A", "1", engine,
sheets: [new SheetStockInput { Width = 10, Length = 10, Quantity = int.MaxValue },
new SheetStockInput { Width = 11, Length = 11, Quantity = 1 }]));
Assert.Contains("cannot be combined", tools.AutoNestJob(0, "A", "1", engine,
no_new_plates: true, sheets: [valid]));
Assert.Single(session.AllPlates());
Assert.Empty(plate.Parts);
Assert.Equal(1, plate.Quantity);
}
[Fact]
public void FiniteInventoryUsesOnlyOneOfTwoMatchingEmptySheets()
{
var session = Session();
var original = session.GetPlate(0);
session.Plates.Add(new Plate(10, 10) { PartSpacing = 0.25, Quantity = 5 });
var text = new NestingTools(session).AutoNestJob(0, "A", "1", "Rectangles",
sheets: [new SheetStockInput { Width = 10, Length = 10, Quantity = 1 }]);
Assert.Contains("Whole job complete", text);
Assert.Contains("stock-0 (10x10): used=1, remaining=0", text);
Assert.Single(original.Parts);
Assert.Empty(session.GetPlate(1).Parts);
Assert.Equal(5, session.GetPlate(1).Quantity);
}
[Fact]
public void LaterSelectedTemplateIsReusedBeforeOtherCompatibleEmptySheet()
{
var session = Session();
var earlier = session.GetPlate(0);
var selected = new Plate(10, 10) { PartSpacing = 0.25, Quantity = 3 };
session.Plates.Add(selected);
var text = new NestingTools(session).AutoNestJob(1, "A", "1", "Rectangles");
Assert.Contains("Whole job complete", text);
Assert.Empty(earlier.Parts);
Assert.Single(selected.Parts);
Assert.Equal(1, selected.Quantity);
}
[Fact]
public void CreatesSheetsForCompleteMixedJobThenSavesAndReloads()
{
var session = Session();
var original = session.GetPlate(0);
var engine = "JobTwoSheets-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "two safe sheets", () => new TwoSheetStub());
var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine);
Assert.Contains("complete", text, StringComparison.OrdinalIgnoreCase);
Assert.Equal(2, session.AllPlates().Count);
Assert.Same(original, session.GetPlate(0));
Assert.Single(session.Plates);
Assert.All(session.AllPlates(), plate =>
{
Assert.Equal(2, plate.Parts.Count);
Assert.Equal(0.25, plate.PartSpacing);
Assert.Equal(1, plate.Quantity);
});
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
var path = Path.Combine(directory, "complete.nest");
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
var loaded = new NestReader(path).Read();
Assert.Equal(2, loaded.Plates.Count);
Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "B").Quantity.Nested);
}
[Fact]
public void NoNewPlateFlagRefusesIncompleteProposalWithoutMutation()
{
var session = Session();
var original = session.GetPlate(0);
var engine = "JobLimited-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "one sheet when capped", () => new TwoSheetStub());
var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine, noNew: true);
Assert.Contains("incomplete", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Nothing committed", text);
Assert.Single(session.AllPlates());
Assert.Same(original, session.GetPlate(0));
Assert.Empty(original.Parts);
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
}
[Fact]
public void ExistingQuantityAndPrecreatedSheetsCompleteWithRealEngine()
{
var session = Session();
var occupied = session.GetPlate(0);
occupied.Quantity = 2;
occupied.Parts.Add(new Part(session.Nest.Drawings.Single(d => d.Name == "A")));
var first = new Plate(3, 3) { PartSpacing = 0.25, Quantity = 1 };
var second = new Plate(3, 3) { PartSpacing = 0.25, Quantity = 1 };
session.Plates.Add(first);
session.Plates.Add(second);
var text = Call(new NestingTools(session), 1, "A,B", "3,1", "Rectangles", noNew: true);
Assert.Contains("Whole job complete", text);
Assert.Contains("0 new sheet(s)", text);
Assert.Same(first, session.GetPlate(1));
Assert.Same(second, session.GetPlate(2));
Assert.Equal(3, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(1, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
Assert.Equal(3, session.AllPlates().Count);
var path = Path.Combine(directory, "existing.nest");
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
var loaded = new NestReader(path).Read();
Assert.Equal(3, loaded.Plates.Count);
Assert.Equal(3, loaded.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(1, loaded.Drawings.Single(d => d.Name == "B").Quantity.Nested);
}
[Fact]
public void RealEngineAddsSheetsWhenOnlyTemplateExists()
{
var session = Session();
session.GetPlate(0).Size = new Size(3, 3);
var text = Call(new NestingTools(session), 0, "A,B", "2,2", "Rectangles");
Assert.Contains("Whole job complete", text);
Assert.Equal(4, session.AllPlates().Count);
Assert.All(session.AllPlates(), p => Assert.Single(p.Parts));
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
}
[Fact]
public void InvalidPluginLayoutCannotCreateOrOccupySheets()
{
var session = Session();
var original = session.GetPlate(0);
var engine = "JobOverlap-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "invalid layout", () => new TwoSheetStub(overlap: true));
var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine);
Assert.Contains("invalid", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Nothing committed", text);
Assert.Single(session.AllPlates());
Assert.Same(original, session.GetPlate(0));
Assert.Empty(original.Parts);
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
}
[Fact]
public void RestrictedDrawingRejectsPluginRotationWithoutMutationAfterReload()
{
var session = Session();
var drawing = session.Nest.Drawings.Single(d => d.Name == "A");
drawing.Constraints = new NestConstraints
{
StepAngle = OpenNest.Math.Angle.TwoPI,
StartAngle = 0,
EndAngle = 0,
};
session.GetPlate(0).Parts.Add(new Part(session.Nest.Drawings.Single(d => d.Name == "B")));
var path = Path.Combine(directory, "rotation.nest");
Assert.True(new NestWriter(session.Nest).Write(path));
session = new NestSession { Nest = new NestReader(path).Read() };
Assert.Equal(OpenNest.Math.Angle.TwoPI, session.Nest.Drawings.Single(d => d.Name == "A").Constraints.StepAngle);
session.Plates.Add(new Plate(10, 10) { PartSpacing = 0.25, Quantity = 1 });
var engine = "JobForbiddenRotation-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "rotated proposal", () => new RotatedStub());
var plateTool = new NestingTools(session).AutoNestPlate(1, "A", "1", engine);
Assert.Contains("Violation", plateTool);
Assert.Contains("rotation", plateTool, StringComparison.OrdinalIgnoreCase);
Assert.Empty(session.GetPlate(1).Parts);
var text = Call(new NestingTools(session), 1, "A", "1", engine);
Assert.True(text.Contains("rotation", StringComparison.OrdinalIgnoreCase), text);
Assert.Contains("Nothing committed", text);
Assert.Equal(2, session.AllPlates().Count);
Assert.Empty(session.GetPlate(1).Parts);
Assert.Single(session.GetPlate(0).Parts);
Assert.Equal(0, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
}
[Fact]
public void EquivalentDeserializedCuttingSettingsReuseExistingSheets()
{
var session = Session();
var first = session.GetPlate(0);
first.Size = new Size(3, 3);
first.CuttingParameters = new CuttingParameters { MachineName = "Same" };
var second = new Plate(3, 3)
{
Quantity = 1,
PartSpacing = 0.25,
CuttingParameters = new CuttingParameters { MachineName = "Same" }
};
session.Plates.Add(second);
Assert.NotSame(first.CuttingParameters, second.CuttingParameters);
var text = Call(new NestingTools(session), 0, "A", "2", "Rectangles", noNew: true);
Assert.Contains("Whole job complete", text);
Assert.Equal(2, session.AllPlates().Count);
Assert.Single(first.Parts);
Assert.Single(second.Parts);
}
[Fact]
public void DifferentCuttingSettingsAreNotReusedAsStock()
{
var session = Session();
var first = session.GetPlate(0);
first.Size = new Size(3, 3);
first.CuttingParameters = new CuttingParameters { MachineName = "First" };
var second = new Plate(3, 3)
{
Quantity = 1,
PartSpacing = 0.25,
CuttingParameters = new CuttingParameters { MachineName = "Other" }
};
session.Plates.Add(second);
var text = Call(new NestingTools(session), 0, "A", "2", "Rectangles", noNew: true);
Assert.Contains("incomplete", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Nothing committed", text);
Assert.Empty(first.Parts);
Assert.Empty(second.Parts);
Assert.Equal(2, session.AllPlates().Count);
}
[Fact]
public void SolverNotSupportedErrorNamesActualCause()
{
var session = Session();
var engine = "JobUnsupported-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "unsupported solver", () => new UnsupportedStub());
var text = Call(new NestingTools(session), 0, "A", "1", engine);
Assert.Contains("unsupported fixture", text);
Assert.DoesNotContain("Use autonest_plate", text);
Assert.Contains("Nothing committed", text);
Assert.Empty(session.GetPlate(0).Parts);
}
[Fact]
public void CancellationAfterPluginIgnoresTokenDoesNotCommit()
{
var session = Session();
var required = session.AllDrawings().Select(d => d.Quantity.Required).ToArray();
using var cts = new CancellationTokenSource();
var engine = "JobCancel-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "cancels after solving", () => new CancelStub(cts));
Assert.ThrowsAny<OperationCanceledException>(() =>
Call(new NestingTools(session), 0, "A,B", "2,2", engine, token: cts.Token));
Assert.Single(session.AllPlates());
Assert.Empty(session.GetPlate(0).Parts);
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
Assert.Equal(required, session.AllDrawings().Select(d => d.Quantity.Required));
}
private sealed class CancelStub(CancellationTokenSource source) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var result = new TwoSheetStub().Solve(job, progress, token);
source.Cancel();
return result;
}
}
private sealed class RotatedStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var stock = Assert.Single(job.Plates);
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, stock,
[new NestJobPlacement(job.Parts[0].Id, 0, 4, 4, System.Math.PI / 2)])], [], []);
}
}
private sealed class UnsupportedStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => throw new NotSupportedException("unsupported fixture");
}
private sealed class TwoSheetStub(bool overlap = false) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var stock = Assert.Single(job.Plates);
var sheets = new List<NestJobPlateResult>();
for (var i = 0; i < System.Math.Min(2, stock.Quantity ?? 2); i++)
sheets.Add(new NestJobPlateResult(i, stock,
[new NestJobPlacement(job.Parts[0].Id, i, 1, 1, 0),
new NestJobPlacement(job.Parts[1].Id, i, overlap ? 1 : 5, 1, 0)]));
return new NestJobResult(sheets.Count == 2 ? NestJobStatus.Complete : NestJobStatus.Incomplete,
sheets.Count == 2 ? NestJobStopReason.Completed : NestJobStopReason.StockExhausted,
sheets, [], []);
}
}
}
@@ -0,0 +1,32 @@
using OpenNest.Engine.Jobs;
using OpenNest.IO;
namespace OpenNest.IO.Tests;
public class StockCostPersistenceTests
{
[Theory]
[InlineData(0)]
[InlineData(12.5)]
public void ExistingOptionsRoundTripWithoutChangingMetadata(double cost)
{
var nest = new Nest("priced options")
{
Customer = "customer",
Notes = "notes",
Units = Units.Inches,
SalvageRate = 0.25,
PlateOptions = [new PlateOption { Width = 48, Length = 96, Cost = cost }],
};
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
var reopened = new NestReader(stream).Read();
Assert.Equal(cost, Assert.Single(reopened.PlateOptions).Cost);
Assert.Equal(nest.Customer, reopened.Customer);
Assert.Equal(nest.Notes, reopened.Notes);
Assert.Equal(nest.SalvageRate, reopened.SalvageRate);
var stock = Assert.Single(NestStockBuilder.FromTemplate(new Plate(48, 96), reopened.PlateOptions));
Assert.Equal(cost == 0 ? (double?)null : cost, stock.Cost);
}
}
+35
View File
@@ -17,6 +17,9 @@ namespace OpenNest.Mcp
public Plate GetPlate(int index) public Plate GetPlate(int index)
{ {
if (index < 0)
return null;
if (Nest != null && index < Nest.Plates.Count) if (Nest != null && index < Nest.Plates.Count)
return Nest.Plates[index]; return Nest.Plates[index];
@@ -27,6 +30,38 @@ namespace OpenNest.Mcp
return null; return null;
} }
/// <summary>Remove only a genuinely unused sheet; loaded sheets precede appended sheets.</summary>
public bool TryDeleteEmptyPlate(int index, out string reason)
{
var plate = GetPlate(index);
if (plate == null)
{
reason = $"plate {index} not found";
return false;
}
if (plate.Parts.Count != 0)
{
reason = $"plate {index} is occupied";
return false;
}
if (plate.CutOffs.Count != 0)
{
reason = $"plate {index} has cutoff definitions";
return false;
}
var loadedCount = Nest?.Plates.Count ?? 0;
if (index < loadedCount)
Nest.Plates.RemoveAt(index);
else
Plates.RemoveAt(index - loadedCount);
reason = null;
return true;
}
public Drawing GetDrawing(string name) public Drawing GetDrawing(string name)
{ {
if (Nest != null) if (Nest != null)
+12
View File
@@ -0,0 +1,12 @@
namespace OpenNest.Mcp;
/// <summary>Request-local physical inventory for one whole-job solve.</summary>
public sealed class SheetStockInput
{
/// <summary>Positive per-sheet cost in common units; omit on every row for area scoring.</summary>
public double? Cost { get; set; }
public double Width { get; set; }
public double Length { get; set; }
public int Quantity { get; set; }
}
+18 -2
View File
@@ -69,8 +69,24 @@ namespace OpenNest.Mcp.Tools
[Description("Name for the nest (optional)")] string name = null [Description("Name for the nest (optional)")] string name = null
) )
{ {
var nest = new Nest(); var source = _session.Nest;
nest.Name = name ?? Path.GetFileNameWithoutExtension(path); var nest = new Nest
{
Name = name ?? (string.IsNullOrWhiteSpace(source?.Name) ? Path.GetFileNameWithoutExtension(path) : source.Name),
Customer = source?.Customer ?? "",
Notes = source?.Notes ?? "",
AssistGas = source?.AssistGas ?? "",
Status = source?.Status ?? NestStatus.Quote,
MadeBy = source?.MadeBy ?? "",
Thickness = source?.Thickness ?? 0,
Material = source?.Material ?? new Material(),
Units = source?.Units ?? default,
DateCreated = source?.DateCreated ?? default,
DateLastModified = source?.DateLastModified ?? default,
PlateDefaults = source?.PlateDefaults ?? new Nest.PlateSettings(),
PlateOptions = source?.PlateOptions ?? new(),
SalvageRate = source?.SalvageRate ?? 0.5,
};
foreach (var drawing in _session.AllDrawings()) foreach (var drawing in _session.AllDrawings())
nest.Drawings.Add(drawing); nest.Drawings.Add(drawing);
+247 -9
View File
@@ -10,6 +10,7 @@ using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement; using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Mcp.Tools namespace OpenNest.Mcp.Tools
{ {
@@ -257,10 +258,7 @@ namespace OpenNest.Mcp.Tools
sb.AppendLine($" Total parts: {countAfter}"); sb.AppendLine($" Total parts: {countAfter}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}"); sb.AppendLine($" Utilization: {plate.Utilization():P1}");
// Breakdown by drawing AppendMixSummary(sb, items, parts);
var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name);
foreach (var group in groups)
sb.AppendLine($" {group.Key}: {group.Count()}");
return sb.ToString(); return sb.ToString();
} }
@@ -333,11 +331,235 @@ namespace OpenNest.Mcp.Tools
sb.AppendLine($" Parts placed: {totalPlaced}"); sb.AppendLine($" Parts placed: {totalPlaced}");
sb.AppendLine($" Total parts: {plate.Parts.Count}"); sb.AppendLine($" Total parts: {plate.Parts.Count}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}"); sb.AppendLine($" Utilization: {plate.Utilization():P1}");
foreach (var group in plate.Parts.GroupBy(p => p.BaseDrawing.Name)) AppendMixSummary(sb, parsed.items, proposed == null ? Enumerable.Empty<Part>() : proposed.Parts);
sb.AppendLine($" {group.Key}: {group.Count()}");
return sb.ToString(); return sb.ToString();
} }
[McpServerTool(Name = "autonest_job")]
[Description("Complete requested drawing quantities across physical sheets. The selected empty plate supplies common settings; optional sheets offer finite sizes and quantities. An incomplete or invalid proposal changes nothing.")]
public string AutoNestJob(
[Description("Index of an empty plate to use as the stock template")] int plateIndex,
[Description("Comma-separated drawing names")] string drawingNames,
[Description("Comma-separated total requested quantities for the entire session")] string quantities,
[Description(JobEngines)] string engine = null,
[Description("Use only already-created empty plates with matching dimensions and spacing; do not add sheets")] bool no_new_plates = false,
CancellationToken cancellationToken = default,
[Description("Optional finite physical sheet stock: width, length, available quantity and optional positive per-sheet cost in common units. Specify costs for every row or omit all. Incompatible with no_new_plates.")] SheetStockInput[] sheets = null
)
{
var template = _session.GetPlate(plateIndex);
if (template == null)
return $"Error: plate {plateIndex} not found";
if (template.Parts.Count != 0 || template.CutOffs.Count != 0)
return "Error: stock template must be empty and have no cutoff definitions. Nothing committed.";
if (sheets != null && no_new_plates)
return "Error: sheets cannot be combined with no_new_plates. Nothing committed.";
var offeredStock = BuildSheetStock(template, sheets);
if (offeredStock.error != null)
return offeredStock.error;
if (string.IsNullOrWhiteSpace(drawingNames) || string.IsNullOrWhiteSpace(quantities))
return "Error: drawingNames and quantities are required";
var parsed = ParseItems(drawingNames, quantities);
if (parsed.error != null)
return parsed.error;
if (parsed.items.Any(item => item.Quantity <= 0))
return "Error: whole-job quantities must be positive";
var existing = _session.AllPlates();
if (existing.Any(p => p.Parts.Count > 0 && p.Quantity <= 0))
return "Error: an occupied plate has a nonpositive quantity. Nothing committed.";
var remaining = new List<NestItem>(parsed.items.Count);
var counts = new Dictionary<Drawing, long>(ReferenceEqualityComparer.Instance);
foreach (var item in parsed.items)
{
var nested = existing.Sum(p => (long)p.Parts.Count(part => ReferenceEquals(part.BaseDrawing, item.Drawing)) * p.Quantity);
if (nested > item.Quantity)
return $"Error: drawing '{item.Drawing.Name}' already exceeds requested quantity ({nested} > {item.Quantity}). Nothing committed.";
counts[item.Drawing] = nested;
if (nested < item.Quantity)
remaining.Add(new NestItem
{
Drawing = item.Drawing,
Quantity = (int)(item.Quantity - nested),
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
});
}
if (remaining.Count == 0)
return "Job already complete: all requested quantities are present. Nothing committed.";
var available = new List<Plate> { template };
available.AddRange(existing.Where(p => !ReferenceEquals(p, template)
&& p.Parts.Count == 0 && p.CutOffs.Count == 0 && SameStock(p, template)));
var demand = remaining.Sum(item => (long)item.Quantity);
if (demand > int.MaxValue)
return "Error: total remaining demand exceeds the supported sheet limit. Nothing committed.";
var limit = no_new_plates ? System.Math.Min(available.Count, (int)demand)
: sheets != null ? (int)System.Math.Min(offeredStock.total, demand) : (int)demand;
var engineName = string.IsNullOrWhiteSpace(engine) ? _session.DefaultEngineName : engine.Trim();
if (NestingEngineRegistry.ResolveName(engineName) == null)
return $"Error: unknown whole-job engine '{engineName}'. Nothing committed. Available: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}";
NestPipelineResult result;
try
{
result = NestPipeline.Run(new NestPipelineRequest(engineName, remaining,
sheets == null ? NestStockBuilder.FromTemplate(template, null, no_new_plates ? available.Count : null) : offeredStock.stock,
new NestJobOptions(maxPlates: limit)), token: cancellationToken);
}
catch (NotSupportedException ex)
{
return $"Error: whole-job request unsupported: {ex.Message}. Nothing committed.";
}
cancellationToken.ThrowIfCancellationRequested();
if (!result.CanKeep || !result.IsValid)
return "Error: invalid whole-job proposal. Nothing committed. " + string.Join("; ", result.Violations);
var placed = new Dictionary<Drawing, long>(ReferenceEqualityComparer.Instance);
foreach (var sheet in result.Plates)
foreach (var part in sheet.Parts)
placed[part.BaseDrawing] = placed.GetValueOrDefault(part.BaseDrawing) + 1;
if (remaining.Any(item => placed.GetValueOrDefault(item.Drawing) != item.Quantity))
{
var summary = string.Join(", ", remaining.Select(item =>
$"{item.Drawing.Name}: {placed.GetValueOrDefault(item.Drawing)}/{item.Quantity} newly placed"));
return $"Error: incomplete whole-job proposal ({result.StopReason}); {summary}. Nothing committed. A solver's no-placement result does not prove geometric impossibility.";
}
if (result.Plates.Count > limit)
return "Error: proposal exceeds the allowed sheet count. Nothing committed.";
var costSummary = NestCostSummary.FromAccepted(result);
// Build the entire target map before attaching a single part. Indexes in a mixed
// stock proposal are solver ordinals, not indexes into the session's empty plates.
var usedTargets = new HashSet<Plate>(ReferenceEqualityComparer.Instance);
var targets = new List<(Plate plate, bool created, string stockId)>();
foreach (var proposed in result.Plates)
{
var target = new[] { template }.Concat(existing.Where(p => !ReferenceEquals(p, template)))
.FirstOrDefault(p => !usedTargets.Contains(p)
&& p.Parts.Count == 0 && p.CutOffs.Count == 0
&& SameStock(p, template, proposed.Stock.Size));
var created = target == null;
target ??= new Plate(proposed.Stock.Size)
{
GrainAngle = template.GrainAngle,
CuttingParameters = template.CuttingParameters,
};
usedTargets.Add(target);
targets.Add((target, created, proposed.Stock.Id));
}
cancellationToken.ThrowIfCancellationRequested();
for (var i = 0; i < result.Plates.Count; i++)
{
var proposed = result.Plates[i];
var (target, created, _) = targets[i];
target.Size = proposed.Stock.Size;
target.PartSpacing = proposed.Stock.PartSpacing;
target.EdgeSpacing = proposed.Stock.EdgeSpacing;
target.Quadrant = proposed.Stock.Quadrant;
target.Quantity = 1;
if (created)
_session.Plates.Add(target);
target.Parts.AddRange(proposed.Parts);
}
foreach (var item in parsed.items)
item.Drawing.Quantity.Required = item.Quantity;
if (sheets != null)
{
_session.Nest ??= new Nest();
_session.Nest.PlateOptions = offeredStock.stock.Select(s => new PlateOption
{ Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
}
var lines = new StringBuilder();
lines.AppendLine($"Cost basis={costSummary.Basis}, gross total={costSummary.GrossTotal}, estimated salvage credit={costSummary.SalvageCredit}, net score={costSummary.NetScore}");
foreach (var usage in costSummary.Stock)
lines.AppendLine($" {usage.StockId}: used={usage.Used}, supplied cost={usage.Cost?.ToString() ?? "unspecified"}, gross subtotal={usage.GrossSubtotal}");
lines.AppendLine($"Whole job complete: {result.Plates.Count} sheet(s) placed, {targets.Count(t => t.created)} new sheet(s) created.");
if (sheets != null)
{
foreach (var stock in offeredStock.stock)
{
var used = targets.Count(t => t.stockId == stock.Id);
lines.AppendLine($" {stock.Id} ({stock.Size.Width}x{stock.Size.Length}): used={used}, remaining={stock.Quantity - used}, reused={targets.Count(t => !t.created && t.stockId == stock.Id)}, created={targets.Count(t => t.created && t.stockId == stock.Id)}");
}
foreach (var (target, _, stockId) in targets)
lines.AppendLine($" plate {_session.AllPlates().FindIndex(p => ReferenceEquals(p, target))} -> {stockId}");
}
foreach (var item in parsed.items)
lines.AppendLine($" {item.Drawing.Name}: requested={item.Quantity}, already={counts[item.Drawing]}, newly placed={placed.GetValueOrDefault(item.Drawing)}, remaining=0");
return lines.ToString();
}
private static (List<NestPlateStock> stock, long total, string error) BuildSheetStock(Plate template, SheetStockInput[] sheets)
{
if (sheets == null)
return (null, 0, null);
if (sheets.Length == 0)
return (null, 0, "Error: sheets must contain at least one stock size. Nothing committed.");
var stock = new List<NestPlateStock>(sheets.Length);
var priced = sheets.Any(s => s?.Cost != null);
long total = 0;
for (var i = 0; i < sheets.Length; i++)
{
var row = sheets[i];
if (row == null || !double.IsFinite(row.Width) || !double.IsFinite(row.Length)
|| !double.IsFinite(row.Width * row.Length)
|| row.Width <= 0 || row.Length <= 0 || row.Quantity <= 0
|| row.Length <= template.EdgeSpacing.Left + template.EdgeSpacing.Right
|| row.Width <= template.EdgeSpacing.Top + template.EdgeSpacing.Bottom)
return (null, 0, $"Error: sheets[{i}] requires finite positive usable dimensions and a positive physical quantity. Nothing committed.");
if (row.Cost is double cost && (!double.IsFinite(cost) || cost <= 0)
|| priced && row.Cost == null)
return (null, 0, $"Error: sheets[{i}] requires a finite positive cost; specify costs on every row or omit all. Nothing committed.");
var duplicate = stock.FindIndex(s => s.Size.Width == row.Width && s.Size.Length == row.Length);
if (duplicate >= 0)
return (null, 0, $"Error: sheets[{i}] duplicates sheets[{duplicate}] size. Nothing committed.");
total += row.Quantity;
if (total > int.MaxValue)
return (null, 0, "Error: total sheet inventory exceeds the supported sheet limit. Nothing committed.");
stock.Add(new NestPlateStock($"stock-{i}", new Size(row.Width, row.Length), row.Quantity,
template.PartSpacing, template.EdgeSpacing, template.Quadrant, row.Cost));
}
return (stock, total, null);
}
private static bool SameStock(Plate a, Plate b) => SameStock(a, b, b.Size);
private static bool SameStock(Plate a, Plate b, Size size) =>
a.Size.Width == size.Width && a.Size.Length == size.Length
&& a.PartSpacing == b.PartSpacing && a.EdgeSpacing.Equals(b.EdgeSpacing)
&& a.Quadrant == b.Quadrant && a.GrainAngle == b.GrainAngle
&& (a.CuttingParameters == null && b.CuttingParameters == null
|| a.CuttingParameters != null && b.CuttingParameters != null
&& string.Equals(CuttingParametersSerializer.Serialize(a.CuttingParameters),
CuttingParametersSerializer.Serialize(b.CuttingParameters), StringComparison.Ordinal));
private static void AppendMixSummary(StringBuilder sb, IReadOnlyList<NestItem> items, IEnumerable<Part> newlyPlaced)
{
var counts = new Dictionary<Drawing, int>(ReferenceEqualityComparer.Instance);
foreach (var part in newlyPlaced)
counts[part.BaseDrawing] = counts.TryGetValue(part.BaseDrawing, out var n) ? n + 1 : 1;
var placed = new int[items.Count];
sb.AppendLine(" Requested mix (new placements only):");
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
placed[i] = counts.GetValueOrDefault(item.Drawing);
var remaining = item.Quantity - placed[i];
sb.AppendLine($" [{i}] {item.Drawing.Name}: requested={item.Quantity}, placed={placed[i]}, remaining={remaining}");
}
if (placed.All(count => count == 0))
sb.AppendLine(" Mix: zero progress for all requested drawings.");
else if (items.Where((item, i) => item.Quantity > placed[i]).Any())
sb.AppendLine(" Warning: remaining demand in this call; place the outstanding quantities on another sheet before treating the job as complete.");
else
sb.AppendLine(" Mix: requested quantities placed in this call.");
}
/// <summary> /// <summary>
/// Resolves the requested fill strategy; an omitted name means Fill. Returns null when the /// Resolves the requested fill strategy; an omitted name means Fill. Returns null when the
/// name is not a single-plate placement strategy. /// name is not a single-plate placement strategy.
@@ -376,6 +598,8 @@ namespace OpenNest.Mcp.Tools
{ {
if (!int.TryParse(qtyStrings[i], out qtys[i])) if (!int.TryParse(qtyStrings[i], out qtys[i]))
return (null, $"Error: '{qtyStrings[i]}' is not a valid quantity"); return (null, $"Error: '{qtyStrings[i]}' is not a valid quantity");
if (qtys[i] < 0)
return (null, $"Error: negative quantity '{qtys[i]}' is not supported");
} }
if (names.Length != qtys.Length) if (names.Length != qtys.Length)
@@ -385,14 +609,28 @@ namespace OpenNest.Mcp.Tools
); );
var items = new List<NestItem>(); var items = new List<NestItem>();
var seen = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
for (var i = 0; i < names.Length; i++) for (var i = 0; i < names.Length; i++)
{ {
var drawing = _session.GetDrawing(names[i]); var matches = _session.AllDrawings().Where(d => d.Name == names[i]).ToArray();
if (drawing == null) if (matches.Length == 0)
return (null, $"Error: drawing '{names[i]}' not found"); return (null, $"Error: drawing '{names[i]}' not found");
if (matches.Length > 1)
return (null, $"Error: ambiguous drawing name '{names[i]}' matches multiple drawings");
var drawing = matches[0];
if (!seen.Add(drawing))
return (null, $"Error: duplicate drawing '{names[i]}' in request");
items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] }); items.Add(new NestItem
{
Drawing = drawing,
Quantity = qtys[i],
Priority = drawing.Priority,
StepAngle = drawing.Constraints?.StepAngle ?? 0,
RotationStart = drawing.Constraints?.StartAngle ?? 0,
RotationEnd = drawing.Constraints?.EndAngle ?? 0,
});
} }
return (items, null); return (items, null);
+16
View File
@@ -58,6 +58,22 @@ namespace OpenNest.Mcp.Tools
return sb.ToString(); return sb.ToString();
} }
[McpServerTool(Name = "delete_plate")]
[Description("Delete an empty plate without parts or cutoff definitions. Later plate indices shift down by one.")]
public string DeletePlate([Description("Index of the empty plate to delete")] int plateIndex)
{
var oldCount = _session.AllPlates().Count;
if (!_session.TryDeleteEmptyPlate(plateIndex, out var reason))
return $"Error: {reason}; no plates changed";
var sb = new StringBuilder();
sb.AppendLine($"Deleted plate {plateIndex}. Plates: {oldCount} -> {oldCount - 1}.");
for (var oldIndex = plateIndex + 1; oldIndex < oldCount; oldIndex++)
sb.AppendLine($" {oldIndex} -> {oldIndex - 1}");
sb.AppendLine("Use get_plate_info with the new indices before further operations.");
return sb.ToString();
}
[McpServerTool(Name = "clear_plate")] [McpServerTool(Name = "clear_plate")]
[Description("Remove all parts from a plate. Returns how many parts were removed.")] [Description("Remove all parts from a plate. Returns how many parts were removed.")]
public string ClearPlate([Description("Index of the plate to clear")] int plateIndex) public string ClearPlate([Description("Index of the plate to clear")] int plateIndex)
+17 -85
View File
@@ -25,7 +25,6 @@ public static class NestPdfWriter
private const double PortraitWidth = 540; private const double PortraitWidth = 540;
private const double LandscapeWidth = 720; private const double LandscapeWidth = 720;
private const double OverviewHeight = 320; private const double OverviewHeight = 320;
private const double DetailHeight = 400;
private static readonly XColor Fill = XColor.FromArgb(222, 222, 222); private static readonly XColor Fill = XColor.FromArgb(222, 222, 222);
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture; private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
@@ -54,7 +53,7 @@ public static class NestPdfWriter
// Plan every diagram before layout; nothing below touches the destination until Save. // Plan every diagram before layout; nothing below touches the destination until Save.
var plans = snapshot.Plates.Select(plate => var plans = snapshot.Plates.Select(plate =>
NestReportDiagram.Plan(plate, LandscapeWidth, OverviewHeight, DetailHeight)).ToList(); NestReportDiagram.Plan(plate, LandscapeWidth, OverviewHeight)).ToList();
var document = new Document(); var document = new Document();
document.Info.Title = snapshot.Name; document.Info.Title = snapshot.Name;
@@ -131,7 +130,7 @@ public static class NestPdfWriter
else else
{ {
var plates = AddTable(section, 60, 200, 80, 100, 100); var plates = AddTable(section, 60, 200, 80, 100, 100);
AddHeader(plates, "Plate", "Stock size", "Copies", "Parts/sheet", "Utilization"); AddHeader(plates, "Plate", "Stock size", "Quantity", "Parts per plate", "Utilization");
foreach (var plate in snapshot.Plates) foreach (var plate in snapshot.Plates)
AddRow(plates, $"Plate {plate.Number}", false, plate.Number.ToString(Invariant), SheetSize(snapshot, plate), AddRow(plates, $"Plate {plate.Number}", false, plate.Number.ToString(Invariant), SheetSize(snapshot, plate),
plate.Copies.ToString(Invariant), plate.Parts.Length.ToString(Invariant), Percent(plate.Utilization)); plate.Copies.ToString(Invariant), plate.Parts.Length.ToString(Invariant), Percent(plate.Utilization));
@@ -158,7 +157,7 @@ public static class NestPdfWriter
} }
private static void AddPlate(Document document, NestReportSnapshot snapshot, ReportPlate plate, private static void AddPlate(Document document, NestReportSnapshot snapshot, ReportPlate plate,
PlateDiagramPlan plan, List<(Table, Action<XGraphics, XRect>)> areas) DiagramView plan, List<(Table, Action<XGraphics, XRect>)> areas)
{ {
var heading = $"Plate {plate.Number} of {snapshot.Plates.Length}"; var heading = $"Plate {plate.Number} of {snapshot.Plates.Length}";
var header = new List<string> { $"Nest report: {snapshot.Name}", heading, SheetSize(snapshot, plate) }; var header = new List<string> { $"Nest report: {snapshot.Name}", heading, SheetSize(snapshot, plate) };
@@ -172,30 +171,23 @@ public static class NestPdfWriter
title.Format.Font.Size = 13; title.Format.Font.Size = 13;
title.Format.Font.Bold = true; title.Format.Font.Bold = true;
var info = AddTable(section, 80, 280, 90, 270); var info = AddTable(section, 100, 260, 90, 270);
info.Borders.Visible = false; info.Borders.Visible = false;
AddInfoRow(info, "Nest:", snapshot.Name, "Sheet size:", SheetSize(snapshot, plate)); AddInfoRow(info, "Nest:", snapshot.Name, "Sheet size:", SheetSize(snapshot, plate));
AddInfoRow(info, "Material:", Material(snapshot), "Copies:", plate.Copies.ToString(Invariant)); AddInfoRow(info, "Material:", Material(snapshot), "Quantity:", plate.Copies.ToString(Invariant));
AddInfoRow(info, "Thickness:", Thickness(snapshot), "Part spacing:", AddInfoRow(info, "Thickness:", Thickness(snapshot), "Part spacing:",
$"{Number(plate.PartSpacing)} {snapshot.Units}"); $"{Number(plate.PartSpacing)} {snapshot.Units}");
AddInfoRow(info, "Parts/sheet:", plate.Parts.Length.ToString(Invariant), "Utilization:", AddInfoRow(info, "Parts per plate:", plate.Parts.Length.ToString(Invariant), "Utilization:",
Percent(plate.Utilization) + " (net part area / full sheet)"); Percent(plate.Utilization) + " (net part area / full sheet)");
var overview = AddReservedArea(section, null, plan.Overview.Size.Height); var overview = AddReservedArea(section, plan.Size.Height);
areas.Add((overview, (gfx, _) => DrawView(gfx, plate, plan.Overview))); areas.Add((overview, (gfx, _) => DrawView(gfx, plate, plan)));
var legend = section.AddParagraph("Filled outlines are closed parts; open material paths such as tab gaps are shown without fill. Dashed lines are scrap cutoffs."); var legend = section.AddParagraph("Filled outlines are closed parts; open material paths such as tab gaps are shown without fill. Dashed lines are scrap cutoffs.");
legend.Format.Font.Size = 7.5; legend.Format.Font.Size = 7.5;
legend.Format.SpaceBefore = Unit.FromPoint(4); legend.Format.SpaceBefore = Unit.FromPoint(4);
if (plan.Overview.Grid is { } grid) var labelsNote = section.AddParagraph("Part IDs are shown where space permits. A dotted outline groups like parts under one ID.");
{ labelsNote.Format.Font.Size = 7.5;
var names = string.Join(", ", plan.Details.Select(detail => detail.Cell));
var fallback = section.AddParagraph();
fallback.Format.Font.Size = 7.5;
AddLines(fallback, ReportText.Wrap(
$"Some part IDs are too small to label at this scale. Grid rows are lettered from the top and columns numbered from the left; cells outlined dash-dot have detail views on the following pages: {names}.",
new XFont(ReportFonts.Family, 7.5), LandscapeWidth));
}
var approval = section.AddParagraph("This report is not a geometry or CNC approval."); var approval = section.AddParagraph("This report is not a geometry or CNC approval.");
approval.Format.Font.Size = 7.5; approval.Format.Font.Size = 7.5;
approval.Format.SpaceAfter = Unit.FromPoint(4); approval.Format.SpaceAfter = Unit.FromPoint(4);
@@ -204,36 +196,23 @@ public static class NestPdfWriter
var perSheet = plate.Parts.GroupBy(part => part.ReportId, StringComparer.Ordinal) var perSheet = plate.Parts.GroupBy(part => part.ReportId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => (long)group.Count(), StringComparer.Ordinal); .ToDictionary(group => group.Key, group => (long)group.Count(), StringComparer.Ordinal);
var parts = AddTable(section, 60, 400, 110, 150); var parts = AddTable(section, 60, 400, 110, 150);
AddHeader(parts, "ID", "Drawing", "Qty/sheet", $"Total ({plate.Copies} copies)"); AddHeader(parts, "ID", "Drawing", "Parts per plate", $"Total ({plate.Copies} plates)");
foreach (var drawing in snapshot.Drawings.Where(drawing => perSheet.ContainsKey(drawing.Id))) foreach (var drawing in snapshot.Drawings.Where(drawing => perSheet.ContainsKey(drawing.Id)))
{ {
var count = perSheet[drawing.Id]; var count = perSheet[drawing.Id];
AddRow(parts, $"Plate {plate.Number}, drawing {drawing.Id}", false, drawing.Id, names2[drawing.Id], AddRow(parts, $"Plate {plate.Number}, drawing {drawing.Id}", false, drawing.Id, names2[drawing.Id],
count.ToString(Invariant), checked(count * plate.Copies).ToString(Invariant)); count.ToString(Invariant), checked(count * plate.Copies).ToString(Invariant));
} }
foreach (var detail in plan.Details)
{
var bounds = detail.CellBounds!;
var caption = $"Plate {plate.Number} detail {detail.Cell}: X {Number(bounds.Left)} to {Number(bounds.Right)}, Y {Number(bounds.Bottom)} to {Number(bounds.Top)} {snapshot.Units}. Parts centered in the dash-dot cell are labeled.";
var table = AddReservedArea(section, caption, detail.Size.Height);
areas.Add((table, (gfx, _) => DrawView(gfx, plate, detail)));
}
} }
/// <summary>A fixed-height, never-split table row reserves a vector drawing area in MigraDoc's flow.</summary> /// <summary>A fixed-height, never-split table row reserves a vector drawing area in MigraDoc's flow.</summary>
private static Table AddReservedArea(Section section, string? caption, double height) private static Table AddReservedArea(Section section, double height)
{ {
var table = AddTable(section, LandscapeWidth); var table = AddTable(section, LandscapeWidth);
table.Borders.Visible = false; table.Borders.Visible = false;
table.LeftPadding = table.RightPadding = Unit.Zero; table.LeftPadding = table.RightPadding = Unit.Zero;
table.TopPadding = table.BottomPadding = Unit.Zero; table.TopPadding = table.BottomPadding = Unit.Zero;
table.Format.SpaceBefore = Unit.FromPoint(4); table.Format.SpaceBefore = Unit.FromPoint(4);
if (caption != null)
{
var row = AddRow(table, "Detail caption", true, caption);
row.KeepWith = 1;
}
var reserved = table.AddRow(); var reserved = table.AddRow();
reserved.HeightRule = RowHeightRule.Exactly; reserved.HeightRule = RowHeightRule.Exactly;
reserved.Height = Unit.FromPoint(height); reserved.Height = Unit.FromPoint(height);
@@ -276,14 +255,6 @@ public static class NestPdfWriter
private static void DrawView(XGraphics gfx, ReportPlate plate, DiagramView view) private static void DrawView(XGraphics gfx, ReportPlate plate, DiagramView view)
{ {
var fit = view.Fit; var fit = view.Fit;
// Detail views clip exactly at their frame line so cropped neighbours stop there.
var frame = new XRect(0.5, 0.5, view.Size.Width - 1, view.Size.Height - 1);
var state = gfx.Save();
if (view.CellBounds != null)
gfx.IntersectClip(frame);
// The map grid lies beneath the sheet and parts so it never hides an outline.
if (view.Grid is { } grid)
DrawGrid(gfx, grid, fit);
gfx.DrawRectangle(new XPen(XColors.Black, 1), fit.Rect(plate.Bounds)); gfx.DrawRectangle(new XPen(XColors.Black, 1), fit.Rect(plate.Bounds));
var outline = new XPen(XColors.Black, 0.6); var outline = new XPen(XColors.Black, 0.6);
foreach (var part in plate.Parts) foreach (var part in plate.Parts)
@@ -293,54 +264,15 @@ public static class NestPdfWriter
var cutoff = new XPen(XColors.Black, 0.75) { DashPattern = [9, 3] }; var cutoff = new XPen(XColors.Black, 0.75) { DashPattern = [9, 3] };
foreach (var geometry in plate.Cutoffs) foreach (var geometry in plate.Cutoffs)
StrokeContours(gfx, geometry, fit, cutoff); StrokeContours(gfx, geometry, fit, cutoff);
// Long dash-dot, so detail cells are never confused with dashed scrap cutoffs. var groupPen = new XPen(XColors.Black, 0.6) { DashPattern = [1, 3] };
var detail = new XPen(XColors.Black, 1.6) { DashPattern = [9, 3, 1, 3] }; foreach (var group in view.Groups)
if (view.Grid is { } overviewGrid) gfx.DrawRectangle(groupPen, group.Boundary);
{
foreach (var (row, column) in overviewGrid.DetailCells)
gfx.DrawRectangle(detail, fit.Rect(overviewGrid.Cell(row, column)));
}
if (view.CellBounds is { } cell)
gfx.DrawRectangle(detail, fit.Rect(cell));
var font = NestReportDiagram.LabelFont(); var font = NestReportDiagram.LabelFont();
foreach (var label in view.Labels) foreach (var label in view.Labels.Concat(view.Groups.Select(group => group.Label)))
{ {
gfx.DrawRectangle(XBrushes.White, label.Box); gfx.DrawRectangle(XBrushes.White, label.Box);
gfx.DrawString(label.Id, font, XBrushes.Black, label.Box, XStringFormats.Center); gfx.DrawString(label.Id, font, XBrushes.Black, label.Box, XStringFormats.Center);
} }
gfx.Restore(state);
// Frame the viewport so cropped neighbouring parts read as intentional.
if (view.CellBounds != null)
gfx.DrawRectangle(new XPen(XColors.Gray, 0.75), frame);
}
private static void DrawGrid(XGraphics gfx, DiagramGrid grid, Fit fit)
{
var line = new XPen(XColors.Gray, 0.3) { DashPattern = [3, 6] };
var area = fit.Rect(grid.Union);
for (var column = 0; column <= grid.Columns; column++)
{
var x = area.X + column * grid.CellLength * fit.Scale;
gfx.DrawLine(line, x, area.Y, x, area.Y + grid.Rows * grid.CellWidth * fit.Scale);
}
for (var row = 0; row <= grid.Rows; row++)
{
var y = area.Y + row * grid.CellWidth * fit.Scale;
gfx.DrawLine(line, area.X, y, area.X + grid.Columns * grid.CellLength * fit.Scale, y);
}
var font = NestReportDiagram.LabelFont();
for (var column = 0; column < grid.Columns; column++)
{
var x = area.X + (column + 0.5) * grid.CellLength * fit.Scale;
gfx.DrawString((column + 1).ToString(Invariant), font, XBrushes.Black,
new XRect(x - 20, area.Y - 11, 40, 10), XStringFormats.BottomCenter);
}
for (var row = 0; row < grid.Rows; row++)
{
var y = area.Y + (row + 0.5) * grid.CellWidth * fit.Scale;
gfx.DrawString(ReportText.RowName(row), font, XBrushes.Black,
new XRect(area.X - 16, y - 5, 14, 10), XStringFormats.CenterRight);
}
} }
private static string Thumbnail(ReportGeometry geometry) private static string Thumbnail(ReportGeometry geometry)
@@ -532,7 +464,7 @@ public static class NestPdfWriter
snapshot.Thickness > 0 ? $"{Number(snapshot.Thickness)} {snapshot.Units}" : "Not specified"; snapshot.Thickness > 0 ? $"{Number(snapshot.Thickness)} {snapshot.Units}" : "Not specified";
private static string SheetSize(NestReportSnapshot snapshot, ReportPlate plate) => private static string SheetSize(NestReportSnapshot snapshot, ReportPlate plate) =>
$"{Number(plate.Bounds.Length)} x {Number(plate.Bounds.Width)} {snapshot.Units}"; $"{Number(plate.Bounds.Width)} x {Number(plate.Bounds.Length)} {snapshot.Units}";
private static string Number(double value) => value.ToString("0.####", Invariant); private static string Number(double value) => value.ToString("0.####", Invariant);
+8 -2
View File
@@ -21,13 +21,19 @@ public static class NestReportBuilder
{ {
if (byReference.TryGetValue(drawing, out var existing)) if (byReference.TryGetValue(drawing, out var existing))
return existing; return existing;
var item = new DrawingCount(drawing, "R" + (ordered.Count + 1).ToString("D3", CultureInfo.InvariantCulture)); var item = new DrawingCount(drawing, (ordered.Count + 1).ToString(CultureInfo.InvariantCulture));
byReference.Add(drawing, item); byReference.Add(drawing, item);
ordered.Add(item); ordered.Add(item);
return item; return item;
} }
for (var index = 0; index < nest.Plates.Count; index++) // The editor can leave unused stock at the end of a job. Exclude those placeholders
// from report pages and totals without changing plate numbers or the live nest.
var plateCount = nest.Plates.Count;
while (plateCount > 0 && nest.Plates[plateCount - 1]?.Parts.Count == 0)
plateCount--;
for (var index = 0; index < plateCount; index++)
{ {
var number = index + 1; var number = index + 1;
var plate = nest.Plates[index]; var plate = nest.Plates[index];
+81 -118
View File
@@ -6,43 +6,20 @@ namespace OpenNest.Reporting;
/// <summary>A part ID label in canvas-local page coordinates (Y down).</summary> /// <summary>A part ID label in canvas-local page coordinates (Y down).</summary>
internal sealed record DiagramLabel(int Part, string Id, XRect Box); internal sealed record DiagramLabel(int Part, string Id, XRect Box);
/// <summary>Map grid over the overview; rows are lettered from the top, columns numbered from the left.</summary> /// <summary>A dotted group boundary and its shared ID, in canvas coordinates.</summary>
internal sealed record DiagramGrid(ReportBounds Union, double CellLength, double CellWidth, int Columns, int Rows, internal sealed record DiagramGroup(IReadOnlyList<int> Parts, XRect Boundary, DiagramLabel Label);
IReadOnlyList<(int Row, int Column)> DetailCells)
{
public string Name(int row, int column) => ReportText.RowName(row) + (column + 1).ToString(System.Globalization.CultureInfo.InvariantCulture);
public ReportBounds Cell(int row, int column)
{
var left = Union.Left + column * CellLength;
var top = Union.Top - row * CellWidth;
return new ReportBounds(left, top - CellWidth, left + CellLength, top);
}
}
/// <summary>One drawn view of a plate: the fitted overview or a zoomed grid-cell detail.</summary>
internal sealed record DiagramView(XSize Size, Fit Fit, IReadOnlyList<DiagramLabel> Labels, internal sealed record DiagramView(XSize Size, Fit Fit, IReadOnlyList<DiagramLabel> Labels,
DiagramGrid? Grid, string? Cell, ReportBounds? CellBounds); IReadOnlyList<DiagramGroup> Groups);
internal sealed record PlateDiagramPlan(DiagramView Overview, IReadOnlyList<DiagramView> Details); /// <summary>Plans best-effort labels on a single plate view; small repeated parts may share a label.</summary>
/// <summary>
/// Plans labels before any PDF output. IDs are never shrunk below <see cref="LabelFontSize"/> or
/// dropped: a label that cannot sit legibly inside its part's material at overview scale is placed
/// in a zoomed map-grid detail view instead, or the report fails with the part identified.
/// </summary>
internal static class NestReportDiagram internal static class NestReportDiagram
{ {
internal const double LabelFontSize = 7; internal const double LabelFontSize = 7;
internal const int MaxDetailViews = 24;
private const double LabelPadding = 1; private const double LabelPadding = 1;
// Minimum white space between a label and any edge, hole, cutoff or other label. // Minimum white space between a label and any edge, hole, cutoff or other label.
private const double Clearance = 1.5; private const double Clearance = 1.5;
private const double Inset = 5; private const double Inset = 5;
private const double GutterLeft = 16;
private const double GutterTop = 12;
private const double DetailMargin = 0.08;
private static readonly double[] Zooms = [1, 2, 4];
internal static XFont LabelFont() internal static XFont LabelFont()
{ {
@@ -58,100 +35,100 @@ internal static class NestReportDiagram
internal static double PolePrecision(ReportBounds bounds) => internal static double PolePrecision(ReportBounds bounds) =>
System.Math.Max(System.Math.Max(bounds.Length, bounds.Width) * 0.005, 1e-9); System.Math.Max(System.Math.Max(bounds.Length, bounds.Width) * 0.005, 1e-9);
internal static PlateDiagramPlan Plan(ReportPlate plate, double width, double overviewHeight, double detailHeight) internal static DiagramView Plan(ReportPlate plate, double width, double height)
{ {
var shapes = plate.Parts.Select(part => new Shape(part.Geometry)).ToList(); var shapes = plate.Parts.Select(part => new Shape(part.Geometry)).ToList();
var obstacles = shapes.Concat(plate.Cutoffs.Select(cutoff => new Shape(cutoff))).ToList(); var obstacles = shapes.Concat(plate.Cutoffs.Select(cutoff => new Shape(cutoff))).ToList();
var union = Union(plate, obstacles); var union = Union(plate, obstacles);
var font = LabelFont(); var font = LabelFont();
var all = Enumerable.Range(0, plate.Parts.Length).ToList(); var all = Enumerable.Range(0, plate.Parts.Length).ToList();
var canvas = new XSize(width, overviewHeight); var canvas = new XSize(width, height);
var overviewView = new XRect(0, 0, width, overviewHeight); var view = new XRect(0, 0, width, height);
var fit = Fit.Create(union, new XRect(Inset, Inset, width - 2 * Inset, height - 2 * Inset));
var labels = PlaceLabels(plate, shapes, obstacles, all, fit, view, font, out var failed);
var groups = PlaceGroups(plate, failed, labels, fit, view, font);
return new DiagramView(canvas, fit, labels, groups);
}
var fit = Fit.Create(union, new XRect(Inset, Inset, width - 2 * Inset, overviewHeight - 2 * Inset)); private static List<DiagramGroup> PlaceGroups(ReportPlate plate, List<int> failed,
var labels = PlaceLabels(plate, shapes, obstacles, all, fit, overviewView, font, out var failed); List<DiagramLabel> labels, Fit fit, XRect view, XFont font)
if (failed.Count == 0) {
return new PlateDiagramPlan(new DiagramView(canvas, fit, labels, null, null, null), []); var boxes = plate.Parts.Select(part => fit.Rect(part.Geometry.Bounds)).ToList();
var cutoffs = plate.Cutoffs.Select(cutoff => fit.Rect(cutoff.Bounds)).ToList();
// Reserve map-grid gutters, then re-place what still fits at overview scale. var groups = new List<DiagramGroup>();
fit = Fit.Create(union, new XRect(GutterLeft, GutterTop, width - GutterLeft - Inset, overviewHeight - GutterTop - Inset)); // A small screen-space gap also accommodates tightly packed parts with unset spacing.
labels = PlaceLabels(plate, shapes, obstacles, all, fit, overviewView, font, out failed); var gap = System.Math.Max(4, plate.PartSpacing * fit.Scale * 2);
var failedSet = failed.ToHashSet(); var remaining = failed.ToHashSet();
var required = failed.Max(index => RequiredScale(plate, index, font)); foreach (var seed in failed)
var inner = new XRect(Inset, Inset, width - 2 * Inset, detailHeight - 2 * Inset);
var legibleGrid = false;
foreach (var zoom in Zooms)
{ {
var scale = required * zoom; if (!remaining.Remove(seed))
// Cells evenly divide the sheet (no overrun); each is at most the size a detail view
// shows at the required scale, so the zoom only increases.
var columns = System.Math.Max(1, (int)System.Math.Ceiling(union.Length * scale * (1 + 2 * DetailMargin) / inner.Width));
var rows = System.Math.Max(1, (int)System.Math.Ceiling(union.Width * scale * (1 + 2 * DetailMargin) / inner.Height));
var cellLength = union.Length / columns;
var cellWidth = union.Width / rows;
var grid = new DiagramGrid(union, cellLength, cellWidth, columns, rows, []);
// Every grid line and gutter name must stay readable on the overview.
if (!GridIsLegible(grid, fit, font))
continue; continue;
legibleGrid = true; var id = plate.Parts[seed].ReportId;
(int Row, int Column) CellOf(int index) => CellIndex(plate, index, union, columns, rows); var members = new List<int> { seed };
for (var cursor = 0; cursor < members.Count; cursor++)
var cells = failed.Select(CellOf).Distinct().OrderBy(cell => cell.Row).ThenBy(cell => cell.Column).ToList();
if (cells.Count > MaxDetailViews)
throw new NotSupportedException($"Plate {plate.Number}: legible part ID labels would need {cells.Count} detail views; this report supports at most {MaxDetailViews} per plate.");
grid = grid with { DetailCells = cells };
var details = new List<DiagramView>();
var unplaced = new List<int>();
foreach (var (row, column) in cells)
{ {
var cell = grid.Cell(row, column); var nearby = boxes[members[cursor]];
var marginX = cellLength * DetailMargin; nearby.Inflate(gap, gap);
var marginY = cellWidth * DetailMargin; foreach (var candidate in failed)
var view = new ReportBounds(cell.Left - marginX, cell.Bottom - marginY, cell.Right + marginX, cell.Top + marginY); {
var detailFit = Fit.Create(view, inner); if (remaining.Contains(candidate) && plate.Parts[candidate].ReportId == id
// Parts missing from the overview claim label space first. && nearby.IntersectsWith(boxes[candidate]))
var members = all.Where(index => CellOf(index) == (row, column)) {
.OrderBy(index => failedSet.Contains(index) ? 0 : 1).ToList(); remaining.Remove(candidate);
var detailLabels = PlaceLabels(plate, shapes, obstacles, members, detailFit, members.Add(candidate);
new XRect(0, 0, width, detailHeight), font, out var missed); }
unplaced.AddRange(missed.Where(failedSet.Contains)); }
details.Add(new DiagramView(new XSize(width, detailHeight), detailFit, detailLabels, null, }
grid.Name(row, column), cell)); if (members.Count < 2)
continue;
var boundary = boxes[seed];
foreach (var member in members.Skip(1))
boundary.Union(boxes[member]);
boundary.Inflate(1, 1);
var memberSet = members.ToHashSet();
// A rectangular group must not imply that unrelated or ungrouped parts belong to it.
if (!view.Contains(boundary)
|| boxes.Where((_, index) => !memberSet.Contains(index)).Any(box => box.IntersectsWith(boundary))
|| cutoffs.Any(box => box.IntersectsWith(boundary))
|| labels.Any(label => label.Box.IntersectsWith(boundary))
|| groups.Any(group => group.Boundary.IntersectsWith(boundary) || group.Label.Box.IntersectsWith(boundary)))
continue;
var size = ReportText.Size(id, font);
var labelWidth = size.Width + 2 * LabelPadding;
var labelHeight = size.Height + 2 * LabelPadding;
var centerX = boundary.X + (boundary.Width - labelWidth) / 2;
var centerY = boundary.Y + (boundary.Height - labelHeight) / 2;
// Put the ID beside the dotted boundary, never over a small part or its hole.
var candidates = new[]
{
new XRect(centerX, boundary.Y - labelHeight - Clearance, labelWidth, labelHeight),
new XRect(centerX, boundary.Bottom + Clearance, labelWidth, labelHeight),
new XRect(boundary.Right + Clearance, centerY, labelWidth, labelHeight),
new XRect(boundary.X - labelWidth - Clearance, centerY, labelWidth, labelHeight),
};
foreach (var box in candidates)
{
var padded = box;
padded.Inflate(Clearance, Clearance);
if (!view.Contains(padded) || boxes.Any(other => other.IntersectsWith(padded))
|| cutoffs.Any(other => other.IntersectsWith(padded))
|| labels.Any(label => label.Box.IntersectsWith(padded))
|| groups.Any(group => group.Boundary.IntersectsWith(padded) || group.Label.Box.IntersectsWith(padded)))
continue;
groups.Add(new DiagramGroup(members, boundary, new DiagramLabel(seed, id, box)));
break;
} }
if (unplaced.Count == 0)
return new PlateDiagramPlan(new DiagramView(canvas, fit, labels, grid, null, null), details);
} }
return groups;
var worst = failed.First();
if (!legibleGrid)
throw new NotSupportedException($"Plate {plate.Number}, part {worst + 1} ({plate.Parts[worst].ReportId}): the part is too small relative to the sheet for a legible detail-view grid; this is not supported by the report.");
throw new NotSupportedException($"Plate {plate.Number}, part {worst + 1} ({plate.Parts[worst].ReportId}): its ID label cannot be placed legibly inside the part's material, even in a zoomed detail view; this is not supported by the report.");
}
/// <summary>Grid cell holding a part's bounds center; rows count down from the top.</summary>
private static (int Row, int Column) CellIndex(ReportPlate plate, int index, ReportBounds union, int columns, int rows)
{
var bounds = plate.Parts[index].Geometry.Bounds;
var x = (bounds.Left + bounds.Right) / 2;
var y = (bounds.Bottom + bounds.Top) / 2;
return (System.Math.Clamp((int)((union.Top - y) / (union.Width / rows)), 0, rows - 1),
System.Math.Clamp((int)((x - union.Left) / (union.Length / columns)), 0, columns - 1));
}
/// <summary>Grid spacing on the overview must exceed the gutter names it carries.</summary>
internal static bool GridIsLegible(DiagramGrid grid, Fit fit, XFont font)
{
var column = ReportText.Size(grid.Columns.ToString(System.Globalization.CultureInfo.InvariantCulture), font);
var row = ReportText.Size(ReportText.RowName(grid.Rows - 1), font);
return grid.CellLength * fit.Scale >= column.Width + 2 && grid.CellWidth * fit.Scale >= row.Height + 1
&& row.Width + 2 <= GutterLeft && column.Height + 2 <= GutterTop;
} }
/// <summary> /// <summary>
/// Centers each ID on its part's pole of inaccessibility (<see cref="PolyLabel"/>, as PlateView /// Centers each ID on its part's pole of inaccessibility (<see cref="PolyLabel"/>, as PlateView
/// does), so it sits deepest inside the material and clear of holes. A label box that would /// does), so it sits deepest inside the material and clear of holes. A label box that would
/// touch an edge, hole, cutoff or another label is not moved elsewhere: the part fails here /// touch an edge, hole, cutoff or another label is not moved elsewhere: the individual label is skipped here
/// and gets a zoomed detail view instead. /// and may share a group label or remain unlabeled.
/// </summary> /// </summary>
private static List<DiagramLabel> PlaceLabels(ReportPlate plate, List<Shape> shapes, List<Shape> obstacles, private static List<DiagramLabel> PlaceLabels(ReportPlate plate, List<Shape> shapes, List<Shape> obstacles,
List<int> targets, Fit fit, XRect view, XFont font, out List<int> failed) List<int> targets, Fit fit, XRect view, XFont font, out List<int> failed)
@@ -178,20 +155,6 @@ internal static class NestReportDiagram
return placed; return placed;
} }
/// <summary>Scale at which the label would fit the part's bounds, with room to avoid holes and strokes.</summary>
private static double RequiredScale(ReportPlate plate, int index, XFont font)
{
var bounds = plate.Parts[index].Geometry.Bounds;
if (!(bounds.Length > 0) || !(bounds.Width > 0))
throw new NotSupportedException($"Plate {plate.Number}, part {index + 1} ({plate.Parts[index].ReportId}): the part has no area to hold its ID label; this is not supported by the report.");
var size = ReportText.Size(plate.Parts[index].ReportId, font);
var scale = System.Math.Max((size.Width + 2 * (LabelPadding + Clearance)) / bounds.Length,
(size.Height + 2 * (LabelPadding + Clearance)) / bounds.Width) * 1.5;
if (!double.IsFinite(scale))
throw new NotSupportedException($"Plate {plate.Number}, part {index + 1} ({plate.Parts[index].ReportId}): the part is too small to hold a legible ID label.");
return scale;
}
private static ReportBounds Union(ReportPlate plate, IEnumerable<Shape> shapes) private static ReportBounds Union(ReportPlate plate, IEnumerable<Shape> shapes)
{ {
var left = plate.Bounds.Left; var left = plate.Bounds.Left;
+16 -35
View File
@@ -99,6 +99,7 @@ public sealed class NestApiTests
nest.Status = NestStatus.ToBeCut; nest.Status = NestStatus.ToBeCut;
nest.Plates[0].Parts[0].Location = new Vector(4, 5); nest.Plates[0].Parts[0].Location = new Vector(4, 5);
var archive = Archive(nest); var archive = Archive(nest);
server.Client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue("\"1\""));
var updated = await session.SaveAsync(server.Repository, server.Url, nest, archive); var updated = await session.SaveAsync(server.Repository, server.Url, nest, archive);
Assert.Equal(created.Id, updated.Id); Assert.Equal(created.Id, updated.Id);
@@ -133,41 +134,18 @@ public sealed class NestApiTests
} }
[Fact] [Fact]
public async Task MetadataUpdate_ChangesFieldsWithoutTouchingArchiveOrFileSize() public async Task MetadataAndDelete_AreBlockedWithoutMutation()
{ {
using var server = new TestServerSession(); using var server = new TestServerSession();
var nest = SyntheticNest(); var created = await server.SeedAsync();
var archive = Archive(nest); var before = await server.SnapshotAsync();
var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, archive); server.Client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue("\"1\""));
using var metadata = await server.Client.PutAsync($"/api/nests/{created.Id}/metadata", new StringContent("{}"));
var change = Clone(created); Assert.Equal(HttpStatusCode.BadRequest, metadata.StatusCode);
change.Name = "Renamed by metadata update"; server.Client.DefaultRequestHeaders.IfMatch.Clear();
change.Status = NestStatus.HasBeenCut; using var delete = await server.Client.DeleteAsync($"/api/nests/{created.Id}");
change.Comments = "Metadata-only change"; Assert.Equal((HttpStatusCode)428, delete.StatusCode);
change.FileSize = 1; // Server-computed; must be ignored. Assert.Equal(before, await server.SnapshotAsync());
var updated = await server.Repository.UpdateMetadataAsync(created.Id, change);
Assert.Equal(created.Id, updated.Id);
Assert.Equal("Renamed by metadata update", updated.Name);
Assert.Equal(NestStatus.HasBeenCut, updated.Status);
Assert.Equal("Metadata-only change", updated.Comments);
Assert.Equal(archive.LongLength, updated.FileSize);
AssertSameMetadata(updated, (await server.Repository.GetMetadataAsync(created.Id))!);
Assert.Equal(archive, await server.Repository.GetFileAsync(created.Id));
}
[Fact]
public async Task Delete_RemovesRecordAndArchive()
{
using var server = new TestServerSession();
var nest = SyntheticNest();
var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, Archive(nest));
await server.Repository.DeleteAsync(created.Id);
Assert.Empty(await server.Repository.ListAsync());
Assert.Null(await server.Repository.GetMetadataAsync(created.Id));
Assert.Null(await server.Repository.GetFileAsync(created.Id));
} }
public static TheoryData<string, string> InvalidUploads() public static TheoryData<string, string> InvalidUploads()
@@ -199,6 +177,7 @@ public sealed class NestApiTests
var method = route == "create" ? HttpMethod.Post : HttpMethod.Put; var method = route == "create" ? HttpMethod.Post : HttpMethod.Put;
var path = route == "create" ? "/api/nests" : $"/api/nests/{existing.Id}/file"; var path = route == "create" ? "/api/nests" : $"/api/nests/{existing.Id}/file";
using var request = new HttpRequestMessage(method, path) { Content = InvalidUploadContent(body) }; using var request = new HttpRequestMessage(method, path) { Content = InvalidUploadContent(body) };
request.Headers.IfMatch.Add(new EntityTagHeaderValue("\"1\""));
using var response = await server.Client.SendAsync(request); using var response = await server.Client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -208,7 +187,7 @@ public sealed class NestApiTests
[Theory] [Theory]
[InlineData("{not-json")] [InlineData("{not-json")]
[InlineData("null")] [InlineData("null")]
public async Task MetadataUpdate_WithInvalidJson_Returns400AndLeavesStoreUnchanged(string json) public async Task MetadataUpdate_WithoutPrecondition_Returns428AndLeavesStoreUnchanged(string json)
{ {
using var server = new TestServerSession(); using var server = new TestServerSession();
var existing = await server.SeedAsync(); var existing = await server.SeedAsync();
@@ -217,7 +196,7 @@ public sealed class NestApiTests
using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await server.Client.PutAsync($"/api/nests/{existing.Id}/metadata", content); using var response = await server.Client.PutAsync($"/api/nests/{existing.Id}/metadata", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal((HttpStatusCode)428, response.StatusCode);
Assert.Equal(before, await server.SnapshotAsync()); Assert.Equal(before, await server.SnapshotAsync());
} }
@@ -250,6 +229,7 @@ public sealed class NestApiTests
foreach (var create in requests) foreach (var create in requests)
{ {
using var request = create(); using var request = create();
request.Headers.IfMatch.Add(new EntityTagHeaderValue("\"1\""));
using var response = await server.Client.SendAsync(request); using var response = await server.Client.SendAsync(request);
Assert.True(response.StatusCode == HttpStatusCode.NotFound, Assert.True(response.StatusCode == HttpStatusCode.NotFound,
$"{request.Method} {request.RequestUri} returned {(int)response.StatusCode}, expected 404."); $"{request.Method} {request.RequestUri} returned {(int)response.StatusCode}, expected 404.");
@@ -339,6 +319,7 @@ public sealed class NestApiTests
{ {
var expected = NestRecordFactory.FromNest(nest, saved.Id, archive.LongLength); var expected = NestRecordFactory.FromNest(nest, saved.Id, archive.LongLength);
expected.SavedAt = saved.SavedAt; expected.SavedAt = saved.SavedAt;
expected.Revision = saved.Revision;
return expected; return expected;
} }
@@ -18,7 +18,10 @@ public sealed class NestDatabaseConcurrencyTests
[InlineData("Insert")] [InlineData("Insert")]
[InlineData("UpdateFile")] [InlineData("UpdateFile")]
[InlineData("UpdateMetadata")] [InlineData("UpdateMetadata")]
[InlineData("Delete")] [InlineData("SetDeleted")]
[InlineData("Restore")]
[InlineData("History")]
[InlineData("GetRevisionFile")]
[InlineData("Health")] [InlineData("Health")]
[InlineData("Dispose")] [InlineData("Dispose")]
public async Task Concurrency_EveryOperationWaitsForTheSameMonitor(string operation) public async Task Concurrency_EveryOperationWaitsForTheSameMonitor(string operation)
@@ -44,9 +47,12 @@ public sealed class NestDatabaseConcurrencyTests
case "Get": database.Get(id); break; case "Get": database.Get(id); break;
case "GetFile": database.GetFile(id); break; case "GetFile": database.GetFile(id); break;
case "Insert": database.Insert(Guid.NewGuid(), record, new byte[] { 4 }); break; case "Insert": database.Insert(Guid.NewGuid(), record, new byte[] { 4 }); break;
case "UpdateFile": database.Update(id, record, new byte[] { 5 }); break; case "UpdateFile": database.Update(id, record, new byte[] { 5 }, 1); break;
case "UpdateMetadata": database.Update(id, record, null); break; case "UpdateMetadata": database.UpdateMetadata(id, record, 1); break;
case "Delete": database.Delete(id); break; case "SetDeleted": database.SetDeleted(id, true, 1); break;
case "Restore": database.Restore(id, 1, 1); break;
case "History": database.History(id, 0, 100); break;
case "GetRevisionFile": database.GetRevisionFile(id, 1); break;
case "Health": Assert.True(database.IsHealthy()); break; case "Health": Assert.True(database.IsHealthy()); break;
case "Dispose": database.Dispose(); break; case "Dispose": database.Dispose(); break;
default: throw new InvalidOperationException($"Unknown test operation: {operation}"); default: throw new InvalidOperationException($"Unknown test operation: {operation}");
@@ -102,10 +108,19 @@ public sealed class NestDatabaseConcurrencyTests
() => database.Query(new NestQuery { Search = "Synthetic", Limit = 1 }), () => database.Query(new NestQuery { Search = "Synthetic", Limit = 1 }),
() => database.Get(id), () => database.Get(id),
() => database.GetFile(id), () => database.GetFile(id),
() => database.Update(id, record, new byte[] { 2 }), () => database.Update(id, record, new byte[] { 2 }, 1),
() => database.Update(id, record, null), () => database.UpdateMetadata(id, record, 2),
() => database.History(id, 0, 100),
() => Assert.True(database.IsHealthy()), () => Assert.True(database.IsHealthy()),
() => database.Delete(id), () => database.GetRevisionFile(id, 1),
() => database.SetDeleted(id, true, 2),
() => database.Query(new NestQuery(), true),
() => database.Get(id, true),
() => database.GetFile(id, true),
() => database.History(id, 0, 100, true),
() => database.GetRevisionFile(id, 1, true),
() => database.SetDeleted(id, false, 3),
() => database.Restore(id, 1, 4),
]; ];
foreach (var operation in operations) foreach (var operation in operations)
{ {
@@ -143,17 +158,13 @@ public sealed class NestDatabaseConcurrencyTests
Assert.Equal(file, database.GetFile(id)); Assert.Equal(file, database.GetFile(id));
record.Comments = "Archive updated"; record.Comments = "Archive updated";
file = file.Concat(new byte[] { 42 }).ToArray(); file = file.Concat(new byte[] { 42 }).ToArray();
saved = database.Update(id, record, file)!; saved = database.Update(id, record, file, 1)!;
AssertStoredRecord(record, id, file, saved); AssertStoredRecord(record, id, file, saved);
record.Comments = "Metadata only"; record.Comments = "Metadata only";
saved = database.Update(id, record, null)!; saved = database.Update(id, record, file, saved.Revision)!;
AssertStoredRecord(record, id, file, saved); AssertStoredRecord(record, id, file, saved);
Assert.Equal(file.LongLength, saved.FileSize); Assert.Equal(file.LongLength, saved.FileSize);
Assert.Equal(file, database.GetFile(id)); Assert.Equal(file, database.GetFile(id));
var transient = Guid.NewGuid();
database.Insert(transient, record, new byte[] { 9 });
Assert.True(database.Delete(transient));
Assert.Null(database.Get(transient));
records.Add((saved, file)); records.Add((saved, file));
} }
return records; return records;
@@ -193,11 +204,12 @@ public sealed class NestDatabaseConcurrencyTests
Assert.Contains(await repository.ListAsync(watchdog.Token), r => r.Id == saved.Id); Assert.Contains(await repository.ListAsync(watchdog.Token), r => r.Id == saved.Id);
nest.Notes = $"Archive change {lane}-{index}"; nest.Notes = $"Archive change {lane}-{index}";
file = NestApiTests.Archive(nest); file = NestApiTests.Archive(nest);
client.DefaultRequestHeaders.IfMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"1\""));
var updated = await session.SaveAsync(repository, client.BaseAddress.ToString(), nest, file, var updated = await session.SaveAsync(repository, client.BaseAddress.ToString(), nest, file,
cancellationToken: watchdog.Token); cancellationToken: watchdog.Token);
client.DefaultRequestHeaders.IfMatch.Clear();
AssertStoredRecord(NestRecordFactory.FromNest(nest, saved.Id, file.LongLength), saved.Id, file, updated); AssertStoredRecord(NestRecordFactory.FromNest(nest, saved.Id, file.LongLength), saved.Id, file, updated);
updated.Comments = $"Metadata only {lane}-{index}"; saved = updated;
saved = await repository.UpdateMetadataAsync(updated.Id, updated, watchdog.Token);
AssertStoredRecord(updated, updated.Id, file, saved); AssertStoredRecord(updated, updated.Id, file, saved);
Assert.Equal(file.LongLength, saved.FileSize); Assert.Equal(file.LongLength, saved.FileSize);
Assert.Equal(file, await repository.GetFileAsync(saved.Id, watchdog.Token)); Assert.Equal(file, await repository.GetFileAsync(saved.Id, watchdog.Token));
@@ -243,6 +255,7 @@ public sealed class NestDatabaseConcurrencyTests
expected.Id = id; expected.Id = id;
expected.FileSize = file.LongLength; expected.FileSize = file.LongLength;
expected.SavedAt = actual.SavedAt; expected.SavedAt = actual.SavedAt;
expected.Revision = actual.Revision;
Assert.NotEqual(default, actual.SavedAt); Assert.NotEqual(default, actual.SavedAt);
AssertRecord(expected, actual); AssertRecord(expected, actual);
} }
@@ -0,0 +1,171 @@
using System.Net;
using OpenNest.Data;
namespace OpenNest.Server.Tests;
public sealed class NestHistoryClientTests
{
[Fact]
public async Task LostCreateReply_BlocksReplayAndReconcilesSameId()
{
using var factory = new ServerFactory();
using var http = factory.CreateDefaultClient(new LostReplyHandler());
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
var session = new NestSaveSession();
var nest = NestApiTests.SyntheticNest();
var archive = NestApiTests.Archive(nest);
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
var pendingId = session.PendingCreateId;
Assert.NotEqual(Guid.Empty, pendingId);
Assert.True(session.RequiresReload);
Assert.Single(await repository.ListAsync());
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
Assert.Single(await repository.ListAsync());
var reconciled = await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString());
Assert.Equal(pendingId, reconciled!.Id);
Assert.Equal(pendingId, session.RemoteId);
Assert.Equal(Guid.Empty, session.PendingCreateId);
Assert.Single(await repository.ListAsync());
Assert.Equal(archive, await repository.GetRevisionFileAsync(pendingId, 1));
}
private sealed class LostReplyHandler : DelegatingHandler
{
private bool lost;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var response = await base.SendAsync(request, ct);
if (request.Method == HttpMethod.Post && !lost)
{
lost = true;
response.Dispose();
throw new IOException("Response lost after commit.");
}
return response;
}
}
[Fact]
public async Task LostCreateReply_ChangedServerMetadataCannotBindWithoutReload()
{
using var factory = new ServerFactory();
using var http = factory.CreateDefaultClient(new LostReplyHandler());
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
var session = new NestSaveSession();
var nest = NestApiTests.SyntheticNest();
var archive = NestApiTests.Archive(nest);
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
var id = session.PendingCreateId;
var current = (await repository.GetMetadataAsync(id))!;
current.Comments = "Changed by another editor";
await repository.UpdateMetadataAsync(id, current, current.Revision);
await Assert.ThrowsAsync<IOException>(() => session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString()));
Assert.Equal(Guid.Empty, session.RemoteId);
Assert.True(session.RequiresReload);
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
Assert.Single(await repository.ListAsync());
}
private sealed class FirstPostFailsBeforeSend : DelegatingHandler
{
private bool failed;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
if (request.Method == HttpMethod.Post && !failed)
{
failed = true;
throw new IOException("Upload was not sent.");
}
return base.SendAsync(request, ct);
}
}
[Fact]
public async Task AbsentPendingUpload_RetriesSameIdOnlyAfterExplicitReconciliation()
{
using var factory = new ServerFactory();
using var http = factory.CreateDefaultClient(new FirstPostFailsBeforeSend());
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
var session = new NestSaveSession();
var nest = NestApiTests.SyntheticNest();
var archive = NestApiTests.Archive(nest);
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
var pending = session.PendingCreateId;
Assert.Empty(await repository.ListAsync());
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
Assert.Null(await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString()));
Assert.False(session.RequiresReload);
var created = await session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive);
Assert.Equal(pending, created.Id);
Assert.Equal(1, created.Revision);
Assert.Single(await repository.ListAsync());
}
[Fact]
public async Task AbsentPendingSaveAs_NeverUpdatesBoundOriginalOnRetry()
{
using var factory = new ServerFactory();
using var seedHttp = factory.CreateClient();
using var seed = new RemoteNestRepository(seedHttp, seedHttp.BaseAddress!.ToString());
var nest = NestApiTests.SyntheticNest();
var originalBytes = NestApiTests.Archive(nest);
var original = await seed.UploadAsync(originalBytes, new NestRecord { Name = "Original" });
using var http = factory.CreateDefaultClient(new FirstPostFailsBeforeSend());
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
var session = new NestSaveSession();
session.Bind(original.Id, http.BaseAddress.ToString(), original.Revision);
nest.Name = "Copied nest";
var copyBytes = NestApiTests.Archive(nest);
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes, saveCopy: true));
var pending = session.PendingCreateId;
Assert.Null(await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString()));
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes));
var copy = await session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes, saveCopy: true);
Assert.Equal(pending, copy.Id);
Assert.NotEqual(original.Id, copy.Id);
Assert.Equal(1, (await repository.GetMetadataAsync(original.Id))!.Revision);
Assert.Equal(originalBytes, await repository.GetFileAsync(original.Id));
Assert.Equal(copyBytes, await repository.GetFileAsync(copy.Id));
}
[Fact]
public async Task RealClient_CreatesAndUpdatesOneNestWithExactHistoricalArchives()
{
using var factory = new ServerFactory();
using var http = factory.CreateClient();
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
var id = Guid.NewGuid();
var first = new byte[] { 80, 75, 0, 1, 255 };
var second = new byte[] { 80, 75, 0, 2, 254 };
var created = await repository.UploadAsync(first, new NestRecord { Id = id, Name = "Synthetic" });
Assert.Equal(1, created.Revision);
var updated = await repository.UpdateFileAsync(id, second, new NestRecord { Id = id, Name = "Synthetic two" }, 1);
Assert.Equal(2, updated.Revision);
var page = await repository.GetRevisionsAsync(id, limit: 1);
Assert.Equal(2, page.Total);
Assert.Equal(2, Assert.Single(page.Items).Revision);
Assert.Equal("Synthetic", Assert.Single((await repository.GetRevisionsAsync(id, offset: 1, limit: 1)).Items).Name);
Assert.Equal(first, await repository.GetRevisionFileAsync(id, 1));
Assert.Equal(second, await repository.GetRevisionFileAsync(id, 2));
Assert.Equal(second, await repository.GetFileAsync(id));
var conflict = await Assert.ThrowsAsync<IOException>(() =>
repository.UpdateFileAsync(id, first, created, 1));
Assert.Contains("409", conflict.Message);
Assert.Equal(2, (await repository.GetRevisionsAsync(id, limit: 1)).Total);
Assert.Equal(second, await repository.GetFileAsync(id));
}
[Fact]
public async Task LegacyUnguardedDelete_IsRefusedWithoutMutation()
{
using var factory = new ServerFactory();
using var http = factory.CreateClient();
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
var created = await repository.UploadAsync([1, 2, 3], new NestRecord { Name = "Synthetic" });
using var response = await http.DeleteAsync($"/api/nests/{created.Id}");
Assert.Equal((HttpStatusCode)428, response.StatusCode);
Assert.Equal(new byte[] { 1, 2, 3 }, await repository.GetRevisionFileAsync(created.Id, 1));
}
}
+287
View File
@@ -0,0 +1,287 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server.Tests;
public sealed class NestMutationTests
{
[Fact]
public async Task Metadata_AppendsExactArchiveAndNoOpStillChecksPrecondition()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var original = await repository.UploadAsync([0, 255, 1], new NestRecord { Name = "Fixture" });
original.Comments = "Edited";
var saved = await repository.UpdateMetadataAsync(original.Id, original);
Assert.Equal(2, saved.Revision);
var retry = await repository.UpdateMetadataAsync(saved.Id, saved);
Assert.Equal(saved.SavedAt, retry.SavedAt);
Assert.Equal(2, retry.Revision);
Assert.Contains("409", (await Assert.ThrowsAsync<IOException>(() => repository.UpdateMetadataAsync(original.Id, original))).Message);
var history = await repository.GetRevisionsAsync(saved.Id);
Assert.Equal(new[] { "Edited", "" }, history.Items.Select(r => r.Comments));
foreach (var revision in history.Items)
Assert.Equal(new byte[] { 0, 255, 1 }, await repository.GetRevisionFileAsync(saved.Id, revision.Revision));
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(2, reopened.History(saved.Id, 0, 100)!.Total);
Assert.Equal(new byte[] { 0, 255, 1 }, reopened.GetFile(saved.Id));
}
[Theory]
[InlineData(null, 428)]
[InlineData("1", 400)]
[InlineData("\"2\"", 409)]
public async Task Metadata_RejectedWriteLeavesHistory(string? header, int status)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" });
if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header);
using var response = await client.PutAsJsonAsync($"/api/nests/{saved.Id}/metadata", new NestRecord { Name = "Rejected" });
Assert.Equal(status, (int)response.StatusCode);
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal("Fixture", reopened.Get(saved.Id)!.Name);
Assert.Equal(1, reopened.History(saved.Id, 0, 100)!.Total);
}
[Fact]
public async Task Metadata_FailedSnapshotRollsBack()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" });
FailSnapshots(factory.DatabasePath);
saved.Comments = "Rejected";
Assert.Contains("500", (await Assert.ThrowsAsync<IOException>(() => repository.UpdateMetadataAsync(saved.Id, saved))).Message);
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal("", reopened.Get(saved.Id)!.Comments);
Assert.Equal(1, reopened.History(saved.Id, 0, 100)!.Total);
}
[Fact]
public async Task Delete_HidesAllOrdinaryReadsAndRecoveryPreservesVersions()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var saved = await repository.UploadAsync([0, 255], new NestRecord { Name = "Fixture" });
var deleted = await repository.DeleteAsync(saved.Id, 1);
Assert.True(deleted.Deleted);
Assert.Equal(2, deleted.Revision);
foreach (var suffix in new[] { "", "/file", "/revisions", "/revisions/1/file" })
{
using var hidden = await client.GetAsync($"/api/nests/{saved.Id}{suffix}");
Assert.Equal(HttpStatusCode.NotFound, hidden.StatusCode);
}
Assert.Empty(await repository.ListAsync());
Assert.Empty((await repository.QueryAsync(new NestQuery())).Items);
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Null(reopened.Get(saved.Id));
// Recovery is explicit and bounded, never ordinary search.
Assert.Equal(saved.Id, Assert.Single((await repository.QueryDeletedAsync(new NestQuery { Limit = 1 })).Items).Id);
Assert.True((await repository.GetDeletedMetadataAsync(saved.Id))!.Deleted);
Assert.Equal(new byte[] { 0, 255 }, await repository.GetDeletedFileAsync(saved.Id));
var history = await repository.GetDeletedRevisionsAsync(saved.Id);
Assert.Equal(new[] { true, false }, history.Items.Select(r => r.Deleted));
foreach (var item in history.Items)
Assert.Equal(new byte[] { 0, 255 }, await repository.GetDeletedRevisionFileAsync(saved.Id, item.Revision));
var recovered = await repository.RecoverAsync(saved.Id, 2);
Assert.False(recovered.Deleted);
Assert.Empty((await repository.QueryDeletedAsync(new NestQuery())).Items);
Assert.Equal(3, (await repository.GetMetadataAsync(saved.Id))!.Revision);
Assert.Equal(new byte[] { 0, 255 }, await repository.GetFileAsync(saved.Id));
Assert.Equal(3, (await repository.GetRevisionsAsync(saved.Id)).Total);
}
[Theory]
[InlineData(false, null, 428)]
[InlineData(false, "1", 400)]
[InlineData(false, "\"2\"", 409)]
[InlineData(true, null, 428)]
[InlineData(true, "1", 400)]
[InlineData(true, "\"1\"", 409)]
public async Task Visibility_RejectedWriteLeavesBytesAndHistory(bool recover, string? header, int status)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" });
if (recover) await repository.DeleteAsync(saved.Id, 1);
using var request = new HttpRequestMessage(recover ? HttpMethod.Post : HttpMethod.Delete,
recover ? $"/api/nests/deleted/{saved.Id}/recover" : $"/api/nests/{saved.Id}");
if (header is not null) request.Headers.TryAddWithoutValidation("If-Match", header);
using var response = await client.SendAsync(request);
Assert.Equal(status, (int)response.StatusCode);
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(recover ? 2 : 1, reopened.History(saved.Id, 0, 100, recover)!.Total);
Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id, recover));
Assert.Equal(recover, reopened.Get(saved.Id, recover)!.Deleted);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Visibility_FailedSnapshotRollsBack(bool recover)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" });
if (recover) await repository.DeleteAsync(saved.Id, 1);
FailSnapshots(factory.DatabasePath);
Assert.Contains("500", (await Assert.ThrowsAsync<IOException>(() => recover
? repository.RecoverAsync(saved.Id, 2) : repository.DeleteAsync(saved.Id, 1))).Message);
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(recover ? 2 : 1, reopened.History(saved.Id, 0, 100, recover)!.Total);
Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id, recover));
}
[Fact]
public async Task Restore_AppendsSelectedSnapshotWithoutChangingInterveningHistory()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var first = await repository.UploadAsync([0, 255], new NestRecord { Name = "Original", Comments = "Before" });
var second = await repository.UpdateFileAsync(first.Id, [3, 4], new NestRecord { Name = "Changed" }, 1);
var before = await repository.GetRevisionsAsync(first.Id);
var restored = await repository.RestoreAsync(first.Id, 1, 2);
Assert.Equal(1, restored.RestoredFromRevision);
var current = (await repository.GetMetadataAsync(first.Id))!;
Assert.Equal(3, current.Revision);
Assert.Equal(first.Id, current.Id);
Assert.Equal("Original", current.Name);
Assert.Equal("Before", current.Comments);
Assert.Equal(new byte[] { 0, 255 }, await repository.GetFileAsync(first.Id));
Assert.Equal(new byte[] { 3, 4 }, await repository.GetRevisionFileAsync(first.Id, 2));
var after = await repository.GetRevisionsAsync(first.Id);
Assert.Equal(3, after.Total);
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(before.Items), System.Text.Json.JsonSerializer.Serialize(after.Items.Skip(1)));
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(3, reopened.History(first.Id, 0, 100)!.Total);
Assert.Equal(new byte[] { 0, 255 }, reopened.GetFile(first.Id));
}
[Theory]
[InlineData(null, 1, 428)]
[InlineData("1", 1, 400)]
[InlineData("\"2\"", 1, 409)]
[InlineData("\"1\"", 0, 400)]
[InlineData("\"1\"", 99, 404)]
public async Task Restore_RejectedWriteLeavesHistory(string? header, int source, int status)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" });
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/nests/{saved.Id}/revisions/{source}/restore");
if (header is not null) request.Headers.TryAddWithoutValidation("If-Match", header);
using var response = await client.SendAsync(request);
Assert.Equal(status, (int)response.StatusCode);
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(1, reopened.History(saved.Id, 0, 100)!.Total);
Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id));
Assert.Null(reopened.Get(saved.Id)!.RestoredFromRevision);
}
[Fact]
public async Task Restore_FailedSnapshotRollsBackAndSameContentStillAppends()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" });
var restored = await repository.RestoreAsync(saved.Id, 1, 1);
Assert.Equal(2, restored.Revision);
Assert.Equal(1, restored.RestoredFromRevision);
FailSnapshots(factory.DatabasePath);
Assert.Contains("500", (await Assert.ThrowsAsync<IOException>(() => repository.RestoreAsync(saved.Id, 1, 2))).Message);
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(2, reopened.History(saved.Id, 0, 100)!.Total);
Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id));
Assert.Equal(1, reopened.Get(saved.Id)!.RestoredFromRevision);
}
[Theory]
[InlineData("metadata")]
[InlineData("delete")]
[InlineData("restore")]
[InlineData("recover")]
public async Task Mutations_UnknownOrWrongVisibilityIs404(string operation)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" });
if (operation != "recover") await repository.DeleteAsync(saved.Id, 1);
foreach (var id in new[] { saved.Id, Guid.NewGuid() })
{
using var request = new HttpRequestMessage(operation == "metadata" ? HttpMethod.Put :
operation == "delete" ? HttpMethod.Delete : HttpMethod.Post,
operation switch
{
"metadata" => $"/api/nests/{id}/metadata",
"delete" => $"/api/nests/{id}",
"restore" => $"/api/nests/{id}/revisions/1/restore",
_ => $"/api/nests/deleted/{id}/recover"
});
request.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
if (operation == "metadata") request.Content = JsonContent.Create(new NestRecord());
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(operation == "recover" ? 1 : 2, reopened.History(saved.Id, 0, 100, operation != "recover")!.Total);
}
[Theory]
[InlineData("metadata")]
[InlineData("delete")]
[InlineData("restore")]
[InlineData("recover")]
public async Task ConcurrentMutation_OnlyOneExpectedRevisionWins(string operation)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" });
var expected = 1;
if (operation == "recover") { await repository.DeleteAsync(saved.Id, 1); expected = 2; }
var attempts = Enumerable.Range(0, 2).Select(async i =>
{
using var request = new HttpRequestMessage(operation == "metadata" ? HttpMethod.Put :
operation == "delete" ? HttpMethod.Delete : HttpMethod.Post,
operation switch
{
"metadata" => $"/api/nests/{saved.Id}/metadata",
"delete" => $"/api/nests/{saved.Id}",
"restore" => $"/api/nests/{saved.Id}/revisions/1/restore",
_ => $"/api/nests/deleted/{saved.Id}/recover"
});
request.Headers.TryAddWithoutValidation("If-Match", $"\"{expected}\"");
if (operation == "metadata") request.Content = JsonContent.Create(new NestRecord { Name = $"Writer {i}" });
using var response = await client.SendAsync(request);
return response.StatusCode;
});
var statuses = await Task.WhenAll(attempts);
Assert.Single(statuses.Where(s => s == HttpStatusCode.OK));
Assert.Single(statuses.Where(s => s == (operation is "delete" or "recover" ? HttpStatusCode.NotFound : HttpStatusCode.Conflict)));
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(expected + 1, reopened.History(saved.Id, 0, 100, operation == "delete")!.Total);
Assert.Equal(new byte[] { 1 }, reopened.GetRevisionFile(saved.Id, 1, operation == "delete"));
}
private static void FailSnapshots(string path)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TRIGGER fail_snapshot BEFORE INSERT ON nest_revisions BEGIN SELECT RAISE(ABORT, 'fixture failure'); END";
command.ExecuteNonQuery();
}
}
+246
View File
@@ -0,0 +1,246 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
using OpenNest.Data;
namespace OpenNest.Server.Tests;
public sealed class NestRevisionTests
{
private const string LegacySchema = """
CREATE TABLE nests (
id TEXT PRIMARY KEY, name TEXT NOT NULL, customer TEXT NOT NULL DEFAULT '',
dateCreated TEXT NOT NULL, dateModified TEXT NOT NULL, material TEXT NOT NULL DEFAULT '',
thickness REAL NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'Quote',
plateCount INTEGER NOT NULL DEFAULT 0, partCount INTEGER NOT NULL DEFAULT 0,
comments TEXT NOT NULL DEFAULT '', madeBy TEXT NOT NULL DEFAULT '',
fileSize INTEGER NOT NULL DEFAULT 0, savedAt TEXT NOT NULL, file BLOB NOT NULL);
""";
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } };
internal static MultipartFormDataContent Body(NestRecord record, byte[] bytes)
{
var body = new MultipartFormDataContent();
body.Add(new StringContent(JsonSerializer.Serialize(record)), "metadata");
body.Add(new ByteArrayContent(bytes), "file", "fixture.nest");
return body;
}
[Fact]
public async Task ChangedSave_PreservesExactHistoryAndNoOp()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
var record = new NestRecord { Name = "Initial" };
using var created = await client.PostAsync("/api/nests", Body(record, [1, 2]));
var current = (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!;
Assert.Equal(1, current.Revision);
record.Name = "Changed";
client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\"");
using var updated = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, [3, 4]));
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
var latest = (await updated.Content.ReadFromJsonAsync<NestRecord>(Options))!;
Assert.Equal(2, latest.Revision);
Assert.Equal(new byte[] { 1, 2 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file"));
Assert.Equal(new byte[] { 3, 4 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file"));
client.DefaultRequestHeaders.Remove("If-Match");
client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"2\"");
using var retry = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, [3, 4]));
Assert.Equal(latest.SavedAt, (await retry.Content.ReadFromJsonAsync<NestRecord>(Options))!.SavedAt);
var page = await client.GetFromJsonAsync<NestPage>($"/api/nests/{current.Id}/revisions?limit=1", Options);
Assert.Equal(2, page!.Total);
Assert.Equal(2, Assert.Single(page.Items).Revision);
}
[Theory]
[InlineData(null, 428)]
[InlineData("1", 400)]
[InlineData("\"0\"", 400)]
[InlineData("\"2\"", 409)]
public async Task RejectedWrite_LeavesCurrentAndHistoryUnchanged(string? header, int status)
{
using var factory = new ServerFactory();
Guid id;
using (var client = factory.CreateClient())
{
using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
id = (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!.Id;
if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header);
using var response = await client.PutAsync($"/api/nests/{id}/file", Body(new NestRecord { Name = "Rejected" }, [2]));
Assert.Equal(status, (int)response.StatusCode);
Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{id}/file"));
}
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal("Initial", reopened.Get(id)!.Name);
Assert.Equal(1, reopened.Get(id)!.Revision);
}
[Fact]
public async Task FailedRevisionInsert_RollsBackCurrentProjection()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
var current = (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!;
using (var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False"))
{
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TRIGGER fail_revision BEFORE INSERT ON nest_revisions BEGIN SELECT RAISE(ABORT, 'fixture failure'); END";
command.ExecuteNonQuery();
}
client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\"");
using var failed = await client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = "Rejected" }, [2]));
Assert.Equal(HttpStatusCode.InternalServerError, failed.StatusCode);
Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file"));
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal("Initial", reopened.Get(current.Id)!.Name);
Assert.Equal(1, reopened.History(current.Id, 0, 100)!.Total);
}
[Fact]
public void OldSchema_BackfillsExactRowsAndIsIdempotent()
{
using var factory = new ServerFactory();
var ids = new[] { Guid.NewGuid(), Guid.NewGuid() };
var bytes = new[] { new byte[] { 0, 1, 255 }, new byte[] { 7, 8, 9, 0 } };
var timestamp = new DateTime(2026, 1, 2, 3, 4, 5);
using (var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False"))
{
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = LegacySchema;
command.ExecuteNonQuery();
for (var i = 0; i < ids.Length; i++)
{
command.Parameters.Clear();
command.CommandText = "INSERT INTO nests VALUES ($id, $name, 'Fixture', $date, $date, 'Alloy', 0.25, 'ToBeCut', 2, 3, 'Notes', 'Author', $size, $date, $file)";
command.Parameters.AddWithValue("$id", ids[i].ToString());
command.Parameters.AddWithValue("$name", $"Legacy {i}");
command.Parameters.AddWithValue("$date", timestamp.ToString("o"));
command.Parameters.AddWithValue("$size", bytes[i].Length);
command.Parameters.AddWithValue("$file", bytes[i]);
command.ExecuteNonQuery();
}
}
for (var open = 0; open < 2; open++)
{
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(2, database.List().Count);
for (var i = 0; i < ids.Length; i++)
{
var current = database.Get(ids[i])!;
Assert.Equal(1, current.Revision);
Assert.Equal(timestamp, current.SavedAt);
Assert.Equal($"Legacy {i}", current.Name);
Assert.Equal("Fixture", current.Customer);
Assert.Equal("Notes", current.Comments);
Assert.Equal(bytes[i], database.GetFile(ids[i]));
Assert.Equal(bytes[i], database.GetRevisionFile(ids[i], 1));
var historical = Assert.Single(database.History(ids[i], 0, 100)!.Items);
Assert.Equal(JsonSerializer.Serialize(current), JsonSerializer.Serialize(historical));
}
}
}
[Theory]
[InlineData("limit=0")]
[InlineData("limit=201")]
[InlineData("offset=-1")]
[InlineData("limit=1&limit=2")]
[InlineData("limit=no")]
[InlineData("unknown=1")]
public async Task History_InvalidPagination_IsRejected(string query)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync($"/api/nests/{Guid.NewGuid()}/revisions?{query}");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task ConcurrentGuardedSaves_OneWinsAndOneConflicts()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
var current = (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!;
client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\"");
var responses = await Task.WhenAll(Enumerable.Range(2, 2).Select(i =>
client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = $"Writer {i}" }, [(byte)i]))));
try
{
Assert.Single(responses.Where(r => r.StatusCode == HttpStatusCode.OK));
var conflict = Assert.Single(responses.Where(r => r.StatusCode == HttpStatusCode.Conflict));
Assert.Equal(2, (await conflict.Content.ReadFromJsonAsync<NestRecord>(Options))!.Revision);
var history = await client.GetFromJsonAsync<NestPage>($"/api/nests/{current.Id}/revisions", Options);
Assert.Equal(new[] { 2, 1 }, history!.Items.Select(r => r.Revision));
Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file"));
}
finally { foreach (var response in responses) response.Dispose(); }
}
[Theory]
[InlineData("metadata", null, 428)]
[InlineData("delete", null, 428)]
[InlineData("metadata", "\"1\"", 400)]
[InlineData("file", "\"1\"", 400)]
public async Task BlockedOrMalformedWrite_PreservesAllSnapshots(string route, string? header, int status)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
var current = (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!;
if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header);
using var response = route == "delete"
? await client.DeleteAsync($"/api/nests/{current.Id}")
: await client.PutAsync($"/api/nests/{current.Id}/{route}", new StringContent("Malformed"));
Assert.Equal(status, (int)response.StatusCode);
Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file"));
var history = await client.GetFromJsonAsync<NestPage>($"/api/nests/{current.Id}/revisions", Options);
Assert.Equal(1, history!.Total);
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal("Initial", reopened.Get(current.Id)!.Name);
Assert.Equal(new byte[] { 1 }, reopened.GetRevisionFile(current.Id, 1));
}
[Fact]
public async Task UnknownHistoryOrVersion_Is404_AndOffsetIsBounded()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var unknown = await client.GetAsync($"/api/nests/{Guid.NewGuid()}/revisions");
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
var current = (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!;
using var missing = await client.GetAsync($"/api/nests/{current.Id}/revisions/2/file");
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
var page = await client.GetFromJsonAsync<NestPage>($"/api/nests/{current.Id}/revisions?offset=2147483647", Options);
Assert.Empty(page!.Items);
Assert.Equal(1, page.Total);
}
[Fact]
public void FailedMigration_RollsBackSchemaAndCanRestart()
{
using var factory = new ServerFactory();
using var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = LegacySchema + "CREATE TABLE nest_revisions (fixture TEXT);";
command.ExecuteNonQuery();
Assert.Throws<SqliteException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
command.CommandText = "PRAGMA user_version";
Assert.Equal(0L, command.ExecuteScalar());
command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('nests') WHERE name='revision'";
Assert.Equal(0L, command.ExecuteScalar());
command.CommandText = "DROP TABLE nest_revisions";
command.ExecuteNonQuery();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Empty(database.List());
}
}
+196 -22
View File
@@ -29,7 +29,9 @@ public sealed class NestDatabase : IDisposable
Pooling = pooling, Pooling = pooling,
}.ToString()); }.ToString());
_connection.Open(); _connection.Open();
Execute(""" try
{
Execute("""
PRAGMA journal_mode=WAL; PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS nests ( CREATE TABLE IF NOT EXISTS nests (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -50,6 +52,9 @@ public sealed class NestDatabase : IDisposable
); );
CREATE INDEX IF NOT EXISTS ix_nests_savedAt ON nests(savedAt DESC); CREATE INDEX IF NOT EXISTS ix_nests_savedAt ON nests(savedAt DESC);
"""); """);
Migrate();
}
catch { _connection.Dispose(); throw; }
} }
public IReadOnlyList<NestRecord> List() public IReadOnlyList<NestRecord> List()
@@ -58,7 +63,7 @@ public sealed class NestDatabase : IDisposable
{ {
using var command = _connection.CreateCommand(); using var command = _connection.CreateCommand();
command.CommandText = command.CommandText =
$"SELECT {RecordColumns} FROM nests ORDER BY savedAt DESC"; $"SELECT {RecordColumns} FROM nests WHERE deleted=0 ORDER BY savedAt DESC";
var records = new List<NestRecord>(); var records = new List<NestRecord>();
using var reader = command.ExecuteReader(); using var reader = command.ExecuteReader();
while (reader.Read()) while (reader.Read())
@@ -67,12 +72,13 @@ public sealed class NestDatabase : IDisposable
} }
} }
public NestRecord? Get(Guid id) public NestRecord? Get(Guid id, bool deleted = false)
{ {
lock (_sync) lock (_sync)
{ {
using var command = _connection.CreateCommand(); using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id"; command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id AND deleted=$deleted";
command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0);
command.Parameters.AddWithValue("$id", id.ToString()); command.Parameters.AddWithValue("$id", id.ToString());
using var reader = command.ExecuteReader(); using var reader = command.ExecuteReader();
return reader.Read() ? ReadRecord(reader) : null; return reader.Read() ? ReadRecord(reader) : null;
@@ -83,7 +89,7 @@ public sealed class NestDatabase : IDisposable
/// One bounded page of matching metadata plus the total match count. The count and /// One bounded page of matching metadata plus the total match count. The count and
/// the page are read in the same monitor hold, so they describe one state. /// the page are read in the same monitor hold, so they describe one state.
/// </summary> /// </summary>
public NestPage Query(NestQuery query) public NestPage Query(NestQuery query, bool deleted = false)
{ {
ArgumentNullException.ThrowIfNull(query); ArgumentNullException.ThrowIfNull(query);
var error = query.GetValidationError(); var error = query.GetValidationError();
@@ -91,7 +97,7 @@ public sealed class NestDatabase : IDisposable
throw new ArgumentException(error, nameof(query)); throw new ArgumentException(error, nameof(query));
var search = query.NormalizedSearch; var search = query.NormalizedSearch;
var where = search.Length == 0 ? "" : SearchPredicate; var where = "WHERE deleted=" + (deleted ? "1" : "0") + (search.Length == 0 ? "" : " AND (" + SearchPredicate + ")");
lock (_sync) lock (_sync)
{ {
using var count = _connection.CreateCommand(); using var count = _connection.CreateCommand();
@@ -118,12 +124,13 @@ public sealed class NestDatabase : IDisposable
} }
} }
public byte[]? GetFile(Guid id) public byte[]? GetFile(Guid id, bool deleted = false)
{ {
lock (_sync) lock (_sync)
{ {
using var command = _connection.CreateCommand(); using var command = _connection.CreateCommand();
command.CommandText = "SELECT file FROM nests WHERE id = $id"; command.CommandText = "SELECT file FROM nests WHERE id = $id AND deleted=$deleted";
command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0);
command.Parameters.AddWithValue("$id", id.ToString()); command.Parameters.AddWithValue("$id", id.ToString());
var value = command.ExecuteScalar(); var value = command.ExecuteScalar();
return value is byte[] bytes ? bytes : null; return value is byte[] bytes ? bytes : null;
@@ -134,7 +141,9 @@ public sealed class NestDatabase : IDisposable
{ {
lock (_sync) lock (_sync)
{ {
using var transaction = _connection.BeginTransaction();
using var command = _connection.CreateCommand(); using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """ command.CommandText = """
INSERT INTO nests (id, name, customer, dateCreated, dateModified, material, INSERT INTO nests (id, name, customer, dateCreated, dateModified, material,
thickness, status, plateCount, partCount, comments, madeBy, fileSize, thickness, status, plateCount, partCount, comments, madeBy, fileSize,
@@ -145,42 +154,199 @@ public sealed class NestDatabase : IDisposable
"""; """;
AddRecordParameters(command, id, record, file, updateFile: true); AddRecordParameters(command, id, record, file, updateFile: true);
command.ExecuteNonQuery(); command.ExecuteNonQuery();
Snapshot(id, transaction);
transaction.Commit();
return Get(id) ?? throw new InvalidOperationException("Insert did not persist."); return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
} }
} }
public NestRecord? Update(Guid id, NestRecord record, byte[]? file) public NestRecord? UpdateMetadata(Guid id, NestRecord record, int expectedRevision)
{ {
lock (_sync) lock (_sync)
{ {
if (Get(id) is null) return Update(id, record, null, expectedRevision);
return null; }
}
using var command = _connection.CreateCommand(); public NestRecord? Update(Guid id, NestRecord record, byte[]? file, int expectedRevision)
var fileClause = file is null ? "" : ", file = $file, fileSize = $fileSize"; {
command.CommandText = $""" lock (_sync)
{
using var transaction = _connection.BeginTransaction();
var current = Get(id);
if (current is null) return null;
if (current.Revision != expectedRevision) throw new RevisionConflictException(current);
file ??= GetFile(id)!;
if (SameMetadata(current, record) && GetFile(id)!.AsSpan().SequenceEqual(file)) return current;
WriteRevision(id, record, file, current.Revision, null, transaction);
transaction.Commit();
return Get(id);
}
}
// Called only under _sync with the caller's transaction; promotion and snapshot are one write.
private void WriteRevision(Guid id, NestRecord record, byte[] file, int currentRevision,
int? sourceRevision, SqliteTransaction transaction)
{
using var command = _connection.CreateCommand();
command.Transaction = transaction;
const string fileClause = ", file = $file, fileSize = $fileSize";
command.CommandText = $"""
UPDATE nests SET UPDATE nests SET
name = $name, customer = $customer, dateCreated = $dateCreated, name = $name, customer = $customer, dateCreated = $dateCreated,
dateModified = $dateModified, material = $material, dateModified = $dateModified, material = $material,
thickness = $thickness, status = $status, plateCount = $plateCount, thickness = $thickness, status = $status, plateCount = $plateCount,
partCount = $partCount, comments = $comments, madeBy = $madeBy, partCount = $partCount, comments = $comments, madeBy = $madeBy,
savedAt = $savedAt{fileClause} savedAt = $savedAt, revision = $revision, restoredFromRevision=$source{fileClause}
WHERE id = $id WHERE id = $id
"""; """;
AddRecordParameters(command, id, record, file, updateFile: file is not null); AddRecordParameters(command, id, record, file, updateFile: file is not null);
command.ExecuteNonQuery(); command.Parameters.AddWithValue("$revision", checked(currentRevision + 1));
command.Parameters.AddWithValue("$source", (object?)sourceRevision ?? DBNull.Value);
command.ExecuteNonQuery();
Snapshot(id, transaction);
}
public NestRecord? Restore(Guid id, int sourceRevision, int expectedRevision)
{
if (sourceRevision < 1) throw new ArgumentOutOfRangeException(nameof(sourceRevision));
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
var current = Get(id);
if (current is null) return null;
if (current.Revision != expectedRevision) throw new RevisionConflictException(current);
NestRecord historical;
using (var command = _connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = $"SELECT {RecordColumns} FROM nest_revisions WHERE id=$id AND revision=$revision";
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$revision", sourceRevision);
using var reader = command.ExecuteReader();
if (!reader.Read()) return null;
historical = ReadRecord(reader);
}
// Restore is a deliberate new active revision, including when the bytes already match.
WriteRevision(id, historical, GetRevisionFile(id, sourceRevision)!, current.Revision, sourceRevision, transaction);
transaction.Commit();
return Get(id); return Get(id);
} }
} }
public bool Delete(Guid id) public NestRecord? SetDeleted(Guid id, bool deleted, int expectedRevision)
{
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
var current = Get(id, !deleted);
if (current is null) return null;
if (current.Revision != expectedRevision) throw new RevisionConflictException(current);
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "UPDATE nests SET deleted=$deleted, revision=$revision, savedAt=$savedAt, restoredFromRevision=NULL WHERE id=$id";
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0);
command.Parameters.AddWithValue("$revision", checked(current.Revision + 1));
command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o"));
command.ExecuteNonQuery();
Snapshot(id, transaction);
transaction.Commit();
return Get(id, deleted);
}
}
public NestPage? History(Guid id, int offset, int limit, bool deleted = false)
{
if (offset < 0 || limit is < 1 or > 200) throw new ArgumentOutOfRangeException(nameof(limit));
lock (_sync)
{
if (Get(id, deleted) is null) return null;
using var count = _connection.CreateCommand();
count.CommandText = "SELECT COUNT(*) FROM nest_revisions WHERE id=$id";
count.Parameters.AddWithValue("$id", id.ToString());
var total = checked((int)(long)count.ExecuteScalar()!);
using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {RecordColumns} FROM nest_revisions WHERE id=$id ORDER BY revision DESC LIMIT $limit OFFSET $offset";
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$limit", limit);
command.Parameters.AddWithValue("$offset", offset);
var records = new List<NestRecord>();
using var reader = command.ExecuteReader();
while (reader.Read()) records.Add(ReadRecord(reader));
return new NestPage { Items = records, Total = total, Offset = offset, Limit = limit };
}
}
public byte[]? GetRevisionFile(Guid id, int revision, bool deleted = false)
{ {
lock (_sync) lock (_sync)
{ {
using var command = _connection.CreateCommand(); using var command = _connection.CreateCommand();
command.CommandText = "DELETE FROM nests WHERE id = $id"; if (Get(id, deleted) is null) return null;
command.CommandText = "SELECT file FROM nest_revisions WHERE id=$id AND revision=$revision";
command.Parameters.AddWithValue("$id", id.ToString()); command.Parameters.AddWithValue("$id", id.ToString());
return command.ExecuteNonQuery() > 0; command.Parameters.AddWithValue("$revision", revision);
return command.ExecuteScalar() as byte[];
}
}
private static bool SameMetadata(NestRecord a, NestRecord b) =>
a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated &&
a.DateModified == b.DateModified && a.Material == b.Material && a.Thickness == b.Thickness &&
a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount &&
a.Comments == b.Comments && a.MadeBy == b.MadeBy;
private void Snapshot(Guid id, SqliteTransaction transaction)
{
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = $"INSERT INTO nest_revisions ({RecordColumns}, file) SELECT {RecordColumns}, file FROM nests WHERE id=$id";
command.Parameters.AddWithValue("$id", id.ToString());
command.ExecuteNonQuery();
}
private void Migrate()
{
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
if (version > 3) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
ALTER TABLE nests ADD COLUMN revision INTEGER NOT NULL DEFAULT 1;
CREATE TABLE nest_revisions AS SELECT * FROM nests WHERE 0;
CREATE UNIQUE INDEX ix_nest_revisions_key ON nest_revisions(id, revision);
INSERT INTO nest_revisions SELECT * FROM nests;
PRAGMA user_version=1;
""";
command.ExecuteNonQuery();
}
if (version < 2)
{
command.CommandText = """
ALTER TABLE nests ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
ALTER TABLE nest_revisions ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
PRAGMA user_version=2;
""";
command.ExecuteNonQuery();
}
if (version < 3)
{
command.CommandText = """
ALTER TABLE nests ADD COLUMN restoredFromRevision INTEGER;
ALTER TABLE nest_revisions ADD COLUMN restoredFromRevision INTEGER;
PRAGMA user_version=3;
""";
command.ExecuteNonQuery();
}
transaction.Commit();
} }
} }
@@ -217,13 +383,13 @@ public sealed class NestDatabase : IDisposable
private const string RecordColumns = """ private const string RecordColumns = """
id, name, customer, dateCreated, dateModified, material, thickness, id, name, customer, dateCreated, dateModified, material, thickness,
status, plateCount, partCount, comments, madeBy, fileSize, savedAt status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision, deleted, restoredFromRevision
"""; """;
// Case-insensitive (ASCII) substring over the text columns and the status's stored // Case-insensitive (ASCII) substring over the text columns and the status's stored
// and display names. The search text is always a bound, escaped LIKE parameter. // and display names. The search text is always a bound, escaped LIKE parameter.
private const string SearchPredicate = """ private const string SearchPredicate = """
WHERE name LIKE $pattern ESCAPE '\' name LIKE $pattern ESCAPE '\'
OR customer LIKE $pattern ESCAPE '\' OR customer LIKE $pattern ESCAPE '\'
OR material LIKE $pattern ESCAPE '\' OR material LIKE $pattern ESCAPE '\'
OR madeBy LIKE $pattern ESCAPE '\' OR madeBy LIKE $pattern ESCAPE '\'
@@ -303,6 +469,14 @@ public sealed class NestDatabase : IDisposable
MadeBy = reader.GetString(11), MadeBy = reader.GetString(11),
FileSize = reader.GetInt64(12), FileSize = reader.GetInt64(12),
SavedAt = DateTime.Parse(reader.GetString(13)), SavedAt = DateTime.Parse(reader.GetString(13)),
Revision = reader.GetInt32(14),
Deleted = reader.GetInt32(15) != 0,
RestoredFromRevision = reader.IsDBNull(16) ? null : reader.GetInt32(16),
}; };
} }
} }
public sealed class RevisionConflictException(NestRecord current) : Exception("The nest has changed. Reload or Save As.")
{
public NestRecord Current { get; } = current;
}
+102 -22
View File
@@ -62,41 +62,121 @@ app.MapPost("/api/nests", async (HttpContext http, NestDatabase db) =>
var id = record!.Id == Guid.Empty ? Guid.NewGuid() : record.Id; var id = record!.Id == Guid.Empty ? Guid.NewGuid() : record.Id;
return Results.Ok(db.Insert(id, record, file!)); return Results.Ok(db.Insert(id, record, file!));
}); });
app.MapGet("/api/nests/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
ReadHistory(id, request, db));
app.MapGet("/api/nests/{id:guid}/revisions/{revision:int}/file", (Guid id, int revision, NestDatabase db) =>
revision < 1 ? Results.BadRequest("Invalid revision.") :
db.GetRevisionFile(id, revision) is { } file ? Results.File(file, "application/zip", $"{id}-r{revision}.nest") : Results.NotFound());
app.MapPut("/api/nests/{id:guid}/file", async (Guid id, HttpContext http, NestDatabase db) => app.MapPut("/api/nests/{id:guid}/file", async (Guid id, HttpContext http, NestDatabase db) =>
{ {
var precondition = ReadPrecondition(http.Request, out var expected);
if (precondition is not null) return precondition;
var (record, file, error) = await ReadMultipart(http); var (record, file, error) = await ReadMultipart(http);
if (error is not null) if (error is not null) return error;
return error;
var updated = db.Update(id, record!, file);
return updated is null ? Results.NotFound() : Results.Ok(updated);
});
app.MapPut("/api/nests/{id:guid}/metadata", async (Guid id, HttpContext http, NestDatabase db) =>
{
NestRecord? record;
try try
{ {
record = await http.Request.ReadFromJsonAsync<NestRecord>(); var updated = db.Update(id, record!, file!, expected);
return updated is null ? Results.NotFound() : Results.Ok(updated);
} }
catch (JsonException) catch (RevisionConflictException conflict)
{ {
return Results.BadRequest("Invalid metadata JSON."); return Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
} }
if (record is null)
return Results.BadRequest("Missing metadata.");
// Keep the stored archive's size; metadata updates never touch the file.
var updated = db.Update(id, record, file: null);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}); });
app.MapDelete("/api/nests/{id:guid}", (Guid id, NestDatabase db) => app.MapPut("/api/nests/{id:guid}/metadata", async (Guid id, HttpRequest request, NestDatabase db) =>
db.Delete(id) ? Results.NoContent() : Results.NotFound()); {
var error = ReadPrecondition(request, out var expected);
if (error is not null) return error;
NestRecord? record;
try { record = await request.ReadFromJsonAsync<NestRecord>(); }
catch (Exception ex) when (ex is JsonException or InvalidOperationException or BadHttpRequestException)
{ return Results.BadRequest("Invalid metadata JSON."); }
if (record is null) return Results.BadRequest("Missing metadata.");
try
{
var updated = db.UpdateMetadata(id, record, expected);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
});
app.MapDelete("/api/nests/{id:guid}", (Guid id, HttpRequest request, NestDatabase db) =>
ChangeVisibility(id, true, request, db));
app.MapPost("/api/nests/deleted/{id:guid}/recover", (Guid id, HttpRequest request, NestDatabase db) =>
ChangeVisibility(id, false, request, db));
app.MapGet("/api/nests/deleted/query", (HttpRequest request, NestDatabase db) =>
NestQueryRequest.TryParse(request.Query, out var query, out var error)
? Results.Ok(db.Query(query, deleted: true)) : Results.BadRequest(error));
app.MapGet("/api/nests/deleted/{id:guid}", (Guid id, NestDatabase db) =>
db.Get(id, deleted: true) is { } record ? Results.Ok(record) : Results.NotFound());
app.MapGet("/api/nests/deleted/{id:guid}/file", (Guid id, NestDatabase db) =>
db.GetFile(id, deleted: true) is { } file ? Results.File(file, "application/zip") : Results.NotFound());
app.MapGet("/api/nests/deleted/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
ReadHistory(id, request, db, deleted: true));
app.MapGet("/api/nests/deleted/{id:guid}/revisions/{revision:int}/file", (Guid id, int revision, NestDatabase db) =>
revision < 1 ? Results.BadRequest("Invalid revision.") :
db.GetRevisionFile(id, revision, deleted: true) is { } file ? Results.File(file, "application/zip") : Results.NotFound());
app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, int revision, HttpRequest request, NestDatabase db) =>
{
var error = ReadPrecondition(request, out var expected);
if (error is not null) return error;
if (revision < 1) return Results.BadRequest("Invalid revision.");
try
{
var restored = db.Restore(id, revision, expected);
return restored is null ? Results.NotFound() : Results.Ok(restored);
}
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
});
app.Run(); app.Run();
static IResult? ReadPrecondition(HttpRequest request, out int revision)
{
revision = 0;
var values = request.Headers.IfMatch;
if (values.Count == 0) return Results.Json("Upgrade/reload required: send If-Match with the current revision.", statusCode: 428);
var text = values.Count == 1 ? values[0] : null;
if (text is null || text.Length < 3 || text[0] != '"' || text[^1] != '"' ||
!text[1..^1].All(c => c is >= '0' and <= '9') ||
!int.TryParse(text[1..^1], out revision) || revision < 1)
return Results.BadRequest("If-Match must contain one quoted positive integer revision.");
return null;
}
static IResult ChangeVisibility(Guid id, bool deleted, HttpRequest request, NestDatabase db)
{
var error = ReadPrecondition(request, out var expected);
if (error is not null) return error;
try
{
var changed = db.SetDeleted(id, deleted, expected);
return changed is null ? Results.NotFound() : Results.Ok(changed);
}
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
}
static IResult ReadHistory(Guid id, HttpRequest request, NestDatabase db, bool deleted = false)
{
var offset = 0;
var limit = 100;
foreach (var parameter in request.Query)
{
if (parameter.Value.Count != 1 || !int.TryParse(parameter.Value[0], out var value))
return Results.BadRequest("Invalid history pagination.");
switch (parameter.Key)
{
case "offset": offset = value; break;
case "limit": limit = value; break;
default: return Results.BadRequest("Unknown history parameter.");
}
}
if (offset < 0 || limit is < 1 or > 200) return Results.BadRequest("Invalid history pagination.");
return db.History(id, offset, limit, deleted) is { } page ? Results.Ok(page) : Results.NotFound();
}
// Shared multipart reader for upload endpoints. Returns a (record, file, error) triple. // Shared multipart reader for upload endpoints. Returns a (record, file, error) triple.
static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultipart(HttpContext http) static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultipart(HttpContext http)
{ {
+46 -1
View File
@@ -4,14 +4,59 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using OpenNest.Api; using OpenNest.Api;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO; using OpenNest.IO;
using OpenNest.Engine.Jobs;
namespace OpenNest.Tests.Api; namespace OpenNest.Tests.Api;
public class NestRunnerTests public class NestRunnerTests
{ {
[Fact]
public async Task ExplicitPricesSelectCheaperStockAndSurviveArchive()
{
var path = CreateTempSquareDxf(4, 4);
var archive = Path.ChangeExtension(path, ".nestquote");
try
{
var response = await NestRunner.RunAsync(new NestRequest
{
Engine = "Rectangles",
Parts = [new NestRequestPart { DxfPath = path, Quantity = 1 }],
Plates = [new NestRequestPlate { Id = "small", Size = new Size(10, 10), Quantity = 1, Cost = 50 },
new NestRequestPlate { Id = "large", Size = new Size(12, 12), Quantity = 1, Cost = 7 }],
});
Assert.Equal(NestValidationStatus.Valid, response.ValidationStatus);
Assert.Equal("large", Assert.Single(response.PlateStockMappings).StockId);
Assert.Equal("supplied-cost", response.Costs.Basis);
Assert.Equal(7, response.Costs.GrossTotal);
Assert.Equal(7, response.Costs.NetScore);
Assert.Equal(0, response.Costs.SalvageCredit);
await response.SaveAsync(archive);
var reopened = await NestResponse.LoadAsync(archive);
Assert.Equal(7, reopened.Costs.GrossTotal);
Assert.Equal(new[] { 50d, 7d }, reopened.Nest.PlateOptions.Select(p => p.Cost));
Assert.Equal(7, reopened.Request.Plates[1].Cost);
}
finally
{
File.Delete(path);
File.Delete(archive);
}
}
[Fact]
public async Task MixedCostsFailBeforeImport()
{
var error = await Assert.ThrowsAsync<ArgumentException>(() => NestRunner.RunAsync(new NestRequest
{
Parts = [new NestRequestPart { DxfPath = "missing.dxf", Quantity = 1 }],
Plates = [new NestRequestPlate { Id = "priced", Size = new Size(10, 10), Cost = 2 },
new NestRequestPlate { Id = "missing", Size = new Size(12, 12) }],
}));
Assert.Contains("Missing: missing", error.Message);
}
[Fact] [Fact]
public async Task RunAsync_LegacySheetSize_UsesUnlimitedLegacyStockAndDerivedPartId() public async Task RunAsync_LegacySheetSize_UsesUnlimitedLegacyStockAndDerivedPartId()
{ {
@@ -76,6 +76,70 @@ namespace OpenNest.Tests.CNC
Assert.Equal(new Vector(108, 204), segments[1].To); Assert.Equal(new Vector(108, 204), segments[1].To);
} }
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void Enumerate_ProgramStartingWithCut_RetainsFirstInternalRapid(Mode mode)
{
// A saved placed program can start with a lead-in cut rather than a G00.
// The first G00 then travels from that cutout to the next one.
var pgm = new Program(mode);
pgm.Codes.Add(new LinearMove(2, 0));
pgm.Codes.Add(new RapidMove(4, 1));
pgm.Codes.Add(new LinearMove(5, 1));
pgm.Codes.Add(new RapidMove(6, 2));
var segments = RapidEnumerator.Enumerate(
pgm,
basePos: new Vector(100, 200),
startPos: Vector.Zero
);
Assert.Equal(3, segments.Count);
Assert.Equal(new RapidEnumerator.Segment(Vector.Zero, new Vector(100, 200)), segments[0]);
Assert.Equal(new Vector(102, 200), segments[1].From);
Assert.Equal(mode == Mode.Absolute ? new Vector(104, 201) : new Vector(106, 201), segments[1].To);
Assert.Equal(mode == Mode.Absolute ? new Vector(105, 201) : new Vector(111, 202), segments[2].From);
}
[Fact]
public void Enumerate_EveryPartStartingWithCut_RetainsItsFirstRapid()
{
var program = new Program(Mode.Incremental);
program.Codes.Add(new LinearMove(1, 0));
program.Codes.Add(new RapidMove(1, 1));
program.Codes.Add(new LinearMove(0, 1));
var drawing = new Drawing("cut-first", program);
var parts = new[]
{
new Part(drawing, new Vector(10, 20)),
new Part(drawing, new Vector(30, 40)),
};
var segments = RapidEnumerator.Enumerate(parts);
Assert.Equal(4, segments.Count);
Assert.Equal(new RapidEnumerator.Segment(new Vector(11, 20), new Vector(12, 21)), segments[1]);
Assert.Equal(new RapidEnumerator.Segment(new Vector(31, 40), new Vector(32, 41)), segments[3]);
}
[Fact]
public void Enumerate_SubProgramStartingWithCut_RetainsFirstInternalRapid()
{
var hole = new Program(Mode.Incremental);
hole.Codes.Add(new LinearMove(1, 0));
hole.Codes.Add(new RapidMove(2, 1));
hole.Codes.Add(new LinearMove(0, 1));
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new SubProgramCall { Id = 1, Program = hole, Offset = new Vector(5, 5) });
var segments = RapidEnumerator.Enumerate(pgm, new Vector(100, 200), Vector.Zero);
Assert.Equal(2, segments.Count);
Assert.Equal(new RapidEnumerator.Segment(Vector.Zero, new Vector(105, 205)), segments[0]);
Assert.Equal(new RapidEnumerator.Segment(new Vector(106, 205), new Vector(108, 206)), segments[1]);
}
[Fact] [Fact]
public void Enumerate_SubProgramCall_RapidEndsAtAbsoluteHolePierce() public void Enumerate_SubProgramCall_RapidEndsAtAbsoluteHolePierce()
{ {
@@ -678,6 +678,23 @@ public class AutomaticCutOffPlannerTests
Assert.Equal(0, changes); Assert.Equal(0, changes);
} }
[Fact]
public void LargeArcRefit_DoesNotReplaceConvertedPerimeterWithUndersizedBox()
{
var plate = MakeArcPlate(0.0001);
var part = Assert.Single(plate.Parts);
var perimeter = Plate.BuildPerimeterCache(plate)[part];
Assert.NotNull(perimeter);
Assert.True(perimeter.BoundingBox.Right > part.BoundingBox.Right + CutOff.GetBoundsRoundoff(part));
var arc = Assert.Single(ConvertProgram.ToGeometry(part.Program).OfType<Arc>());
arc.Offset(part.Location);
var x = (part.BoundingBox.Right + arc.BoundingBox.Right) / 2;
var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical);
manual.Regenerate(plate, new CutOffSettings { PartClearance = 0 }, Plate.BuildPerimeterCache(plate));
foreach (var segment in Segments(manual.Drawing.Program))
Assert.False(arc.Intersects(new Line(segment.From, segment.To), out _));
}
[Fact] [Fact]
public void ExactArcWithinCachedBounds_PlansAndMaterializesSafeCuts() public void ExactArcWithinCachedBounds_PlansAndMaterializesSafeCuts()
{ {
@@ -66,6 +66,63 @@ public class CutOffRoundoffTests
Assert.Empty(AutomaticCutOffPlanner.Create(loadedPlate, options, settings).Definitions); Assert.Empty(AutomaticCutOffPlanner.Create(loadedPlate, options, settings).Definitions);
} }
[SkippableFact]
public void RoundedImportedArcs_PlanAllPlatesWithoutEditingSource()
{
var path = TestConfig.GetExistingPath("RoundedCutOffNestPath");
Skip.If(path == null, "Configure RoundedCutOffNestPath in ignored test-config.json.");
var hash = System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(path!));
var nest = new NestReader(path).Read();
var plates = nest.Plates.Where(p => p.Parts.Count > 0).ToArray();
Assert.Equal(2, plates.Length);
var parts = plates.SelectMany(p => p.Parts).ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var bounds = parts.Select(p => p.BoundingBox).ToArray();
var result = AutomaticCutOffBatch.Create(plates,
new AutomaticCutOffOptions { Spacing = 4, MinimumTailLength = 12 }, new CutOffSettings());
Assert.Equal(plates.Length, result.Count);
Assert.All(result, p => Assert.False(p.HasBlockingDiagnostics));
Assert.All(result, p => Assert.NotEmpty(p.Definitions));
var defaultSpacing = AutomaticCutOffBatch.Create(plates,
new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 }, new CutOffSettings());
Assert.All(defaultSpacing, p => Assert.False(p.HasBlockingDiagnostics));
Assert.All(defaultSpacing, p => Assert.NotEmpty(p.Definitions));
// Probe a separator in the tiny converted-arc protrusion: both cached and
// uncached regeneration must leave the original curve untouched.
var witness = plates[0].Parts.SelectMany(part => ConvertProgram.ToGeometry(part.Program)
.OfType<Arc>().Where(a => SpecialLayers.IsMaterial(a.Layer))
.Select(a => (part, arc: a, top: a.BoundingBox.Translate(part.Location).Top)))
.First(w => w.top > w.part.BoundingBox.Top + 8 *
(System.Math.BitIncrement(w.part.BoundingBox.Top) - w.part.BoundingBox.Top));
witness.arc.Offset(witness.part.Location);
var y = (witness.part.BoundingBox.Top + witness.top) / 2;
foreach (var useCache in new[] { false, true })
{
var cut = new CutOff(new Vector(0, y), CutOffAxis.Horizontal);
cut.Regenerate(plates[0], new CutOffSettings { PartClearance = 0 },
useCache ? Plate.BuildPerimeterCache(plates[0]) : null);
foreach (var line in ConvertProgram.ToGeometry(cut.Drawing.Program).OfType<Line>()
.Where(e => SpecialLayers.IsMaterial(e.Layer)))
Assert.False(witness.arc.Intersects(line, out _), $"useCache={useCache} y={y:R} segment={line}");
}
Assert.Equal(programs, parts.Select(p => p.Program));
Assert.Equal(bounds, parts.Select(p => p.BoundingBox));
Assert.All(plates, p => Assert.Empty(p.CutOffs));
var applied = AutomaticCutOffBatch.Apply(plates,
new AutomaticCutOffOptions { Spacing = 4, MinimumTailLength = 12 }, new CutOffSettings());
Assert.Equal(result.Select(p => p.Definitions.Count), applied.Select(p => p.Definitions.Count));
Assert.All(plates, p => Assert.NotEmpty(p.CutOffs));
Assert.Equal(programs, parts.Select(p => p.Program));
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
var reloaded = new NestReader(stream).Read();
var repeated = AutomaticCutOffBatch.Create(reloaded.Plates.Where(p => p.Parts.Count > 0).ToArray(),
new AutomaticCutOffOptions { Spacing = 4, MinimumTailLength = 12 }, new CutOffSettings());
Assert.All(repeated, p => Assert.Empty(p.Definitions));
Assert.Equal(hash, System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(path!)));
}
[Theory] [Theory]
[InlineData(CutOffAxis.Vertical, false, 0)] [InlineData(CutOffAxis.Vertical, false, 0)]
[InlineData(CutOffAxis.Vertical, true, 0)] [InlineData(CutOffAxis.Vertical, true, 0)]
@@ -310,6 +310,36 @@ public class ContourEntryCandidateTests
Assert.All(near, c => Assert.True(c.Kind > AutomaticEntryKind.TangentJoint)); Assert.All(near, c => Assert.True(c.Kind > AutomaticEntryKind.TangentJoint));
} }
[Fact]
public void NearCornerOnRoundedJointStaysOnItsSelectedLine()
{
// Native contour joints can differ within the accepted closure tolerance. The
// preceding edge's corner is off the following edge by one micron, while the
// near-corner entry must lie on the following edge itself.
var shape = new Shape();
shape.Entities.Add(new Line(0, 0, 10, 0));
shape.Entities.Add(new Line(10, 0, 10, 10));
shape.Entities.Add(new Line(10, 10.000001, 0, 10.000001));
shape.Entities.Add(new Line(0, 10.000001, 0, 0));
shape.UpdateBounds();
// Capture constructs this exact owned native shape after program conversion;
// build it directly to characterize its legal, sub-tolerance joint discrepancy.
var constructor = typeof(PreparedContours).GetConstructor(
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
null, [typeof(Shape[]), typeof(List<Entity>), typeof(OpenNest.CNC.CuttingStrategy.CuttingParameters)], null);
Assert.NotNull(constructor);
var prepared = (PreparedContours)constructor.Invoke([
new[] { shape }, new List<Entity>(), ExplicitContourTests.Parameters()]);
var near = prepared.AutomaticEntryCandidatesWithFallbacks(0)
.Where(c => c.Kind == AutomaticEntryKind.NearCorner).ToList();
Assert.NotEmpty(near);
Assert.All(near, c => Assert.Equal(c.Choice.Point,
prepared.Entry(c.Choice.ContourOrdinal, c.Choice.EntityOrdinal, c.Choice.Point).Point));
Assert.Contains(near, c => c.Choice.EntityOrdinal == 2 &&
System.Math.Abs(c.Choice.Point.Y - 10.000001) < 1e-10);
Assert.Throws<ArgumentException>(() => prepared.Entry(0, 2, new Vector(9.4, 10)));
}
[Fact] [Fact]
public void ShortEdge_OmitsNearCornerInsteadOfExtrapolating() public void ShortEdge_OmitsNearCornerInsteadOfExtrapolating()
{ {
@@ -126,7 +126,7 @@ public class ContourEntryFeasibilityTests
// Nothing to certify means the validator passes vacuously — the emitted program // Nothing to certify means the validator passes vacuously — the emitted program
// carries no lead motion at all, so this verdict certifies no lead and the // carries no lead motion at all, so this verdict certifies no lead and the
// complete-plan missing-lead check still has to run later. The adapter must not // complete-plan rapid/contour checks still have to run later. The adapter must not
// silently drop the distinction: consumers can see it is vacuous by counting leads. // silently drop the distinction: consumers can see it is vacuous by counting leads.
Assert.True(verdict.IsClear); Assert.True(verdict.IsClear);
var motions = ExecutionMotionReader.Read(prepared.Emit(new[] { choice }), At, null, default).Motions; var motions = ExecutionMotionReader.Read(prepared.Emit(new[] { choice }), At, null, default).Motions;
@@ -0,0 +1,78 @@
using System.Security.Cryptography;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.IO;
namespace OpenNest.Tests.CuttingPlanning;
public class ContourRecoveryFileTests
{
// Optional private input: never embed customer geometry or names in the repository.
[SkippableTheory]
[InlineData(false)]
[InlineData(true)]
public void InterlockedPlateFinishesThroughDesktopBatchWithoutChangingInput(bool lineLeads)
{
var path = TestConfig.GetExistingPath("InterlockedCuttingNestPath");
Skip.If(path == null, "Configure InterlockedCuttingNestPath in ignored test-config.json.");
var bytes = SHA256.HashData(File.ReadAllBytes(path!));
var nest = new NestReader(path).Read();
var plates = nest.Plates.Where(p => p.Parts.Count > 0).ToArray();
Assert.NotEmpty(plates);
var parts = plates.SelectMany(p => p.Parts).ToArray();
var programs = parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray();
var poses = parts.Select(p => (p.Location, p.Rotation, p.LeadInsLocked)).ToArray();
var parameters = Parameters(lineLeads);
var proposal = CuttingPlanBatch.Capture(plates, parameters, preservePartOrder: false).Plan();
Assert.True(proposal.CanApply, string.Join(Environment.NewLine, proposal.Describe("in")));
Assert.False(proposal.RequiresWarningAcceptance);
Assert.Equal(plates.Length, proposal.Plates.Count);
foreach (var planned in proposal.Plates)
{
Assert.True(planned.IsOverlapClear);
Assert.True(planned.Result.IndependentlyReplayed);
Assert.Equal(CuttingPlanStatus.Ready, planned.Result.Status);
Assert.InRange(planned.Result.Expansions, 1, CuttingPlanBatch.PlateBudget(planned.Plate.Parts.Count));
Assert.Equal(planned.Plate.Parts.Count, planned.PartCount);
var snapshot = planned.Result.Snapshot;
foreach (var part in planned.Result.ProposedOrder)
{
Assert.Equal(snapshot.Placements[part.SourceOrdinal].Prepared.Count, part.ContourChoices.Count);
Assert.Equal(part.ContourChoices.Count, part.ContourChoices.Select(c => c.ContourOrdinal).Distinct().Count());
}
var replay = CuttingPlanService.ReplayPrograms(snapshot, planned.Result.ProposedOrder, 0, default);
Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
Assert.True(replay.IndependentlyReplayed);
Assert.Empty(replay.Findings);
var bounded = CuttingPlanService.Plan(snapshot.WithSearchLimits(1, 16));
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status);
Assert.Equal(1, bounded.Expansions);
Assert.Empty(bounded.ProposedOrder);
Assert.False(bounded.IndependentlyReplayed);
}
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var cancelled = CuttingPlanBatch.Capture(plates, parameters, false).Plan(token: cancellation.Token);
Assert.True(cancelled.IsCancelled);
Assert.False(cancelled.CanApplyWithWarnings);
Assert.Equal(parts, plates.SelectMany(p => p.Parts));
Assert.Equal(programs, parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)));
Assert.Equal(poses, parts.Select(p => (p.Location, p.Rotation, p.LeadInsLocked)));
Assert.Equal(bytes, SHA256.HashData(File.ReadAllBytes(path)));
}
private static CuttingParameters Parameters(bool lineLeads)
{
var parameters = new CuttingParameters();
if (lineLeads)
{
parameters.ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
parameters.InternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
parameters.ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
}
return parameters;
}
}
@@ -45,7 +45,9 @@ public class CuttingPlanBatchTests
var nest = new Nest(); var nest = new Nest();
var ready = Plate(nest, Clean("open", 1, 1)); var ready = Plate(nest, Clean("open", 1, 1));
var locked = Clean("locked", 1, 1); var locked = Clean("locked", 1, 1);
locked.LeadInsLocked = true; // Locked programs never regenerate: no lead-in is a conflict. var crossing = OwnedProgramCopy.Copy(locked.Program);
crossing.MoveTo(5, 5); // Rapid enters the already completed square.
Assert.True(locked.RestoreLeadInProgram(crossing, true));
var blocked = Plate(nest, locked); var blocked = Plate(nest, locked);
var readyProgram = ready.Parts[0].Program; var readyProgram = ready.Parts[0].Program;
@@ -90,6 +92,10 @@ public class CuttingPlanBatchTests
var text = string.Join("\n", proposal.Describe("in")); var text = string.Join("\n", proposal.Describe("in"));
Assert.Contains("Plate 1: blocked: parts overlap or could not be checked for overlap.", text); Assert.Contains("Plate 1: blocked: parts overlap or could not be checked for overlap.", text);
Assert.Contains("- Part 1 (first) overlaps part 2 (second).", text); Assert.Contains("- Part 1 (first) overlaps part 2 (second).", text);
var row = Assert.Single(proposal.DiagnosticRows());
Assert.Equal((0, 1, 1, 2),
(row.PlateIndex, row.PlateNumber, row.PartNumber, row.OtherPartNumber));
Assert.Contains("overlaps part 2", row.Message);
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status); Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
Assert.Equal(programs, plate.Parts.Select(p => p.Program)); Assert.Equal(programs, plate.Parts.Select(p => p.Program));
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns)); Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
@@ -144,6 +150,63 @@ public class CuttingPlanBatchTests
Assert.Null(proposal.BuildPreview(0)); Assert.Null(proposal.BuildPreview(0));
} }
[Fact]
public void PlanReorder_ShortPassReady_RetainsReplayedApplicableProposal()
{
var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1));
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
confirmedParameters: ExplicitContourTests.Parameters()));
var full = CuttingPlanService.Plan(snapshot);
var result = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None);
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
Assert.True(result.Expansions < full.Expansions,
$"Short pass {result.Expansions}, full {full.Expansions}");
Assert.InRange(result.Expansions, 1, CuttingPlanBatch.FirstPassBudget - 1);
Assert.Equal(2, result.ProposedOrder.Count);
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
}
[Fact]
public void PlanReorder_ShortPassExhausted_UsesFullCapAndOriginalBudget()
{
var plate = Plate(new Nest(), Grid(4));
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
confirmedParameters: ExplicitContourTests.Parameters()));
var full = CuttingPlanService.Plan(snapshot);
var recovered = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None, firstPassBudget: 1);
Assert.Equal(CuttingPlanStatus.Ready, full.Status);
Assert.Equal(CuttingPlanStatus.Ready, recovered.Status);
Assert.True(recovered.IndependentlyReplayed);
Assert.Equal(full.Expansions, recovered.Expansions);
Assert.Equal(full.RapidDistance, recovered.RapidDistance);
Assert.Equal(full.ProposedOrder.Select(p => p.SourceOrdinal), recovered.ProposedOrder.Select(p => p.SourceOrdinal));
for (var i = 0; i < full.ProposedOrder.Count; i++)
Assert.True(ProgramContent.Equal(full.ProposedOrder[i].CopyProgram(), recovered.ProposedOrder[i].CopyProgram()));
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
}
[Fact]
public void PlanReorder_CancelledDuringShortPass_DoesNotRetryOrMutate()
{
var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1));
using var cancellation = new CancellationTokenSource();
var observations = 0;
var request = new CuttingPlanRequest(plate.Parts, confirmedParameters: ExplicitContourTests.Parameters())
{ ExpansionObserver = _ => { observations++; cancellation.Cancel(); } };
var snapshot = CuttingPlanService.Capture(request);
var result = CuttingPlanBatch.PlanReorder(snapshot, cancellation.Token);
Assert.Equal(CuttingPlanStatus.Cancelled, result.Status);
Assert.Equal(1, observations);
Assert.Empty(result.ProposedOrder);
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
}
[Fact] [Fact]
public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo() public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo()
{ {
@@ -253,7 +316,9 @@ public class CuttingPlanBatchTests
var nest = new Nest(); var nest = new Nest();
var ready = Plate(nest, Clean("a", 1, 1)); var ready = Plate(nest, Clean("a", 1, 1));
var locked = Clean("locked", 1, 1); var locked = Clean("locked", 1, 1);
locked.LeadInsLocked = true; var crossing = OwnedProgramCopy.Copy(locked.Program);
crossing.MoveTo(5, 5); // Rapid enters the already completed square.
Assert.True(locked.RestoreLeadInProgram(crossing, true));
var blocked = Plate(nest, locked); var blocked = Plate(nest, locked);
var proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(), false).Plan(); var proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(), false).Plan();
Assert.True(proposal.Plates[0].IsReady); Assert.True(proposal.Plates[0].IsReady);
@@ -8,77 +8,91 @@ namespace OpenNest.Tests.CuttingPlanning;
public class CuttingPlanMessageTests public class CuttingPlanMessageTests
{ {
[Fact]
public void Describe_MixedBestEffortAndOpenContour_CountsOnlyBlockedPlateAndExplainsGap()
{
var open = new OpenNest.CNC.Program();
open.MoveTo(0, 0);
open.LineTo(10, 0);
open.LineTo(10, 10);
open.LineTo(0, 10);
open.LineTo(0, 0.000002);
var part = new Part(new Drawing("open perimeter", open));
var original = OwnedProgramCopy.Copy(part.BaseDrawing.Program);
var plates = new[]
{
BestEffortCuttingPlanTests.Plate(BestEffortCuttingPlanTests.TouchingContours()),
BestEffortCuttingPlanTests.Plate(part),
};
var proposal = CuttingPlanBatch.Capture(plates, ExplicitContourTests.Parameters(), false).Plan();
var text = string.Join("\n", proposal.Describe("in"));
Assert.StartsWith("Apply is unavailable: 1 of 2 plates could not be planned.", text);
Assert.Contains("Plate 1: best-effort, unverified.", text);
Assert.Contains("Plate 2: blocked:", text);
Assert.Contains("contour 1 is open: endpoint gap 2E-06 model units", text);
Assert.Contains("closure tolerance 1E-06", text);
Assert.Contains("Review the source contour in the drawing editor", text);
Assert.False(proposal.CanApplyWithWarnings);
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(true).Status);
Assert.True(ProgramContent.Equal(original, part.BaseDrawing.Program));
Assert.All(plates.SelectMany(p => p.Parts), p => Assert.False(p.HasManualLeadIns));
}
[Theory] [Theory]
[InlineData(false, false)] [InlineData(false, false)]
[InlineData(false, true)] [InlineData(false, true)]
[InlineData(true, false)] [InlineData(true, false)]
[InlineData(true, true)] [InlineData(true, true)]
public void Describe_MissingOrZeroLengthLead_ExplainsSettingsWithoutBlamingSearch(bool keepOrder, bool zeroLength) public void Describe_NoneIsSupportedButDegenerateLeadMotionStillRefuses(bool keepOrder, bool zeroLength)
{ {
var part = new Part(new Drawing("sample", ExplicitContourTests.Square(false)), new Vector(1, 1)); var part = new Part(new Drawing("sample", ExplicitContourTests.Square(false)), new Vector(1, 1));
var plate = new Nest().CreatePlate(); var plate = BestEffortCuttingPlanTests.Plate(part);
plate.Size = new Size(100, 100); var original = OwnedProgramCopy.Copy(part.Program);
plate.Parts.Add(part);
var program = part.Program;
var original = OwnedProgramCopy.Copy(program);
var settings = new CuttingParameters(); var settings = new CuttingParameters();
if (zeroLength) if (zeroLength)
settings.ExternalLeadIn = new LineLeadIn { Length = 0, ApproachAngle = 90 }; settings.ExternalLeadIn = new LineLeadIn { Length = 0, ApproachAngle = 90 };
var proposal = CuttingPlanBatch.Capture([plate], settings, keepOrder).Plan(); var proposal = CuttingPlanBatch.Capture([plate], settings, keepOrder).Plan();
Assert.False(proposal.CanApply); if (zeroLength)
var result = Assert.Single(proposal.Plates).Result; {
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status); Assert.False(proposal.CanApply);
Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.MissingLeadIn); Assert.Contains("Lead motion is missing, degenerate or inconsistent", string.Join("\n", proposal.Describe("in")));
var text = string.Join("\n", proposal.Describe("in")); Assert.True(ProgramContent.Equal(original, part.Program));
Assert.Contains("Plate 1: blocked: missing or zero-length lead-in.", text); return;
Assert.Contains("Open Cutting Settings...", text); }
Assert.Contains("other than None", text);
Assert.Contains("nonzero length", text);
Assert.Contains("Part 1 (sample):", text);
Assert.Contains("Cutting contour 1", text);
Assert.DoesNotContain("search limit", text);
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
Assert.Same(program, part.Program);
Assert.True(ProgramContent.Equal(original, part.Program));
Assert.Null(plate.CuttingParameters);
Assert.False(part.HasManualLeadIns);
// Choosing valid settings fixes the refusal; reporting never changes settings or bypasses checks. Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
var ready = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), keepOrder).Plan(); var result = Assert.Single(proposal.Plates).Result;
Assert.True(ready.CanApply, string.Join("\n", ready.Describe("in"))); Assert.True(result.IndependentlyReplayed);
Assert.True(Assert.Single(ready.Plates).Result.IndependentlyReplayed); Assert.DoesNotContain(result.Findings, f => f.Kind == PostVerificationKind.MissingLeadIn);
Assert.DoesNotContain("missing or zero-length", string.Join("\n", ready.Describe("in"))); Assert.Contains("Plate 1: ready.", string.Join("\n", proposal.Describe("in")));
Assert.Same(program, part.Program); var output = Assert.Single(result.ProposedOrder).CopyProgram();
Assert.DoesNotContain(ExecutionMotionReader.ReadSupported(output, part.Location, null).Motions,
m => m.Layer == OpenNest.CNC.LayerType.Leadin && m.Length > 0);
Assert.True(ProgramContent.Equal(original, part.Program)); Assert.True(ProgramContent.Equal(original, part.Program));
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status);
} }
[Fact] [Fact]
public void Describe_LockedProgramWithoutLead_ExplainsThatSettingsCannotRegenerateIt() public void Describe_LockedProgramWithoutLead_IsReadyAndKeepsItsProgram()
{ {
var part = new Part(new Drawing("locked sample", ExplicitContourTests.Square(false)), new Vector(1, 1)) var part = new Part(new Drawing("locked sample", ExplicitContourTests.Square(false)), new Vector(1, 1))
{ {
LeadInsLocked = true, LeadInsLocked = true,
}; };
var plate = new Nest().CreatePlate(); var plate = BestEffortCuttingPlanTests.Plate(part);
plate.Size = new Size(100, 100);
plate.Parts.Add(part);
var program = part.Program; var program = part.Program;
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan(); var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
Assert.Equal(CuttingPlanStatus.ConstraintConflict, Assert.Single(proposal.Plates).Result.Status); Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
Assert.False(proposal.CanApply); Assert.False(Assert.Single(proposal.Plates[0].Result.ProposedOrder).IsRegenerated);
var text = string.Join("\n", proposal.Describe("in")); Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status);
Assert.Contains("missing or zero-length lead-in", text);
Assert.Contains("If the affected part is locked, edit its lead-ins or unlock it before replanning.", text);
Assert.Contains("Part 1 (locked sample):", text);
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
Assert.True(part.LeadInsLocked); Assert.True(part.LeadInsLocked);
Assert.Same(program, part.Program); Assert.Same(program, part.Program);
} }
[Fact] [Fact]
public void Describe_LeadHitsNeighbour_SuggestsSpacingOrShorterLeadWithoutAllowingApply() public void Describe_LeadHitsNeighbour_SuggestsSpacingOrShorterLeadWithoutAllowingApply()
{ {
@@ -126,9 +126,24 @@ public class CuttingPlanServiceTests
Assert.Equal(beforeCodes, program.Codes?.ToArray()); Assert.Equal(beforeCodes, program.Codes?.ToArray());
} }
[Fact]
public void FixedProgramWithoutLead_IsReadyAndReplayed()
{
var part = Fixture()[0];
part.Program.Codes.RemoveAt(1);
((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
var unchanged = Unchanged([part]);
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
Assert.False(Assert.Single(result.ProposedOrder).IsRegenerated);
unchanged();
}
[Theory] [Theory]
[InlineData("suppressed")] [InlineData("suppressed")]
[InlineData("missing-lead")]
[InlineData("retention-unknown")] [InlineData("retention-unknown")]
[InlineData("cutoff")] [InlineData("cutoff")]
public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault) public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault)
@@ -138,10 +153,6 @@ public class CuttingPlanServiceTests
switch (fault) switch (fault)
{ {
case "suppressed": ((Motion)part.Program.Codes[2]).Suppressed = true; break; case "suppressed": ((Motion)part.Program.Codes[2]).Suppressed = true; break;
case "missing-lead":
part.Program.Codes.RemoveAt(1);
((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
expected = CuttingPlanStatus.ConstraintConflict; break;
case "retention-unknown": ((LinearMove)part.Program.Codes[^2]).EndPoint = new Vector(4, 2.25); break; case "retention-unknown": ((LinearMove)part.Program.Codes[^2]).EndPoint = new Vector(4, 2.25); break;
case "cutoff": part.BaseDrawing.IsCutOff = true; break; case "cutoff": part.BaseDrawing.IsCutOff = true; break;
} }
@@ -0,0 +1,182 @@
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingProposalInspectionTests
{
[Fact]
public async Task ReadyProposal_UsesDetachedExactProgramsAndMappingWithoutLiveEvents()
{
var plate = Plate();
plate.Quantity = 3;
var parts = plate.Parts.ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var nested = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
var events = 0;
plate.PartAdded += (_, _) => events++;
plate.PartRemoved += (_, _) => events++;
plate.PartsReordered += (_, _) => events++;
var inspection = new CuttingProposalInspection();
var work = inspection.BeginPlanning(Capture(plate));
await work;
Assert.True(inspection.IsRunning); // Owner callback, not task completion, releases ownership.
inspection.CompletePlanning(work);
var proposal = inspection.Proposal;
Assert.True(proposal.CanApply);
var preview = Assert.IsType<Plate>(inspection.Preview);
Assert.Same(preview, inspection.RefreshPreview());
Assert.Equal(0, preview.Quantity);
Assert.Equal(new[] { 1, 0 }, proposal.Plates[0].Result.ProposedOrder.Select(p => p.SourceOrdinal));
Assert.Equal(1, CuttingProposalInspection.ProposedPosition(proposal, 0, 1));
Assert.Equal(0, CuttingProposalInspection.ProposedPosition(proposal, 0, 2));
Assert.Null(CuttingProposalInspection.ProposedPosition(proposal, 0, 3));
Assert.Null(CuttingProposalInspection.ProposedPosition(proposal, -1, 1));
for (var i = 0; i < preview.Parts.Count; i++)
{
Assert.DoesNotContain(preview.Parts[i], parts);
Assert.True(ProgramContent.Equal(proposal.Plates[0].Result.ProposedOrder[i].CopyProgram(), preview.Parts[i].Program));
}
Assert.True(inspection.RequestClose());
Assert.Null(inspection.Preview);
Assert.Null(inspection.Proposal);
Assert.Equal(parts, plate.Parts);
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
Assert.Equal(nested, parts.Select(p => p.BaseDrawing.Quantity.Nested));
Assert.Equal(3, plate.Quantity);
Assert.Equal(0, events);
Assert.Null(plate.CuttingParameters);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task StaleBeforeCompletionOrAfterDisplay_RefusesPreview(bool beforeCompletion)
{
var plate = Plate();
var inspection = new CuttingProposalInspection();
var work = inspection.BeginPlanning(Capture(plate));
await work;
if (beforeCompletion)
plate.Quantity++;
inspection.CompletePlanning(work);
if (!beforeCompletion)
{
Assert.NotNull(inspection.Preview);
plate.Parts[0].Offset(0, 1);
}
Assert.Null(inspection.RefreshPreview());
Assert.Null(inspection.Preview);
Assert.NotEmpty(inspection.Proposal.Describe("in"));
Assert.All(plate.Parts, p => Assert.False(p.HasManualLeadIns));
Assert.True(inspection.RequestClose());
}
[Fact]
public async Task UnsupportedGraph_RefusesPreviewAndNeverInvokesForeignClone()
{
var plate = Plate();
var foreign = new ForeignProgram();
plate.Parts[0].Program.Codes.Add(foreign);
var inspection = new CuttingProposalInspection();
var work = inspection.BeginPlanning(Capture(plate));
await work;
inspection.CompletePlanning(work);
Assert.False(inspection.Proposal.CanApply);
Assert.Null(inspection.RefreshPreview());
Assert.Equal(0, foreign.Clones);
Assert.NotEmpty(inspection.Proposal.DiagnosticRows());
Assert.True(inspection.RequestClose());
}
[Fact]
public async Task CloseDuringWorker_RetainsOwnershipUntilOwnerCompletionAndRejectsLateResult()
{
var plate = Plate();
var programs = plate.Parts.Select(p => p.Program).ToArray();
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var release = new ManualResetEventSlim();
var inspection = new CuttingProposalInspection();
var work = inspection.BeginPlanning(Capture(plate), token =>
{
entered.SetResult();
Assert.True(release.Wait(TimeSpan.FromSeconds(30)));
Assert.True(token.IsCancellationRequested);
});
try
{
await entered.Task.WaitAsync(TimeSpan.FromSeconds(30));
Assert.False(inspection.RequestClose());
Assert.True(inspection.IsRunning);
Assert.Throws<InvalidOperationException>(() => inspection.CompletePlanning(work));
Assert.True(inspection.IsRunning);
await Assert.ThrowsAsync<InvalidOperationException>(async () => await inspection.BeginPlanning(Capture(plate)));
}
finally
{
release.Set();
}
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await work);
Assert.True(inspection.IsRunning);
inspection.CompletePlanning(work);
Assert.False(inspection.IsRunning);
Assert.True(inspection.RequestClose());
Assert.Null(inspection.Proposal);
Assert.Null(inspection.Preview);
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
}
[Fact]
public async Task FaultedWorker_OffersNoPreviewButCanBeClosed()
{
var inspection = new CuttingProposalInspection();
var work = inspection.BeginPlanning(Capture(Plate()), _ => throw new InvalidOperationException("synthetic"));
await Assert.ThrowsAsync<InvalidOperationException>(async () => await work);
inspection.CompletePlanning(work);
Assert.False(inspection.IsRunning);
Assert.Null(inspection.Proposal);
Assert.True(inspection.RequestClose());
}
[Fact]
public async Task BatchPreviewNavigatesToSecondPlateAndRejectsItsStaleState()
{
var first = Plate();
var second = Plate();
second.Parts[0].Offset(20, 0);
var inspection = new CuttingProposalInspection();
var work = inspection.BeginPlanning(CuttingPlanBatch.Capture([first, second], ExplicitContourTests.Parameters(), false));
await work;
inspection.CompletePlanning(work);
var preview = inspection.RefreshPreview(1);
Assert.NotNull(preview);
Assert.Equal(second.Parts[1].Location, preview.Parts[0].Location);
Assert.NotSame(preview, inspection.RefreshPreview(0));
second.Quantity++;
Assert.Null(inspection.RefreshPreview(1));
Assert.NotNull(inspection.RefreshPreview(0));
inspection.RequestClose();
}
private static CuttingPlanBatch Capture(Plate plate) =>
CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false);
private static Plate Plate()
{
var plate = new Plate(100, 100);
plate.Parts.Add(new Part(new Drawing("far", ExplicitContourTests.Square(false)), new Vector(12, 1)));
plate.Parts.Add(new Part(new Drawing("near", ExplicitContourTests.Square(false)), new Vector(1, 1)));
return plate;
}
private sealed class ForeignProgram : OpenNest.CNC.LinearMove
{
public int Clones { get; private set; }
public override OpenNest.CNC.ICode Clone()
{
Clones++;
throw new InvalidOperationException("Unsupported code must never be cloned.");
}
}
}
@@ -55,6 +55,12 @@ public class HoleEndpointBudgetTests
Assert.Contains(checker.Check(snapshot.Placements[1].Execution, execution.DeparturePoint, 2), Assert.Contains(checker.Check(snapshot.Placements[1].Execution, execution.DeparturePoint, 2),
f => f.Kind == PostVerificationKind.RapidCrossing); f => f.Kind == PostVerificationKind.RapidCrossing);
var measured = JointCuttingPlanSearch.Run(snapshot, default);
// The untouched baseline spent 63 prechecks in both modes because it evaluated
// alternates even in the single-chain pass. The fallback remains available.
Assert.Equal(CuttingPlanStatus.Ready, measured.Status);
Assert.True(measured.LeadPrechecks < 63,
$"Expected fewer prechecks than the baseline's 63; got {measured.LeadPrechecks}.");
var result = CuttingPlanService.Plan(snapshot); var result = CuttingPlanService.Plan(snapshot);
Assert.True(result.Status == CuttingPlanStatus.Ready, Assert.True(result.Status == CuttingPlanStatus.Ready,
@@ -0,0 +1,86 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class InterlockingHoleRouteTests
{
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void InterlockingHoledPartsPlanWithinDefaultBudget(bool leads, bool keep)
{
var a = Polygon([new(2.5, 2), new(2.5, 4), new(30, 4), new(30, 10),
new(2.5, 10), new(2.5, 12), new(32, 12), new(32, 2)]);
var b = Polygon([new(0, 4.5), new(0, 14.5), new(20, 14.5), new(20, 12.5),
new(2, 12.5), new(2, 6.5), new(20, 6.5), new(20, 4.5)]);
foreach (var x in new[] { 3.5, 12, 21, 31 })
foreach (var y in new[] { 3.0, 11.0 }) Hole(a, x, y);
foreach (var x in new[] { 1.0, 7, 13, 19 })
foreach (var y in new[] { 5.5, 13.5 }) Hole(b, x, y);
var parts = new[] { new Part(new Drawing("left-open bracket", a)), new Part(new Drawing("right-open bracket", b)) };
var parameters = new CuttingParameters();
if (leads)
{
parameters.ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
parameters.InternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
parameters.ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
}
var before = parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts,
confirmedParameters: parameters, preservePartOrder: keep));
var result = CuttingPlanService.Plan(snapshot);
Assert.True(result.Status == CuttingPlanStatus.Ready,
$"{result.Status}: {result.Expansions} " + string.Join(";", result.Findings.Select(f => f.Message)));
Assert.True(result.IndependentlyReplayed);
Assert.Equal(2, result.ProposedOrder.Count);
Assert.All(result.ProposedOrder, p => Assert.Equal(9, p.ContourChoices.Count));
var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default);
Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
Assert.True(replay.IndependentlyReplayed);
Assert.Equal(before, parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)));
Assert.InRange(result.Expansions, 1, 20000);
if (keep)
Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
var repeated = CuttingPlanService.Plan(snapshot);
Assert.Equal(CuttingPlanStatus.Ready, repeated.Status);
Assert.Equal(result.Expansions, repeated.Expansions);
Assert.Equal(result.ProposedOrder.Select(p => ExplicitContourTests.Fingerprint(p.CopyProgram())),
repeated.ProposedOrder.Select(p => ExplicitContourTests.Fingerprint(p.CopyProgram())));
var bounded = CuttingPlanService.Plan(snapshot.WithSearchLimits(1, 16));
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status);
Assert.Equal(1, bounded.Expansions);
Assert.Empty(bounded.ProposedOrder);
using var cancellation = new CancellationTokenSource();
var cancelAt = result.Expansions - 1;
var cancelled = CuttingPlanService.Plan(new CuttingPlanRequest(parts,
confirmedParameters: parameters, preservePartOrder: keep)
{
ExpansionObserver = n => { if (n == cancelAt) cancellation.Cancel(); }
}, cancellation.Token);
Assert.Equal(CuttingPlanStatus.Cancelled, cancelled.Status);
Assert.Equal(cancelAt, cancelled.Expansions);
Assert.Empty(cancelled.ProposedOrder);
Assert.False(cancelled.IndependentlyReplayed);
Assert.Equal(before, parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)));
}
private static OpenNest.CNC.Program Polygon(Vector[] vertices)
{
var program = new OpenNest.CNC.Program();
program.MoveTo(vertices[0].X, vertices[0].Y);
foreach (var point in vertices.Skip(1).Append(vertices[0])) program.LineTo(point.X, point.Y);
return program;
}
private static void Hole(OpenNest.CNC.Program program, double x, double y)
{
program.MoveTo(x + 0.2, y);
program.ArcTo(x + 0.2, y, x, y, RotationType.CCW);
}
}
@@ -4,11 +4,55 @@ using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics; using OpenNest.Diagnostics;
using OpenNest.Engine.CuttingPlanning; using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.CuttingPlanning; namespace OpenNest.Tests.CuttingPlanning;
public class JointCuttingPlanTests public class JointCuttingPlanTests
{ {
[Fact]
public void JointSearch_NoLeads_RepairsRapidCrossings()
{
var (part, parameters) = Crossing();
parameters.ExternalLeadIn = new NoLeadIn();
parameters.InternalLeadIn = new NoLeadIn();
parameters.ArcCircleLeadIn = new NoLeadIn();
var unchanged = Unchanged(part, parameters);
var source = Read(part.Program, part.Location);
Assert.Contains(new ReleasedContourState(reportMissingLeadIns: false).Check(source, Vector.Zero, 1),
f => f.Kind == PostVerificationKind.RapidCrossing);
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], confirmedParameters: parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.True(result.IndependentlyReplayed);
var output = Read(Assert.Single(result.ProposedOrder).CopyProgram(), part.Location);
Assert.DoesNotContain(output.Motions, m => m.Layer == LayerType.Leadin);
Assert.Empty(new ReleasedContourState(reportMissingLeadIns: false).Check(output, Vector.Zero, 1));
Assert.Contains(new ReleasedContourState().Check(output, Vector.Zero, 1),
f => f.Kind == PostVerificationKind.MissingLeadIn);
unchanged();
}
[Fact]
public void JointSearch_NoLeads_ChoosesDifferentPiercesForOppositeApproaches()
{
var part = new Part(new Drawing("direct pierce", ExplicitContourTests.Square(false)));
var settings = new CuttingParameters();
var pierces = new List<Vector?>();
foreach (var start in new[] { new Vector(-5, 5), new Vector(15, 5) })
{
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], start, confirmedParameters: settings));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.True(result.IndependentlyReplayed);
var output = Read(Assert.Single(result.ProposedOrder).CopyProgram(), part.Location);
Assert.DoesNotContain(output.Motions, m => m.Layer == LayerType.Leadin);
pierces.Add(output.Motions.First(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Start);
Assert.Empty(new ReleasedContourState(reportMissingLeadIns: false).Check(output, start, 1));
}
Assert.NotEqual(pierces[0], pierces[1]);
}
[Fact] [Fact]
public void JointSearch_RepairsActualCompletedHoleCrossing_WithExactReplayAndNoMutation() public void JointSearch_RepairsActualCompletedHoleCrossing_WithExactReplayAndNoMutation()
{ {
@@ -46,7 +90,6 @@ public class JointCuttingPlanTests
[Theory] [Theory]
[InlineData("locked")] [InlineData("locked")]
[InlineData("ineligible")] [InlineData("ineligible")]
[InlineData("no-valid-entry")]
[InlineData("unsupported")] [InlineData("unsupported")]
public void JointSearch_RefusesWithoutInstallingFallbackOrMutating(string fault) public void JointSearch_RefusesWithoutInstallingFallbackOrMutating(string fault)
{ {
@@ -55,12 +98,6 @@ public class JointCuttingPlanTests
var expected = CuttingPlanStatus.ConstraintConflict; var expected = CuttingPlanStatus.ConstraintConflict;
if (fault == "locked") part.LeadInsLocked = true; if (fault == "locked") part.LeadInsLocked = true;
if (fault == "ineligible") eligible = []; if (fault == "ineligible") eligible = [];
if (fault == "no-valid-entry")
{
parameters.ArcCircleLeadIn = new NoLeadIn();
parameters.InternalLeadIn = parameters.ArcCircleLeadIn;
expected = CuttingPlanStatus.NoSolutionWithinBudget;
}
if (fault == "unsupported") if (fault == "unsupported")
{ {
part.BaseDrawing.Program.Codes.AddRange(LeadPathValidationTests.Rectangle(30, 30, 40, 40).Codes); part.BaseDrawing.Program.Codes.AddRange(LeadPathValidationTests.Rectangle(30, 30, 40, 40).Codes);
@@ -418,7 +455,7 @@ public class JointCuttingPlanTests
var manual = part.HasManualLeadIns; var manual = part.HasManualLeadIns;
var bounds = part.BoundingBox; var bounds = part.BoundingBox;
var quantity = part.BaseDrawing.Quantity.Nested; var quantity = part.BaseDrawing.Quantity.Nested;
var length = ((LineLeadIn)parameters.ExternalLeadIn).Length; var settings = CuttingParametersSerializer.Serialize(parameters);
return () => return () =>
{ {
Assert.Same(program, part.Program); Assert.Same(clean, part.BaseDrawing.Program); Assert.Same(program, part.Program); Assert.Same(clean, part.BaseDrawing.Program);
@@ -427,7 +464,7 @@ public class JointCuttingPlanTests
Assert.Equal(locked, part.LeadInsLocked); Assert.Equal(manual, part.HasManualLeadIns); Assert.Equal(locked, part.LeadInsLocked); Assert.Equal(manual, part.HasManualLeadIns);
Assert.Same(bounds, part.BoundingBox); Assert.Same(parameters, part.CuttingParameters); Assert.Same(bounds, part.BoundingBox); Assert.Same(parameters, part.CuttingParameters);
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested); Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
Assert.Equal(length, ((LineLeadIn)parameters.ExternalLeadIn).Length); Assert.Equal(settings, CuttingParametersSerializer.Serialize(parameters));
foreach (var sub in subs) foreach (var sub in subs)
{ {
Assert.Same(sub.Program, sub.c.Program); Assert.Equal(sub.text, sub.c.Program.ToString()); Assert.Same(sub.Program, sub.c.Program); Assert.Equal(sub.text, sub.c.Program.ToString());
@@ -0,0 +1,60 @@
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class LazyCuttingSearchTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RankedRouteDoesNotSpendBudgetEmittingUnusedSiblings(bool preserveOrder)
{
var parts = Parts();
var programs = parts.Select(p => p.Program).ToArray();
var parameters = ExplicitContourTests.Parameters();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts, new Vector(-2, -2),
expansionBudget: 36, confirmedParameters: parameters, preservePartOrder: preserveOrder));
var result = CuttingPlanService.Plan(snapshot);
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
Assert.InRange(result.Expansions, 1, 36);
var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default);
Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
Assert.True(replay.IndependentlyReplayed);
Assert.Equal(programs, parts.Select(p => p.Program));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void LazyTraversalKeepsBudgetAndMidSearchCancellationFailClosed(bool preserveOrder)
{
var parts = Parts();
var programs = parts.Select(p => p.Program).ToArray();
var parameters = ExplicitContourTests.Parameters();
var bounded = CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(-2, -2),
expansionBudget: 1, confirmedParameters: parameters, preservePartOrder: preserveOrder));
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status);
Assert.Equal(1, bounded.Expansions);
Assert.Empty(bounded.ProposedOrder);
Assert.False(bounded.IndependentlyReplayed);
using var cancellation = new CancellationTokenSource();
var cancelled = CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(-2, -2),
confirmedParameters: parameters, preservePartOrder: preserveOrder)
{ ExpansionObserver = n => { if (n == 10) cancellation.Cancel(); } }, cancellation.Token);
Assert.Equal(CuttingPlanStatus.Cancelled, cancelled.Status);
Assert.Equal(10, cancelled.Expansions);
Assert.Empty(cancelled.ProposedOrder);
Assert.False(cancelled.IndependentlyReplayed);
Assert.Equal(programs, parts.Select(p => p.Program));
}
private static Part[] Parts() =>
[
new(new Drawing("first", LeadPathValidationTests.Rectangle(0, 0, 10, 10)), Vector.Zero),
new(new Drawing("second", LeadPathValidationTests.Rectangle(0, 0, 10, 10)), new Vector(20, 0)),
];
}
@@ -0,0 +1,137 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class LeadMaterialSnapshotTests
{
[Theory]
[InlineData(false, 0, 0, 0)]
[InlineData(true, 0, 0, 0)]
[InlineData(false, 0.7, 40, 20)]
[InlineData(true, 0.7, 40, 20)]
public void AdjacentArcs_IntersectionOnExtensionIsNotASelfIntersection(bool atSeam, double angle, double x, double y)
{
var program = ArcJoint(0.00005, false, atSeam);
program.Rotate(angle);
var location = new Vector(x, y);
var execution = ExecutionMotionReader.ReadSupported(program, location, null);
var arcs = execution.Motions.Where(m => m.Curve?.IsFiniteArc == true).Select(m => m.Curve!).ToArray();
var contacts = arcs[0].Contacts(arcs[1], out var overlap);
Assert.False(overlap);
Assert.Contains(contacts, p => arcs.Any(a => a.IsOutsideArcSpan(p)));
var material = LeadMaterialSnapshot.Capture(program, location);
Assert.True(material.IsComplete, material.Reason);
}
[Fact]
public void AdjacentArcs_RealSecondIntersectionRemainsRejected()
{
var program = ArcJoint(-0.00005, true, false);
var arcs = ExecutionMotionReader.ReadSupported(program, Vector.Zero, null).Motions
.Where(m => m.Curve?.IsFiniteArc == true).Select(m => m.Curve!).ToArray();
var contacts = arcs[0].Contacts(arcs[1], out var overlap);
Assert.False(overlap);
Assert.Contains(contacts, p => p.DistanceTo(Vector.Zero) > 0.00001
&& !arcs[0].IsOutsideArcSpan(p) && !arcs[1].IsOutsideArcSpan(p));
var material = LeadMaterialSnapshot.Capture(program, Vector.Zero);
Assert.False(material.IsComplete);
Assert.Contains("Material boundaries", material.Reason);
}
private static Program ArcJoint(double centerOffset, bool reverseSecond, bool atSeam)
{
// Near-tangent circles meet at zero and about 0.000106 away. The native
// angular band of the radius-18 arc admits its extension beyond zero.
var first = new Vector(-18, 18);
var center = new Vector(centerOffset, 1);
var end = center + new Vector(center.DistanceTo(Vector.Zero), 0);
var program = new Program();
program.MoveTo(atSeam ? Vector.Zero : first);
if (!atSeam)
program.ArcTo(0, 0, 0, 18, RotationType.CCW);
program.ArcTo(end.X, end.Y, center.X, center.Y, reverseSecond ? RotationType.CW : RotationType.CCW);
program.LineTo(first);
if (atSeam)
program.ArcTo(0, 0, 0, 18, RotationType.CCW);
return program;
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RoundedCornerContact_RoundingAtAdjacentEndpointIsAccepted(bool atSeam)
{
// Rounded imported corner: its native tangent contact falls 1.52e-8
// from the authored arc/line joint. Exercise both consecutive and seam adjacency.
var program = new Program();
var start = new Vector(120.509177, 14.879559);
program.MoveTo(start);
program.Codes.Add(new ArcMove(new Vector(120.5, 14.9375),
new Vector(120.6875000024, 14.9374999848), RotationType.CW));
program.LineTo(120.5, 28.5625);
program.Codes.Add(new ArcMove(new Vector(120.6875, 28.75),
new Vector(120.6875, 28.5625), RotationType.CW));
program.LineTo(127.6875, 28.75);
program.Codes.Add(new ArcMove(new Vector(127.875, 28.5625),
new Vector(127.6875, 28.5625), RotationType.CW));
program.LineTo(127.875, 14.9375);
program.Codes.Add(new ArcMove(new Vector(127.6875, 14.75),
new Vector(127.6875, 14.9375), RotationType.CW));
program.LineTo(120.6875, 14.75);
program.Codes.Add(new ArcMove(start,
new Vector(120.6874999967, 14.9375000024), RotationType.CW));
if (atSeam)
{
var first = program.Codes[1];
program.Codes.RemoveAt(1);
program.Codes.Add(first);
program.Codes[0] = new RapidMove(120.5, 14.9375);
}
var material = LeadMaterialSnapshot.Capture(program, Vector.Zero);
Assert.True(material.IsComplete, material.Reason);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CrossingOrRetracedBoundary_RemainsRejected(bool retraced)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(8, 8);
if (retraced)
{
program.LineTo(4, 4);
program.LineTo(0, 8);
}
else
{
program.LineTo(0, 8);
program.LineTo(8, 0);
}
program.LineTo(0, 0);
var material = LeadMaterialSnapshot.Capture(program, Vector.Zero);
Assert.False(material.IsComplete);
Assert.Contains("Material boundaries", material.Reason);
}
[Fact]
public void SeparateTouchingHoles_RemainRejected()
{
var part = BestEffortCuttingPlanTests.TouchingContours();
var material = LeadMaterialSnapshot.Capture(part.BaseDrawing.Program, Vector.Zero);
Assert.False(material.IsComplete);
Assert.Contains("Material boundaries", material.Reason);
}
}
@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Threading;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using Xunit;
using Curve = OpenNest.Diagnostics.PostVerificationGeometry.Curve;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>
/// Captured material outlines are only nominally closed: capture admits closure slack up to
/// NominalContourClosure.Tolerance, and PEP-converted or rotated geometry carries sub-micro-inch
/// vertex jogs where two segments meet. The point-in-material ray test must stay consistent across
/// such a jog. Without that, a jog lying exactly on the test ray's Y reports far-away exterior
/// points as interior, so LeadPathValidator refuses leads that cannot touch anything: plans get
/// needlessly expensive routes or give up inside the search budget.
/// </summary>
public class MaterialContainmentTests
{
[Fact]
public void SubMicroinchJogAtRayHeight_KeepsDistantExteriorPointExterior()
{
var rings = Ring(
Line((21.2, 0.6), (31.2, 0.6)),
Line((31.2, 0.6000001), (41.2, 0.6000001)), // admitted seam jog on the ray line
Line((41.2, 0.6000001), (41.2, 46.16)),
Line((41.2, 46.16), (21.2, 46.16)),
Line((21.2, 46.16), (21.2, 0.6)));
Assert.False(LeadMaterialSnapshot.Inside(new Vector(20.75, 0.6), rings[0], CancellationToken.None));
Assert.False(LeadMaterialSnapshot.Inside(new Vector(20.75, 0.6000001), rings[0], CancellationToken.None));
Assert.False(LeadMaterialSnapshot.Inside(new Vector(20.75, 0.60000005), rings[0], CancellationToken.None));
Assert.True(LeadMaterialSnapshot.Inside(new Vector(30, 20), rings[0], CancellationToken.None));
}
[Fact]
public void ClosedWithoutJog_ReportsTheSameAnswersAsTheJoggedCopy()
{
var clean = Ring(
Line((21.2, 0.6), (41.2, 0.6)),
Line((41.2, 0.6), (41.2, 46.16)),
Line((41.2, 46.16), (21.2, 46.16)),
Line((21.2, 46.16), (21.2, 0.6)));
var jogged = Ring(
Line((21.2, 0.6), (31.2, 0.6)),
Line((31.2, 0.6000001), (41.2, 0.6000001)),
Line((41.2, 0.6000001), (41.2, 46.16)),
Line((41.2, 46.16), (21.2, 46.16)),
Line((21.2, 46.16), (21.2, 0.6)));
foreach (var y in new[] { 0.6, 0.60000005, 0.6000001 })
{
var expected = LeadMaterialSnapshot.Inside(new Vector(20.75, y), clean[0], CancellationToken.None);
Assert.Equal(expected, LeadMaterialSnapshot.Inside(new Vector(20.75, y), jogged[0], CancellationToken.None));
}
}
[Fact]
public void CircleTestedExactlyAtAnExtremum_KeepsExteriorExteriorAndInteriorInterior()
{
// Full circle r=5 about (10,10) as four quarter arcs. Testing the ray exactly along both
// vertical extrema is where the half-open rule and arc splitting have to agree.
var center = new Vector(10, 10);
var rings = Ring(
Curve.Create(new Vector(15, 10), new Vector(10, 15), center, false),
Curve.Create(new Vector(10, 15), new Vector(5, 10), center, false),
Curve.Create(new Vector(5, 10), new Vector(10, 5), center, false),
Curve.Create(new Vector(10, 5), new Vector(15, 10), center, false));
Assert.False(LeadMaterialSnapshot.Inside(new Vector(-4, 10), rings[0], CancellationToken.None));
Assert.False(LeadMaterialSnapshot.Inside(new Vector(-4, 5), rings[0], CancellationToken.None));
Assert.False(LeadMaterialSnapshot.Inside(new Vector(-4, 15), rings[0], CancellationToken.None));
Assert.True(LeadMaterialSnapshot.Inside(new Vector(10, 10), rings[0], CancellationToken.None));
Assert.True(LeadMaterialSnapshot.Inside(new Vector(7.5, 10), rings[0], CancellationToken.None));
}
[Fact]
public void SeamJogBetweenLineAndArc_KeepsDistantExteriorPointExterior()
{
// Rounded-left-edge material whose semicircular edge meets the bottom line 1e-7 apart:
// an admitted closure seam sitting exactly on a datum Y shared with neighbours.
var center = new Vector(1, 23.38000005);
var rings = Ring(
Line((1, 0.6), (41.2, 0.6)),
Line((41.2, 0.6), (41.2, 46.16)),
Line((41.2, 46.16), (1, 46.16)),
Curve.Create(new Vector(1, 46.16), new Vector(1, 0.6000001), center, false));
Assert.False(LeadMaterialSnapshot.Inside(new Vector(0.5, 0.6), rings[0], CancellationToken.None));
Assert.False(LeadMaterialSnapshot.Inside(new Vector(-4, 0.6), rings[0], CancellationToken.None));
Assert.False(LeadMaterialSnapshot.Inside(new Vector(-4, 0.6000001), rings[0], CancellationToken.None));
Assert.True(LeadMaterialSnapshot.Inside(new Vector(30, 20), rings[0], CancellationToken.None));
}
[Fact]
public void RealConcavityIsNotErasedByTheRayTolerance()
{
// A 0.01 in deep notch is far above any admitted seam tolerance: it must still count.
var rings = Ring(
Line((0, 0), (1, 0)),
Line((1, 0), (1, 1)),
Line((1, 1), (0.5, 1)),
Line((0.5, 1), (0.5, 0.99)), // notch floor
Line((0.5, 0.99), (0, 0.99)),
Line((0, 0.99), (0, 0)));
// The 0.01 in notch sits above the left part's top edge; the right tower rises through it.
Assert.False(LeadMaterialSnapshot.Inside(new Vector(0.25, 0.995), rings[0], CancellationToken.None));
Assert.True(LeadMaterialSnapshot.Inside(new Vector(0.75, 0.995), rings[0], CancellationToken.None));
Assert.True(LeadMaterialSnapshot.Inside(new Vector(0.25, 0.5), rings[0], CancellationToken.None));
Assert.False(LeadMaterialSnapshot.Inside(new Vector(-1, 0.5), rings[0], CancellationToken.None));
}
private static IReadOnlyList<IReadOnlyList<Curve>> Ring(params Curve[] curves) =>
new List<IReadOnlyList<Curve>> { new List<Curve>(curves) };
private static Curve Line((double X, double Y) start, (double X, double Y) end) =>
Curve.Create(new Vector(start.X, start.Y), new Vector(end.X, end.Y), null, false);
}
@@ -119,6 +119,50 @@ public class NativeContactRegressionTests
Assert.Throws<NotSupportedException>(() => circle.Contacts(line, out _)); Assert.Throws<NotSupportedException>(() => circle.Contacts(line, out _));
} }
[Fact]
public void ReusedNativeCurves_KeepContactAndContainmentVerdictsAcrossCallsAndThreads()
{
var lineStart = new Vector(-2, 0);
var lineEnd = new Vector(2, 0);
var arcCenter = new Vector(0, 0.003);
var arcStart = new Vector(0.003, 0.003);
var circleCenter = new Vector(0, 0);
var circleStart = new Vector(0.003, 0);
var line = PostVerificationGeometry.Curve.Create(lineStart, lineEnd, null, false);
var arc = PostVerificationGeometry.Curve.Create(arcStart, new Vector(-0.003, 0.003), arcCenter, false);
var circle = PostVerificationGeometry.Curve.Create(circleStart, circleStart, circleCenter, false);
var pairs = new[] { (line, arc), (arc, line), (line, circle), (circle, line), (arc, circle), (circle, arc) };
var probes = new[] { Vector.Zero, new Vector(0.003, 0), new Vector(0, 0.006) };
string Observe(PostVerificationGeometry.Curve a, PostVerificationGeometry.Curve b)
{
var inside = string.Join(",", probes.Select(p => $"{a.Contains(p)}:{b.Contains(p)}"));
try
{
var contacts = a.Contacts(b, out var overlap);
return $"{inside}|{overlap}|" + string.Join(";", contacts.Select(p =>
$"{BitConverter.DoubleToInt64Bits(p.X)}:{BitConverter.DoubleToInt64Bits(p.Y)}"));
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
return $"{inside}|{ex.GetType().Name}:{ex.Message}";
}
}
var expected = pairs.Select(p => Observe(
PostVerificationGeometry.Curve.Create(p.Item1.Start, p.Item1.End,
ReferenceEquals(p.Item1, arc) ? arcCenter : ReferenceEquals(p.Item1, circle) ? circleCenter : null, false),
PostVerificationGeometry.Curve.Create(p.Item2.Start, p.Item2.End,
ReferenceEquals(p.Item2, arc) ? arcCenter : ReferenceEquals(p.Item2, circle) ? circleCenter : null, false))).ToArray();
for (var repeat = 0; repeat < 3; repeat++)
Assert.Equal(expected, pairs.Select(p => Observe(p.Item1, p.Item2)).ToArray());
Parallel.For(0, 8, _ => Assert.Equal(expected, pairs.Select(p => Observe(p.Item1, p.Item2)).ToArray()));
// A separate pair has not initialized either native wrapper before the readers race.
var coldLine = PostVerificationGeometry.Curve.Create(lineStart, lineEnd, null, false);
var coldCircle = PostVerificationGeometry.Curve.Create(circleStart, circleStart, circleCenter, false);
Parallel.For(0, 8, _ => Assert.Equal(expected[2], Observe(coldLine, coldCircle)));
}
private static Program RoundedRectangle(double width, double height, double radius, double angle, Vector at) private static Program RoundedRectangle(double width, double height, double radius, double angle, Vector at)
{ {
var cos = System.Math.Cos(angle); var cos = System.Math.Cos(angle);
@@ -0,0 +1,36 @@
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class NominalContourClosureTests
{
[Theory]
[InlineData(0.0000009, true)]
[InlineData(0.000001, true)]
[InlineData(0.00000101, false)]
[InlineData(0.000002, false)]
public void CaptureAndPreparation_AgreeAtClosureBoundary(double gap, bool accepted)
{
// Nonzero coordinates exercise roundoff at the inclusive boundary.
var program = new OpenNest.CNC.Program();
program.MoveTo(14, 3);
program.LineTo(24, 3);
program.LineTo(24, 13);
program.LineTo(14, 13);
program.LineTo(14, 3 + gap);
var original = OwnedProgramCopy.Copy(program);
var material = LeadMaterialSnapshot.Capture(program, new Vector(30, 20));
Assert.Equal(accepted, material.IsComplete);
if (accepted)
Assert.Equal(1, PreparedContours.Capture(program, ExplicitContourTests.Parameters()).Count);
else
{
Assert.Contains("closure tolerance 1E-06", material.Reason);
Assert.Throws<ArgumentException>(() => PreparedContours.Capture(program, ExplicitContourTests.Parameters()));
}
Assert.True(ProgramContent.Equal(original, program));
}
}
@@ -34,6 +34,48 @@ public class ReorderSearchTests
Assert.Equal(parts, plate.Parts); // Planning alone never reorders the live plate. Assert.Equal(parts, plate.Parts); // Planning alone never reorders the live plate.
} }
[SkippableFact]
public void DenseGrid144_PerformanceProbe()
{
Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_CUTTING_PERF") == "1",
"Set OPENNEST_RUN_CUTTING_PERF=1 for the 144-part cutting-planner probe.");
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Size = new Size(100, 100);
foreach (var part in Grid(144, false))
plate.Parts.Add(part);
var source = plate.Parts.ToArray();
var timer = System.Diagnostics.Stopwatch.StartNew();
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
confirmedParameters: ExplicitContourTests.Parameters(), expansionBudget: 57600));
timer.Stop();
// Expanded owned execution includes called hole programs; Program.ToString() does not,
// and rounds coordinates. Hash exact scalar bits in execution order instead.
static string Bits(double value) => BitConverter.DoubleToInt64Bits(value).ToString("X16");
static string Point(Vector point) => $"{Bits(point.X)},{Bits(point.Y)}";
var payload = string.Join("\n", result.ProposedOrder.SelectMany(p =>
new[] { $"part:{p.SourceOrdinal}:{Point(p.Location)}:{Bits(p.Rotation)}" }.Concat(
p.Execution.Motions.Select(m =>
{
var geometry = m.Curve?.ToEntity() switch
{
Arc arc => $"arc:{Point(arc.Center)}:{Bits(arc.Radius)}:{Bits(arc.StartAngle)}:{Bits(arc.EndAngle)}:{arc.Rotation}",
Circle circle => $"circle:{Point(circle.Center)}:{Bits(circle.Radius)}:{circle.Rotation}",
Line => "line",
null => "none",
_ => throw new NotSupportedException("Unexpected execution curve."),
};
return $"{m.Layer}:{m.Rapid}:{(m.Start is { } start ? Point(start) : "null")}:{Point(m.End)}:{geometry}";
}))));
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(payload)));
Console.WriteLine($"Cutting144 elapsedMs={timer.Elapsed.TotalMilliseconds:F1} expansions={result.Expansions} status={result.Status} executionSha256={hash}");
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.True(result.IndependentlyReplayed);
Assert.Equal(source.OrderBy(Key), result.ProposedOrder.Select(p => p.SourcePart).OrderBy(Key));
Assert.Equal(source, plate.Parts);
}
[Fact] [Fact]
public void FreeOrder_BlockedApproach_LearnsToCutThatPartFirst() public void FreeOrder_BlockedApproach_LearnsToCutThatPartFirst()
{ {
@@ -0,0 +1,132 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>
/// A nominal contour may lawfully close within <see cref="NominalContourClosure.Tolerance"/>, and
/// capture and preparation already agree on that inclusive boundary. Replay verification must
/// agree too: a rotated tool path legitimately bridges that one admitted joint, so refusing the
/// whole plate over it makes every such converted drawing unplanable.
/// </summary>
public class SeamClosureReplayTests
{
private static readonly Vector Start = new(-2, 5);
/// <summary>
/// Square entering mid-edge so the tool path wraps across the closing seam. The seam offset is
/// perpendicular to the edge that follows it, so bridging it shifts that edge's support by the
/// admitted gap instead of merely shortening it.
/// </summary>
private static Program SeamRing(double seam)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(0, 10);
program.LineTo(10, 10);
program.LineTo(10, 0);
program.LineTo(seam, 0);
return program;
}
private static (Part Part, CuttingParameters Settings) Fixture(double seam)
{
var settings = ExplicitContourTests.Parameters();
var program = SeamRing(seam);
var prepared = PreparedContours.Capture(program, settings);
Assert.Equal(1, prepared.Count); // Preparation accepts the same closure boundary as capture.
var part = new Part(new Drawing("seam", program));
Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.Entry(0, 0, new Vector(0, 5))]), false));
return (part, settings);
}
private static CuttingPlanSnapshot Capture(Part part, CuttingParameters settings)
=> CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
private static Program Path(params Vector[] points)
{
var program = new Program();
program.MoveTo(-0.3, 5);
program.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin });
foreach (var point in points) program.LineTo(point);
return program;
}
private static OwnedExecution Read(Program program) => ExecutionMotionReader.Read(program, Vector.Zero, null, default);
[Theory]
[InlineData(0.0)] // exactly closed: unchanged behaviour
[InlineData(5e-7)] // admitted closure seam
[InlineData(1e-6)] // inclusive closure boundary that capture already accepts
public void AdmittedClosureSeam_StillReplays(double seam)
{
var (part, settings) = Fixture(seam);
var snapshot = Capture(part, settings);
Assert.True(snapshot.Placements[0].Material!.IsComplete, snapshot.Placements[0].Material!.Reason);
var source = snapshot.Placements[0];
var choice = source.Prepared!.Entry(0, 0, new Vector(0, 5)); // path wraps across the seam
var result = CuttingPlanService.ReplayPrograms(snapshot,
[source.Propose(source.Prepared.Emit([choice]), Read(source.Prepared.Emit([choice])), [choice])],
0, default);
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
}
[Fact]
public void AdmittedClosureSeam_StillPlansWithGeneratedLeadIns()
{
// The same plate through the planner entry point: a searched route over an admitted seam
// must not be refused at replay.
var (part, settings) = Fixture(9e-7);
var result = CuttingPlanService.Plan(Capture(part, settings));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
}
[Theory]
[InlineData("missing-edge")] // a real coverage hole next to an admitted seam still refuses
[InlineData("reversed-edge")] // wrong traversal direction still refuses
[InlineData("interior-shift")] // displacement larger than the admitted seam still refuses
[InlineData("second-circuit")] // retracing the ring still refuses
public void AdmittedClosureSeam_KeepsRefusingRealAccountingFaults(string fault)
{
var (part, settings) = Fixture(9e-7);
var snapshot = Capture(part, settings);
var source = snapshot.Placements[0];
var choice = source.Prepared!.Entry(0, 0, new Vector(0, 5));
var actual = fault switch
{
// Leaves the bottom edge uncut: coverage, not closure slack, is what fails.
"missing-edge" => Path(new Vector(0, 10), new Vector(10, 10), new Vector(10, 0)),
// Runs the right edge backwards against the nominal direction.
"reversed-edge" => Path(new(0, 5), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)),
// Closes exactly but shifts an interior vertex by far more than the admitted seam.
"interior-shift" => Path(new Vector(0, 5), new(0, 10), new(10, 9.9999), new(10, 0), new(0, 0), new(0, 5)),
// Two circuits are never closure slack.
"second-circuit" => Path(new(0, 5), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5),
new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)),
_ => throw new ArgumentOutOfRangeException(nameof(fault))
};
var result = CuttingPlanService.ReplayPrograms(snapshot, [source.Propose(actual, Read(actual), [choice])],
0, default);
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.False(result.IndependentlyReplayed);
}
[Fact]
public void GapBeyondAdmittedClosure_StillRefusesAtCapture()
{
// The fix must not widen closure admission itself: beyond tolerance capture still refuses.
var program = SeamRing(2e-6);
var material = LeadMaterialSnapshot.Capture(program, new Vector(30, 20));
Assert.False(material.IsComplete);
Assert.Contains("closure tolerance", material.Reason);
}
}
@@ -0,0 +1,232 @@
using OpenNest.Data;
namespace OpenNest.Tests.Data;
public class NestHistorySessionTests
{
[Fact]
public async Task BoundedHistory_DownloadIsByteExactAndReadOnly()
{
var repository = new Repository();
using var session = new NestHistorySession(repository, "http://shop/");
Assert.True(await session.LoadAsync(repository.Id));
Assert.Equal(NestHistorySession.PageSize, repository.Limit);
Assert.Equal(repository.Bytes, await session.DownloadAsync(2, "http://shop"));
Assert.Equal(0, repository.Writes);
Assert.Equal(5, session.Current!.Revision);
Assert.Equal("Earlier notes", session.Page!.Items[0].Comments);
}
[Fact]
public async Task Restore_UsesDisplayedPreconditionSameIdAndVerifiesCurrent()
{
var repository = new Repository();
using var session = new NestHistorySession(repository, "http://shop");
await session.LoadAsync(repository.Id);
var restored = await session.RestoreAsync(2, "http://shop");
Assert.Equal(repository.Id, restored.Id);
Assert.Equal(6, restored.Revision);
Assert.Equal(5, repository.Expected);
Assert.Equal(2, repository.Source);
Assert.Equal(2, repository.MetadataReads);
Assert.Null(session.Page);
Assert.True(await session.LoadAsync(repository.Id));
Assert.Equal(6, session.Current!.Revision);
}
[Fact]
public async Task Recovery_IsExplicitAndUsesDeletedEndpoints()
{
var repository = new Repository { Deleted = true };
using var session = new NestHistorySession(repository, "http://shop");
await session.LoadAsync(repository.Id, true);
Assert.Equal(repository.Bytes, await session.DownloadAsync(2, "http://shop"));
Assert.Equal(1, repository.DeletedDownloads);
await Assert.ThrowsAsync<InvalidOperationException>(() => session.RestoreAsync(2, "http://shop"));
var recovered = await session.RecoverAsync("http://shop");
Assert.False(recovered.Deleted);
Assert.Equal(6, recovered.Revision);
Assert.Equal(5, repository.Expected);
}
[Fact]
public async Task FailedRestore_PreservesHistoryButDisablesBlindRetry()
{
var repository = new Repository { Fail = true };
using var session = new NestHistorySession(repository, "http://shop");
await session.LoadAsync(repository.Id);
var page = session.Page;
await Assert.ThrowsAsync<IOException>(() => session.RestoreAsync(2, "http://shop"));
Assert.Same(page, session.Page);
Assert.Null(session.Current);
await Assert.ThrowsAsync<InvalidOperationException>(() => session.RestoreAsync(2, "http://shop"));
Assert.Equal(1, repository.Writes);
Assert.Equal(5, repository.Revision);
repository.Fail = false;
await session.LoadAsync(repository.Id);
Assert.NotNull(session.Current);
}
[Fact]
public async Task ServerSwitch_RefusesDownloadRestoreAndRecoveryWithoutRequests()
{
var repository = new Repository();
using var session = new NestHistorySession(repository, "http://shop");
await session.LoadAsync(repository.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() => session.DownloadAsync(2, "http://other"));
await Assert.ThrowsAsync<InvalidOperationException>(() => session.RestoreAsync(2, "http://other"));
await Assert.ThrowsAsync<InvalidOperationException>(() => session.RecoverAsync("http://other"));
Assert.Equal(0, repository.Downloads);
Assert.Equal(0, repository.Writes);
}
[Fact]
public async Task StaleSelection_IgnoresBackendThatIgnoresCancellation()
{
var repository = new Repository();
var held = new TaskCompletionSource<NestRevisionPage>(TaskCreationOptions.RunContinuationsAsynchronously);
repository.History = held.Task;
using var session = new NestHistorySession(repository, "http://shop");
var first = session.LoadAsync(repository.Id);
repository.History = null;
var secondId = Guid.NewGuid();
await session.LoadAsync(secondId);
held.SetResult(new NestRevisionPage { Items = [repository.Record(repository.Id, 2)], Total = 1, Limit = 50 });
Assert.False(await first);
Assert.Equal(secondId, session.Current!.Id);
}
[Fact]
public async Task CancelAndClear_NeverPublishLateHistory()
{
var repository = new Repository();
var held = new TaskCompletionSource<NestRevisionPage>(TaskCreationOptions.RunContinuationsAsynchronously);
repository.History = held.Task;
using var session = new NestHistorySession(repository, "http://shop");
using var cancellation = new CancellationTokenSource();
var task = session.LoadAsync(repository.Id, cancellationToken: cancellation.Token);
cancellation.Cancel();
session.Clear();
held.SetResult(new NestRevisionPage { Items = [repository.Record(repository.Id, 2)], Total = 1, Limit = 50 });
Assert.False(await task);
Assert.Null(session.Current);
Assert.Null(session.Page);
}
[Fact]
public async Task LegacyServer_HistoryRefusesButCurrentReadRetainsZeroRevision()
{
var repository = new Repository { Revision = 0 };
using var session = new NestHistorySession(repository, "http://shop");
await Assert.ThrowsAsync<NotSupportedException>(() => session.LoadAsync(repository.Id));
var snapshot = await NestHistorySession.ReadCurrentAsync(repository, repository.Id, "http://shop/");
Assert.Equal(0, snapshot.Metadata.Revision);
Assert.Equal("http://shop", snapshot.ServerUrl);
Assert.Equal(repository.Bytes, snapshot.Archive);
}
[Fact]
public async Task CurrentOpen_UsesImmutableRevisionNotUnversionedFile()
{
var repository = new Repository();
var snapshot = await NestHistorySession.ReadCurrentAsync(repository, repository.Id, "http://shop");
Assert.Equal(5, repository.DownloadRevision);
Assert.Equal(0, repository.LegacyDownloads);
var save = new NestSaveSession();
save.Bind(snapshot.Metadata.Id, snapshot.ServerUrl, snapshot.Metadata.Revision);
Assert.Equal(5, save.Revision);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CurrentOpen_RejectsChangedMetadataOrMismatchingArchive(bool concurrent)
{
var repository = new Repository { ChangeDuringDownload = concurrent, WrongSize = !concurrent };
await Assert.ThrowsAsync<InvalidDataException>(() => NestHistorySession.ReadCurrentAsync(repository, repository.Id, "http://shop"));
}
[Fact]
public async Task Mutation_VerificationFailureIsNotSuccess()
{
var repository = new Repository { WrongVerification = true };
using var session = new NestHistorySession(repository, "http://shop");
await session.LoadAsync(repository.Id);
await Assert.ThrowsAsync<InvalidDataException>(() => session.RestoreAsync(2, "http://shop"));
Assert.Null(session.Current);
Assert.Equal(1, repository.Writes);
}
[Fact]
public async Task Download_SelectionChangedDuringReadDropsLateBytes()
{
var repository = new Repository();
using var session = new NestHistorySession(repository, "http://shop");
await session.LoadAsync(repository.Id);
var held = new TaskCompletionSource<byte[]?>(TaskCreationOptions.RunContinuationsAsynchronously);
repository.File = held.Task;
var read = session.DownloadAsync(2, "http://shop");
session.Clear();
held.SetResult(repository.Bytes);
Assert.Null(await read);
}
private sealed class Repository : INestRepository
{
public Guid Id { get; } = Guid.NewGuid();
public byte[] Bytes { get; } = [1, 2, 3];
public int Revision = 5;
public bool Deleted, Fail, ChangeDuringDownload, WrongSize, WrongVerification;
public int Limit, Writes, Expected, Source, MetadataReads, Downloads, DeletedDownloads, LegacyDownloads, DownloadRevision;
public Task<NestRevisionPage>? History;
public Task<byte[]?>? File;
public NestRecord Record(Guid id, int revision) => new()
{
Id = id,
Revision = revision,
Deleted = Deleted,
FileSize = WrongSize ? 100 : Bytes.Length,
Comments = "Earlier notes",
};
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default)
{
++MetadataReads;
return Task.FromResult<NestRecord?>(Record(id, WrongVerification && Writes > 0 ? 1 : Revision));
}
public Task<NestRecord?> GetDeletedMetadataAsync(Guid id, CancellationToken ct = default) => GetMetadataAsync(id, ct);
public Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default)
{
Limit = limit;
return History ?? Task.FromResult(new NestRevisionPage { Items = [Record(id, 2)], Total = offset + 1, Offset = offset, Limit = limit });
}
public Task<NestRevisionPage> GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => GetRevisionsAsync(id, offset, limit, cancellationToken);
public Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default)
{
Downloads++;
DownloadRevision = revisionNumber;
if (ChangeDuringDownload) Revision++;
return File ?? Task.FromResult<byte[]?>(Bytes);
}
public Task<byte[]?> GetDeletedRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default)
{
DeletedDownloads++;
return GetRevisionFileAsync(id, revisionNumber, cancellationToken);
}
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) { LegacyDownloads++; return Task.FromResult<byte[]?>(Bytes); }
public Task<NestRecord> RestoreAsync(Guid id, int sourceRevision, int expectedRevision, CancellationToken ct = default)
{
Writes++; Expected = expectedRevision; Source = sourceRevision;
if (Fail) throw new IOException("Conflict: current version 6. Reload or Save As; no automatic retry.");
Deleted = false;
Revision++;
return Task.FromResult(Record(id, Revision));
}
public Task<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken ct = default) => RestoreAsync(id, 0, expectedRevision, ct);
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
}
}
+45 -3
View File
@@ -22,6 +22,8 @@ public class NestSaveSessionTests
await session.SaveAsync(repository, "http://shop:8090", nest, bytes); await session.SaveAsync(repository, "http://shop:8090", nest, bytes);
Assert.Equal(firstId, repository.UpdatedId); Assert.Equal(firstId, repository.UpdatedId);
Assert.Equal(1, repository.Updates); Assert.Equal(1, repository.Updates);
Assert.Equal(1, repository.ExpectedRevision);
Assert.Equal(2, session.Revision);
await session.SaveAsync(repository, "http://shop:8090", nest, bytes, saveCopy: true); await session.SaveAsync(repository, "http://shop:8090", nest, bytes, saveCopy: true);
Assert.NotEqual(firstId, session.RemoteId); Assert.NotEqual(firstId, session.RemoteId);
@@ -41,15 +43,46 @@ public class NestSaveSessionTests
session.SaveAsync(repository, "http://new:8090", new Nest("Job"), [1])); session.SaveAsync(repository, "http://new:8090", new Nest("Job"), [1]));
Assert.Equal(oldId, session.RemoteId); Assert.Equal(oldId, session.RemoteId);
Assert.Equal("http://old:8090", session.ServerUrl); Assert.Equal("http://old:8090", session.ServerUrl);
Assert.Equal(Guid.Empty, repository.LastRecord!.Id); Assert.NotEqual(Guid.Empty, repository.LastRecord!.Id);
Assert.Equal(session.PendingCreateId, repository.LastRecord.Id);
Assert.Equal(0, repository.Updates); Assert.Equal(0, repository.Updates);
repository.Fail = false; repository.Fail = false;
Assert.Null(await session.ReconcilePendingCreateAsync(repository, "http://new:8090"));
await session.SaveAsync(repository, "http://new:8090", new Nest("Job"), [1]); await session.SaveAsync(repository, "http://new:8090", new Nest("Job"), [1]);
Assert.NotEqual(oldId, session.RemoteId); Assert.NotEqual(oldId, session.RemoteId);
Assert.Equal("http://new:8090", session.ServerUrl); Assert.Equal("http://new:8090", session.ServerUrl);
} }
[Fact]
public async Task FailedUpdate_RetainsIdServerAndExpectedRevision_NoAutomaticRetry()
{
var repository = new FakeRepository { Fail = true };
var session = new NestSaveSession();
var id = Guid.NewGuid();
session.Bind(id, "http://shop/", 4);
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, "http://shop", new Nest("Job"), [1]));
Assert.Equal(id, session.RemoteId);
Assert.Equal("http://shop", session.ServerUrl);
Assert.Equal(4, session.Revision);
Assert.Equal(4, repository.ExpectedRevision);
Assert.Equal(1, repository.Updates);
Assert.True(session.RequiresReload);
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, "http://shop", new Nest("Job"), [1]));
Assert.Equal(1, repository.Updates);
session.Bind(id, "http://shop", 5);
Assert.False(session.RequiresReload);
}
[Fact]
public void LegacyBind_DoesNotFabricateARevision()
{
var session = new NestSaveSession();
session.Bind(Guid.NewGuid(), "http://shop", 5);
session.Bind(Guid.NewGuid(), "http://shop");
Assert.Equal(0, session.Revision);
}
private sealed class FakeRepository : INestRepository private sealed class FakeRepository : INestRepository
{ {
public int Uploads { get; private set; } public int Uploads { get; private set; }
@@ -57,16 +90,25 @@ public class NestSaveSessionTests
public Guid UpdatedId { get; private set; } public Guid UpdatedId { get; private set; }
public NestRecord? LastRecord { get; private set; } public NestRecord? LastRecord { get; private set; }
public bool Fail { get; set; } public bool Fail { get; set; }
public int ExpectedRevision { get; private set; }
public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default)
{ {
Uploads++; Uploads++;
LastRecord = record; LastRecord = record;
if (Fail) throw new IOException("Offline"); if (Fail) throw new IOException("Offline");
record.Id = Guid.NewGuid(); if (record.Id == Guid.Empty) record.Id = Guid.NewGuid();
record.Revision = 1;
return Task.FromResult(record); return Task.FromResult(record);
} }
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, int expectedRevision, CancellationToken ct = default)
{
ExpectedRevision = expectedRevision;
record.Revision = expectedRevision + 1;
return UpdateFileAsync(id, file, record, ct);
}
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default)
{ {
Updates++; Updates++;
@@ -78,7 +120,7 @@ public class NestSaveSessionTests
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotImplementedException(); public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotImplementedException(); public Task<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => Task.FromResult<NestRecord?>(null);
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException(); public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more