mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-12 06:44:13 -04:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3bcc77145 | ||
|
|
df94824054 | ||
|
|
9bd5d0dfa7 | ||
|
|
d6ae706098 | ||
|
|
f9b2e1282a | ||
|
|
943b63937d | ||
|
|
6ed7ab7392 | ||
|
|
4738a0390e | ||
|
|
322dc3eda6 | ||
|
|
ddc4710dfd | ||
|
|
97a468ee2c | ||
|
|
09dd19ca66 | ||
|
|
fae37dd1d0 | ||
|
|
94ed8a0f8f | ||
|
|
028832debc | ||
|
|
51ad700bec | ||
|
|
0216a9e9e2 | ||
|
|
a7c8e8541e | ||
|
|
5f1eeb00eb | ||
|
|
914c197181 | ||
|
|
5598804942 | ||
|
|
d216bcd406 | ||
|
|
11471e3c71 | ||
|
|
ed7600dc90 | ||
|
|
a47b414392 | ||
|
|
3d512c794c | ||
|
|
08acb092a7 | ||
|
|
dc142cc88d | ||
|
|
6799e08356 | ||
|
|
ca6966906f | ||
|
|
c28ccd7568 | ||
|
|
90ae3b6a84 | ||
|
|
72776886cb | ||
|
|
d59c03f5ab | ||
|
|
bb108e9341 | ||
|
|
80c647bf07 | ||
|
|
24be90ace9 | ||
|
|
4feada094c | ||
|
|
4e846007a2 | ||
|
|
8ebb48694f | ||
|
|
afa50c5a12 | ||
|
|
ef6ca80e6d | ||
|
|
0c99455489 | ||
|
|
c34ac2bc0e | ||
|
|
076b794dda | ||
|
|
5dbf887b68 | ||
|
|
17e504fb70 | ||
|
|
69304d3851 | ||
|
|
1c6d02c480 | ||
|
|
d328c5f691 | ||
|
|
e897258a85 | ||
|
|
1b9d8a36d8 | ||
|
|
b750475228 | ||
|
|
138c86526a | ||
|
|
07e682a654 | ||
|
|
0b29c683b9 | ||
|
|
71bd964344 | ||
|
|
1d50c6ce60 | ||
|
|
962ae8c825 | ||
|
|
d3e325f36c | ||
|
|
575412a464 | ||
|
|
60fb009329 | ||
|
|
41737a4f4c | ||
|
|
f1d1df3f17 | ||
|
|
81404fc673 | ||
|
|
a5b6c8a00e | ||
|
|
2a6e5504cd | ||
|
|
3d5c73a353 | ||
|
|
8b94e49711 | ||
|
|
2623517219 |
No files matched your search
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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; } = "";
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in new issue
Block a user