mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-11 13:14:13 -04:00
Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
eff148c24e | ||
|
|
648ffbca65 | ||
|
|
4d94273a00 | ||
|
|
028adee046 | ||
|
|
3ddfff242d | ||
|
|
5fc563bbca | ||
|
|
785a861ab9 | ||
|
|
986ba3dee1 | ||
|
|
a73a85ebc0 | ||
|
|
8628c69098 | ||
|
|
c1c4b18f17 | ||
|
|
7e887921ea | ||
|
|
f50705c491 | ||
|
|
81f3c006ab | ||
|
|
b83518feac | ||
|
|
1826e6d5b5 | ||
|
|
47140cc59e | ||
|
|
91aef95b1b | ||
|
|
9110cd62e0 | ||
|
|
8e8b1de25f | ||
|
|
1add37d3e0 | ||
|
|
71f41e23e4 | ||
|
|
b591528aa2 | ||
|
|
12206ed8fd |
No files matched your search
@@ -97,6 +97,11 @@ jobs:
|
|||||||
if ($LASTEXITCODE -ne 0) { $failed += $project }
|
if ($LASTEXITCODE -ne 0) { $failed += $project }
|
||||||
}
|
}
|
||||||
if ($failed.Count -gt 0) { throw "Failed: $($failed -join ', ')" }
|
if ($failed.Count -gt 0) { throw "Failed: $($failed -join ', ')" }
|
||||||
|
- name: Test acceptance evidence checker
|
||||||
|
run: python scripts/test_windows_acceptance.py -v
|
||||||
|
- name: Verify named Windows acceptance coverage
|
||||||
|
if: always()
|
||||||
|
run: python scripts/windows_acceptance.py --results TestResults
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
if: >-
|
if: >-
|
||||||
github.repository == 'ajisaacs/OpenNest' &&
|
github.repository == 'ajisaacs/OpenNest' &&
|
||||||
(github.event_name == 'release' ||
|
((github.event_name == 'release' && github.event.release.prerelease == false) ||
|
||||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'))
|
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
|
|||||||
@@ -49,6 +49,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
dotnet build OpenNest.sln -c Release
|
dotnet build OpenNest.sln -c Release
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||||
|
- name: Install pinned Poppler for PDF report tests
|
||||||
|
run: |
|
||||||
|
$zip = Join-Path $env:RUNNER_TEMP 'poppler-26.09.0.zip'
|
||||||
|
Invoke-WebRequest 'https://github.com/oschwartz10612/poppler-windows/releases/download/v26.09.0-0/Release-26.09.0-0.zip' -OutFile $zip
|
||||||
|
$actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
if ($actual -ne '7a6f256a0ddf7536182246a5733331bf4677cbcc34f4663774947ad34556c8d0') { throw 'Poppler archive hash mismatch.' }
|
||||||
|
$destination = Join-Path $env:RUNNER_TEMP 'poppler-release-tests'
|
||||||
|
Expand-Archive $zip -DestinationPath $destination
|
||||||
|
$bin = Join-Path $destination 'poppler-26.09.0/Library/bin'
|
||||||
|
$tool = Join-Path $bin 'pdftotext.exe'
|
||||||
|
if (-not (Test-Path $tool)) { throw 'Pinned pdftotext.exe is missing.' }
|
||||||
|
& $tool -v
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Pinned pdftotext.exe cannot start.' }
|
||||||
|
$bin >> $env:GITHUB_PATH
|
||||||
- name: Run all test projects on Windows
|
- name: Run all test projects on Windows
|
||||||
# A test silent for 5 minutes is killed and named in the log, with a
|
# A test silent for 5 minutes is killed and named in the log, with a
|
||||||
# mini dump in TestResults, instead of holding the runner until the
|
# mini dump in TestResults, instead of holding the runner until the
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -89,6 +89,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 +130,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; }
|
||||||
@@ -170,12 +199,21 @@ 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) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= Epsilon;
|
||||||
|
|
||||||
|
/// <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 +319,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 +333,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,13 +352,20 @@ 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)
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -32,13 +32,18 @@ namespace OpenNest
|
|||||||
Color.FromArgb(215, 130, 130), // Light Coral
|
Color.FromArgb(215, 130, 130), // Light Coral
|
||||||
};
|
};
|
||||||
|
|
||||||
public static Color GetNextColor()
|
public static bool UseGoldenAngleColors { get; set; }
|
||||||
|
|
||||||
|
public static Color GetPartColor(int index)
|
||||||
{
|
{
|
||||||
var color = PartColors[nextColorIndex % PartColors.Length];
|
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||||
nextColorIndex++;
|
return UseGoldenAngleColors
|
||||||
return color;
|
? PartColorPalette.GoldenAngle(index)
|
||||||
|
: PartColors[index % PartColors.Length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Color GetNextColor() => GetPartColor(unchecked(nextColorIndex++) & int.MaxValue);
|
||||||
|
|
||||||
public Drawing()
|
public Drawing()
|
||||||
: this(string.Empty, new Program()) { }
|
: this(string.Empty, new Program()) { }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Drawing colors: golden-angle hue walk (skipping the etch-green band)
|
||||||
|
/// cycled through eight saturation/lightness tiers for wide separation.
|
||||||
|
/// </summary>
|
||||||
|
public static class PartColorPalette
|
||||||
|
{
|
||||||
|
// (lightness, saturation) tiers cycled on an irrational stride so any
|
||||||
|
// window of neighboring parts spans clearly different tones.
|
||||||
|
private static readonly (double Lightness, double Saturation)[] Tiers =
|
||||||
|
{
|
||||||
|
(0.52, 0.68), (0.64, 0.46), (0.75, 0.54), (0.61, 0.87),
|
||||||
|
(0.79, 0.97), (0.43, 0.82), (0.65, 0.76), (0.48, 0.97),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plastic-number stride: tier(i) never falls into a short repeating phase with the hue walk.
|
||||||
|
private const double TierStride = 0.7548776662466927;
|
||||||
|
|
||||||
|
// Hues land in [0,95) + [170,295) mapped onto the golden-angle cycle,
|
||||||
|
// keeping fills out of the bright-green etch band.
|
||||||
|
private const double HueSpan = 295.0;
|
||||||
|
private const double BandStart = 95.0;
|
||||||
|
private const double BandWidth = 75.0;
|
||||||
|
|
||||||
|
public static Color GoldenAngle(int index)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||||
|
var hue = index * 137.508 % HueSpan;
|
||||||
|
if (hue >= BandStart)
|
||||||
|
hue += BandWidth;
|
||||||
|
var (lightness, saturation) = Tiers[
|
||||||
|
unchecked((int)(Tiers.Length * (index * TierStride % 1.0)) % Tiers.Length)];
|
||||||
|
|
||||||
|
var q = lightness < 0.5
|
||||||
|
? lightness * (1 + saturation)
|
||||||
|
: lightness + saturation - lightness * saturation;
|
||||||
|
var p = 2 * lightness - q;
|
||||||
|
|
||||||
|
int Channel(double t)
|
||||||
|
{
|
||||||
|
t = (t % 1 + 1) % 1;
|
||||||
|
var value = t < 1 / 6.0 ? p + (q - p) * 6 * t
|
||||||
|
: t < 0.5 ? q
|
||||||
|
: t < 2 / 3.0 ? p + (q - p) * (2 / 3.0 - t) * 6
|
||||||
|
: p;
|
||||||
|
return (int)System.Math.Round(value * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
var h = hue / 360.0;
|
||||||
|
return Color.FromArgb(Channel(h + 1 / 3.0), Channel(h), Channel(h - 1 / 3.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -4,11 +4,20 @@ namespace OpenNest.Data;
|
|||||||
/// Loads the details of the nest highlighted in the Database-mode nest browser. Moving
|
/// Loads the details of the nest highlighted in the Database-mode nest browser. Moving
|
||||||
/// the highlight starts a new load and cancels the previous one; a superseded load's
|
/// the highlight starts a new load and cancels the previous one; a superseded load's
|
||||||
/// result or failure is discarded, so details never belong to a nest that is no longer
|
/// result or failure is discarded, so details never belong to a nest that is no longer
|
||||||
/// highlighted. Call from one thread (the UI thread).
|
/// highlighted. Also holds a small LRU cache of recently shown or <see cref="Prefetch"/>ed
|
||||||
|
/// nests, so browsing back onto one, or onto one a caller warmed ahead of time, resolves
|
||||||
|
/// without a server round trip. Call from one thread (the UI thread).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NestDetailsSession : IDisposable
|
public sealed class NestDetailsSession : IDisposable
|
||||||
{
|
{
|
||||||
|
// Bounds memory from cached plate/drawing geometry; generous enough for a caller's
|
||||||
|
// prefetch window plus some scrollback before the oldest entries are evicted.
|
||||||
|
private const int CacheCapacity = 25;
|
||||||
|
|
||||||
private readonly Func<Guid, CancellationToken, Task<NestDetails>> _load;
|
private readonly Func<Guid, CancellationToken, Task<NestDetails>> _load;
|
||||||
|
private readonly Dictionary<Guid, NestDetails> _cache = new();
|
||||||
|
private readonly List<Guid> _cacheOrder = new();
|
||||||
|
private readonly Dictionary<Guid, CancellationTokenSource> _prefetches = new();
|
||||||
private CancellationTokenSource? _pending;
|
private CancellationTokenSource? _pending;
|
||||||
private int _generation;
|
private int _generation;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -49,7 +58,18 @@ public sealed class NestDetailsSession : IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var details = await _load(id, cancellation.Token);
|
NestDetails details;
|
||||||
|
if (_cache.TryGetValue(id, out var cached))
|
||||||
|
{
|
||||||
|
Touch(id);
|
||||||
|
details = cached;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
details = await _load(id, cancellation.Token);
|
||||||
|
Remember(id, details);
|
||||||
|
}
|
||||||
|
|
||||||
if (generation != _generation)
|
if (generation != _generation)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -70,7 +90,63 @@ public sealed class NestDetailsSession : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Forgets the current details and discards any load in flight (no nest highlighted).</summary>
|
/// <summary>
|
||||||
|
/// Starts loading one nest's details in the background so a later <see cref="LoadAsync"/>
|
||||||
|
/// for it resolves from the cache instead of the server. Skipped when the nest is already
|
||||||
|
/// cached, already prefetching, or is the loaded/loading nest. Failures are swallowed here;
|
||||||
|
/// an on-demand load surfaces the error if the nest is actually selected later.
|
||||||
|
/// </summary>
|
||||||
|
public void Prefetch(Guid id)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
if (id == NestId || _cache.ContainsKey(id) || _prefetches.ContainsKey(id))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var cancellation = new CancellationTokenSource();
|
||||||
|
_prefetches[id] = cancellation;
|
||||||
|
_ = PrefetchCoreAsync(id, cancellation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PrefetchCoreAsync(Guid id, CancellationTokenSource cancellation)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var details = await _load(id, cancellation.Token);
|
||||||
|
if (!cancellation.IsCancellationRequested)
|
||||||
|
Remember(id, details);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Silent: this prefetch only mattered if the nest is later selected, and
|
||||||
|
// LoadAsync's own on-demand fetch will surface the error then.
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_prefetches.GetValueOrDefault(id), cancellation))
|
||||||
|
_prefetches.Remove(id);
|
||||||
|
cancellation.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Remember(Guid id, NestDetails details)
|
||||||
|
{
|
||||||
|
_cache[id] = details;
|
||||||
|
Touch(id);
|
||||||
|
while (_cacheOrder.Count > CacheCapacity)
|
||||||
|
{
|
||||||
|
var oldest = _cacheOrder[0];
|
||||||
|
_cacheOrder.RemoveAt(0);
|
||||||
|
_cache.Remove(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Touch(Guid id)
|
||||||
|
{
|
||||||
|
_cacheOrder.Remove(id);
|
||||||
|
_cacheOrder.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forgets the current details and discards any load or prefetch in flight.</summary>
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
@@ -83,6 +159,8 @@ public sealed class NestDetailsSession : IDisposable
|
|||||||
Details = null;
|
Details = null;
|
||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
pending?.Cancel();
|
pending?.Cancel();
|
||||||
|
foreach (var prefetch in _prefetches.Values)
|
||||||
|
prefetch.Cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
private CancellationTokenSource Supersede()
|
private CancellationTokenSource Supersede()
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A deliberately uncertified fallback, not a relaxed safety checker. Reuses closed native
|
||||||
|
/// contour preparation and emission, retaining source order except for proven prerequisites.
|
||||||
|
/// Unknown program semantics, invalid motions and unrepresentable contours still refuse.
|
||||||
|
/// </summary>
|
||||||
|
internal static class BestEffortCuttingPlan
|
||||||
|
{
|
||||||
|
internal static CuttingPlanResult Plan(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (snapshot.Failure is { } failure)
|
||||||
|
return new(failure, findings: snapshot.Findings);
|
||||||
|
if (!snapshot.Regeneration || snapshot.OwnedParameters == null)
|
||||||
|
return new(CuttingPlanStatus.InvalidInput);
|
||||||
|
var findings = snapshot.Findings.ToList();
|
||||||
|
findings.Add(new(null, null, null, null, PostVerificationKind.Incomplete,
|
||||||
|
"Best-effort plan: lead clearance, rapid travel and material containment are not certified. "
|
||||||
|
+ "Review the complete plan before cutting. Source contours have not been repaired."));
|
||||||
|
var order = new List<FixedProgramPlacement>();
|
||||||
|
var done = new HashSet<int>();
|
||||||
|
var position = snapshot.StartPoint;
|
||||||
|
var distance = 0.0;
|
||||||
|
var checker = new ReleasedContourState(reportMissingLeadIns: false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (order.Count < snapshot.Placements.Count)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var source = snapshot.PreservePartOrder ? snapshot.Placements[order.Count]
|
||||||
|
: snapshot.Placements.FirstOrDefault(p => !done.Contains(p.SourceOrdinal)
|
||||||
|
&& snapshot.Dependencies.IsReady(p.SourceOrdinal, done));
|
||||||
|
if (source == null || !snapshot.Dependencies.IsReady(source.SourceOrdinal, done))
|
||||||
|
return new(CuttingPlanStatus.ConstraintConflict, findings: findings);
|
||||||
|
var proposal = source;
|
||||||
|
if (source.Prepared is { } prepared)
|
||||||
|
{
|
||||||
|
var choices = new List<ContourChoice>();
|
||||||
|
var approach = position - source.Location;
|
||||||
|
for (var contour = 0; contour < prepared.Count; contour++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
choices.Add(prepared.ClosestEntry(contour, approach));
|
||||||
|
// Prefixes are standalone programs, not relative to the prior prefix.
|
||||||
|
var prefix = ExecutionMotionReader.ReadSupported(prepared.EmitPrefix(choices),
|
||||||
|
source.Location, position, token);
|
||||||
|
approach = prefix.DeparturePoint - source.Location;
|
||||||
|
}
|
||||||
|
var program = prepared.Emit(choices);
|
||||||
|
var emitted = ExecutionMotionReader.ReadSupported(program, source.Location, position, token);
|
||||||
|
proposal = source.Propose(program, emitted, choices, token);
|
||||||
|
}
|
||||||
|
// Read the owned payload again; malformed/nonfinite output never reaches Apply.
|
||||||
|
var execution = ExecutionMotionReader.ReadSupported(proposal.CopyProgram(), source.Location, position, token);
|
||||||
|
if (!execution.HasCuttingContour)
|
||||||
|
throw new ArgumentException("Best-effort output has no cutting contour.");
|
||||||
|
findings.AddRange(checker.Check(execution, position, source.SourceOrdinal + 1, source.IsCutOff, token)
|
||||||
|
.Select(f => new CuttingPlanFinding(f.PartNumber is int part ? part - 1 : null,
|
||||||
|
f.PartNumber is int ordinal ? snapshot.Placements[ordinal - 1].SourcePart : null,
|
||||||
|
f.OtherPartNumber is int other ? other - 1 : null,
|
||||||
|
f.OtherPartNumber is int otherOrdinal ? snapshot.Placements[otherOrdinal - 1].SourcePart : null,
|
||||||
|
f.Kind, f.Message)));
|
||||||
|
distance += execution.RapidDistanceFrom(position);
|
||||||
|
if (!double.IsFinite(distance))
|
||||||
|
throw new ArgumentException("Best-effort travel is not finite.");
|
||||||
|
position = execution.DeparturePoint;
|
||||||
|
done.Add(source.SourceOrdinal);
|
||||||
|
order.Add(proposal);
|
||||||
|
}
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
return new(CuttingPlanStatus.BestEffort, order, findings, rapidDistance: distance)
|
||||||
|
{ Snapshot = snapshot };
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return new(CuttingPlanStatus.Cancelled);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is ArgumentException or NotSupportedException
|
||||||
|
or InvalidOperationException or ArithmeticException)
|
||||||
|
{
|
||||||
|
findings.Add(new(null, null, null, null, PostVerificationKind.Incomplete, exception.Message));
|
||||||
|
return new(CuttingPlanStatus.UnsupportedGeometry, findings: findings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ internal sealed class CuttingDependencyGraph
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal static CuttingDependencyGraph Build(IReadOnlyList<DependencyNode> nodes, Box plate,
|
internal static CuttingDependencyGraph Build(IReadOnlyList<DependencyNode> nodes, Box plate,
|
||||||
CancellationToken token)
|
CancellationToken token, Action<CuttingDependencyException> uncertain = null)
|
||||||
{
|
{
|
||||||
var edges = nodes.Select(_ => new SortedSet<int>()).ToArray();
|
var edges = nodes.Select(_ => new SortedSet<int>()).ToArray();
|
||||||
for (var cut = 0; cut < nodes.Count; cut++)
|
for (var cut = 0; cut < nodes.Count; cut++)
|
||||||
@@ -98,10 +98,25 @@ internal sealed class CuttingDependencyGraph
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
// Bounds only select candidates; containment itself is proven on native material.
|
// Bounds only select candidates; containment itself is proven on native material.
|
||||||
if (inner != host && !nodes[inner].IsCutOff && !nodes[host].IsCutOff
|
if (inner != host && !nodes[inner].IsCutOff && !nodes[host].IsCutOff
|
||||||
&& Within(nodes[inner].CleanBounds, nodes[host].HostBounds)
|
&& Within(nodes[inner].CleanBounds, nodes[host].HostBounds))
|
||||||
&& InsideCutout(nodes, inner, host, token))
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (InsideCutout(nodes, inner, host, token))
|
||||||
edges[host].Add(inner);
|
edges[host].Add(inner);
|
||||||
}
|
}
|
||||||
|
catch (CuttingDependencyException exception) when (uncertain != null
|
||||||
|
&& exception.Status == CuttingPlanStatus.UnsupportedGeometry)
|
||||||
|
{
|
||||||
|
uncertain(exception);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (uncertain != null
|
||||||
|
&& exception is ArgumentException or NotSupportedException)
|
||||||
|
{
|
||||||
|
uncertain(Ambiguous(inner, host, exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var graph = new CuttingDependencyGraph(edges.Select(e => e.ToArray()).ToArray());
|
var graph = new CuttingDependencyGraph(edges.Select(e => e.ToArray()).ToArray());
|
||||||
if (graph.FindCycle() is { } cyclic)
|
if (graph.FindCycle() is { } cyclic)
|
||||||
throw new CuttingDependencyException(CuttingPlanStatus.ConstraintConflict, cyclic, null,
|
throw new CuttingDependencyException(CuttingPlanStatus.ConstraintConflict, cyclic, null,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -99,7 +103,11 @@ public sealed class CuttingPlanBatch
|
|||||||
var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||||
expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), token);
|
expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), token);
|
||||||
var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
|
var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
|
||||||
entries[index] = new(plate, plateNumbers?[index] ?? index + 1, reorder, keepOrder, overlap);
|
var fallback = reorder ?? keepOrder;
|
||||||
|
if (fallback.Failure == CuttingPlanStatus.UnsupportedGeometry)
|
||||||
|
fallback = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||||
|
confirmedParameters: confirmedParameters, preservePartOrder: preservePartOrder), token, bestEffort: true);
|
||||||
|
entries[index] = new(plate, plateNumbers?[index] ?? index + 1, reorder, keepOrder, overlap, fallback);
|
||||||
}
|
}
|
||||||
return new(entries, owned);
|
return new(entries, owned);
|
||||||
}
|
}
|
||||||
@@ -112,7 +120,8 @@ public sealed class CuttingPlanBatch
|
|||||||
/// Checks and plans every plate from its captured snapshots. Safe on a worker: live plates are
|
/// Checks and plans every plate from its captured snapshots. Safe on a worker: live plates are
|
||||||
/// not read. Overlapping material blocks a plate whatever its route. A free search that ends
|
/// not read. Overlapping material blocks a plate whatever its route. A free search that ends
|
||||||
/// without a complete plan within its budget is retried with the current order; the proposal
|
/// without a complete plan within its budget is retried with the current order; the proposal
|
||||||
/// reports that it kept the order.
|
/// reports that it kept the order. Incomplete geometry also gets a separately labelled,
|
||||||
|
/// unverified fallback when supported closed contours can still be emitted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public CuttingPlanProposal Plan(IProgress<CuttingPlanProgress> progress = null,
|
public CuttingPlanProposal Plan(IProgress<CuttingPlanProgress> progress = null,
|
||||||
CancellationToken token = default)
|
CancellationToken token = default)
|
||||||
@@ -137,22 +146,50 @@ 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, reorder, null, overlap);
|
plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.KeepingOrder));
|
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.KeepingOrder));
|
||||||
plans[index] = new(entry.Plate, entry.Number, CuttingPlanService.Plan(entry.KeepOrder, token), reorder,
|
plans[index] = new(entry.Plate, entry.Number,
|
||||||
overlap);
|
WithFallback(entry, CuttingPlanService.Plan(entry.KeepOrder, token), token), reorder, overlap);
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
{
|
||||||
|
if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null)
|
||||||
|
return strict;
|
||||||
|
var fallback = BestEffortCuttingPlan.Plan(entry.Fallback, token);
|
||||||
|
if (fallback.Status != CuttingPlanStatus.BestEffort)
|
||||||
|
return fallback.Status == CuttingPlanStatus.Cancelled ? fallback : strict;
|
||||||
|
return new(CuttingPlanStatus.BestEffort, fallback.ProposedOrder,
|
||||||
|
strict.Findings.Concat(fallback.Findings).Distinct(), rapidDistance: fallback.RapidDistance)
|
||||||
|
{ Snapshot = entry.Fallback };
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record Entry(Plate Plate, int Number, CuttingPlanSnapshot Reorder, CuttingPlanSnapshot KeepOrder,
|
private sealed record Entry(Plate Plate, int Number, CuttingPlanSnapshot Reorder, CuttingPlanSnapshot KeepOrder,
|
||||||
PlateOverlapSnapshot Overlap);
|
PlateOverlapSnapshot Overlap, CuttingPlanSnapshot Fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One plate's outcome inside a <see cref="CuttingPlanProposal"/>.</summary>
|
/// <summary>One plate's outcome inside a <see cref="CuttingPlanProposal"/>.</summary>
|
||||||
@@ -188,6 +225,10 @@ public sealed class CuttingPlanPlateResult
|
|||||||
|
|
||||||
public bool IsOverlapClear => Overlap is { IsComplete: true } && Overlap.Pairs.Count == 0;
|
public bool IsOverlapClear => Overlap is { IsComplete: true } && Overlap.Pairs.Count == 0;
|
||||||
public bool IsReady => IsRouteReady && IsOverlapClear;
|
public bool IsReady => IsRouteReady && IsOverlapClear;
|
||||||
|
|
||||||
|
/// <summary>Owned readable output is available, but not all geometric checks passed.</summary>
|
||||||
|
public bool CanApplyWithWarnings => Overlap != null && Overlap.Pairs.Count == 0
|
||||||
|
&& (IsRouteReady || Result.Status == CuttingPlanStatus.BestEffort);
|
||||||
public int PartCount => Result.ProposedOrder.Count;
|
public int PartCount => Result.ProposedOrder.Count;
|
||||||
public int RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated);
|
public int RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated);
|
||||||
|
|
||||||
@@ -195,8 +236,12 @@ 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 and is offered only when every plate is ready.
|
/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CuttingPlanProposal
|
public sealed class CuttingPlanProposal
|
||||||
{
|
{
|
||||||
@@ -216,17 +261,55 @@ public sealed class CuttingPlanProposal
|
|||||||
public bool CanApply => Plates.Count > 0 && !IsCancelled && ownedParameters != null
|
public bool CanApply => Plates.Count > 0 && !IsCancelled && ownedParameters != null
|
||||||
&& Plates.All(p => p.IsReady);
|
&& Plates.All(p => p.IsReady);
|
||||||
|
|
||||||
|
/// <summary>All plates have usable output, possibly requiring explicit warning acceptance.</summary>
|
||||||
|
public bool CanApplyWithWarnings => Plates.Count > 0 && !IsCancelled && ownedParameters != null
|
||||||
|
&& Plates.All(p => p.CanApplyWithWarnings);
|
||||||
|
|
||||||
|
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
|
||||||
/// of the confirmed parameters as its cutting settings; any other status changes nothing.
|
/// of the confirmed parameters as its cutting settings; any other status changes nothing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public CuttingCommitResult Apply(CancellationToken token = default)
|
public CuttingCommitResult Apply(CancellationToken token = default) => Apply(false, token);
|
||||||
|
|
||||||
|
/// <summary>Explicit per-proposal acceptance; never grants consent to post CNC output.</summary>
|
||||||
|
public CuttingCommitResult Apply(bool acceptWarnings, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
if (!CanApply)
|
if (!(CanApply || acceptWarnings && CanApplyWithWarnings))
|
||||||
return new(CuttingCommitStatus.InvalidInput,
|
return new(CuttingCommitStatus.InvalidInput,
|
||||||
"Every plate must have a ready plan before anything is applied.");
|
"Every plate must have a ready plan before anything is applied.");
|
||||||
var commit = CuttingPlanService.Apply(Plates.Select(p => p.Result), token);
|
var commit = CuttingPlanService.Apply(Plates.Select(p => p.Result), token, null, acceptWarnings);
|
||||||
if (commit.Status == CuttingCommitStatus.Applied)
|
if (commit.Status == CuttingCommitStatus.Applied)
|
||||||
foreach (var plate in Plates)
|
foreach (var plate in Plates)
|
||||||
plate.Plate.CuttingParameters = OwnedCuttingParameters.Copy(ownedParameters);
|
plate.Plate.CuttingParameters = OwnedCuttingParameters.Copy(ownedParameters);
|
||||||
@@ -234,18 +317,17 @@ public sealed class CuttingPlanProposal
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A detached copy of a ready plate for display, in the proposed order with the proposed
|
/// A detached copy of a ready or explicitly unverified proposal for display, or null.
|
||||||
/// programs, or null. A refused plate is not previewed: its program graphs may be unsafe to
|
/// Refused graphs are never cloned. Changed plates are not previewed at uncaptured poses.
|
||||||
/// copy. Neither is a plate that changed after planning, which would draw replayed programs at
|
/// The copy has quantity zero, so drawing quantities stay put.
|
||||||
/// poses that were never checked. The copy has quantity zero, so drawing quantities stay put.
|
|
||||||
/// Call it on the thread that owns the plates.
|
/// Call it on the thread that owns the plates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Plate BuildPreview(int index)
|
public Plate BuildPreview(int index)
|
||||||
{
|
{
|
||||||
var planned = Plates[index];
|
var planned = Plates[index];
|
||||||
if (!planned.IsReady || planned.Result.Snapshot?.PlateState?.IsCurrent() != true)
|
if (!planned.CanApplyWithWarnings || planned.Result.Snapshot?.PlateState?.IsCurrent() != true)
|
||||||
return null;
|
return null;
|
||||||
// Unchanged since capture: live poses, plate and programs are the validated, replayed ones.
|
// Unchanged since capture: graphs are supported and the preview uses the exact proposal.
|
||||||
var source = planned.Plate;
|
var source = planned.Plate;
|
||||||
var preview = new Plate(source.Size)
|
var preview = new Plate(source.Size)
|
||||||
{
|
{
|
||||||
@@ -267,15 +349,18 @@ 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)
|
||||||
lines.Add($"Ready to apply to {Count(Plates.Count, "plate")}: "
|
lines.Add($"Ready to apply to {Count(Plates.Count, "plate")}: "
|
||||||
+ $"{Count(Plates.Count(p => p.OrderChanged), "plate")} with a new part order, "
|
+ $"{Count(Plates.Count(p => p.OrderChanged), "plate")} with a new part order, "
|
||||||
+ $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated.");
|
+ $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated.");
|
||||||
|
else if (RequiresWarningAcceptance)
|
||||||
|
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.");
|
||||||
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)
|
||||||
@@ -307,6 +392,17 @@ public sealed class CuttingPlanProposal
|
|||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (plate.CanApplyWithWarnings)
|
||||||
|
{
|
||||||
|
yield return heading + $"best-effort, unverified. {Count(plate.PartCount, "part")}, "
|
||||||
|
+ $"{plate.RegeneratedCount} regenerated; review lead-ins and cutting order.";
|
||||||
|
// Unverified Apply requires the operator to see every finding, not just a
|
||||||
|
// bounded diagnostic summary that hides later parts behind "... and N more".
|
||||||
|
foreach (var line in DescribeOverlap(plate.Overlap).Concat(result.Findings.Select(DescribeFinding)))
|
||||||
|
yield return " - " + line;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
var missingLead = !plate.IsRouteReady && result.Status != CuttingPlanStatus.Cancelled
|
var missingLead = !plate.IsRouteReady && result.Status != CuttingPlanStatus.Cancelled
|
||||||
&& result.Findings.Any(f => f.Kind == PostVerificationKind.MissingLeadIn);
|
&& result.Findings.Any(f => f.Kind == PostVerificationKind.MissingLeadIn);
|
||||||
yield return heading + (plate.IsRouteReady
|
yield return heading + (plate.IsRouteReady
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ public sealed class CuttingPlanSnapshot
|
|||||||
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null,
|
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null,
|
||||||
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
|
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
|
||||||
Action<int> expansionObserver = null, PlateCuttingState plateState = null,
|
Action<int> expansionObserver = null, PlateCuttingState plateState = null,
|
||||||
CuttingParameters ownedParameters = null, CuttingDependencyGraph dependencies = null)
|
CuttingParameters ownedParameters = null, CuttingDependencyGraph dependencies = null, bool bestEffort = false)
|
||||||
{
|
{
|
||||||
|
BestEffort = bestEffort;
|
||||||
PlateState = plateState;
|
PlateState = plateState;
|
||||||
OwnedParameters = ownedParameters;
|
OwnedParameters = ownedParameters;
|
||||||
Placements = Array.AsReadOnly(placements.ToArray());
|
Placements = Array.AsReadOnly(placements.ToArray());
|
||||||
@@ -82,6 +83,7 @@ public sealed class CuttingPlanSnapshot
|
|||||||
internal PlateCuttingState PlateState { get; }
|
internal PlateCuttingState PlateState { get; }
|
||||||
/// <summary>Owned copy of the confirmed parameters taken at capture; never the caller's object.</summary>
|
/// <summary>Owned copy of the confirmed parameters taken at capture; never the caller's object.</summary>
|
||||||
internal CuttingParameters OwnedParameters { get; }
|
internal CuttingParameters OwnedParameters { get; }
|
||||||
|
internal bool BestEffort { get; }
|
||||||
internal bool Regeneration { get; }
|
internal bool Regeneration { get; }
|
||||||
internal bool PreservePartOrder { get; }
|
internal bool PreservePartOrder { get; }
|
||||||
internal int MaxEntries { get; }
|
internal int MaxEntries { get; }
|
||||||
@@ -91,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
|
||||||
@@ -159,7 +168,9 @@ public enum CuttingPlanStatus
|
|||||||
UnsupportedGeometry,
|
UnsupportedGeometry,
|
||||||
InvalidInput,
|
InvalidInput,
|
||||||
NoSolutionWithinBudget,
|
NoSolutionWithinBudget,
|
||||||
Cancelled
|
Cancelled,
|
||||||
|
/// <summary>Usable owned programs, but geometric safety checks did not pass. Never Ready.</summary>
|
||||||
|
BestEffort
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ordinals are zero-based source positions, not proposed sequence positions.</summary>
|
/// <summary>Ordinals are zero-based source positions, not proposed sequence positions.</summary>
|
||||||
@@ -169,7 +180,8 @@ public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart,
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// A replayed direct-XY proposal, optionally with regenerated programs, that respects the
|
/// A replayed direct-XY proposal, optionally with regenerated programs, that respects the
|
||||||
/// captured cutoff/containment prerequisites. Not physical safety or posting consent. Apply
|
/// captured cutoff/containment prerequisites. Not physical safety or posting consent. Apply
|
||||||
/// installs it only for a plate-scoped request whose plate is unchanged. Failures contain no proposals.
|
/// installs it only for a plate-scoped request whose plate is unchanged. BestEffort is explicitly
|
||||||
|
/// unverified and requires batch warning acceptance; other failures contain no proposals.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CuttingPlanResult
|
public sealed class CuttingPlanResult
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ public static class CuttingPlanService
|
|||||||
/// Read stable caller-owned sources once into privately owned programs and geometry.
|
/// Read stable caller-owned sources once into privately owned programs and geometry.
|
||||||
/// No private Plates, settings aliases, quantity updates or source subcall rebinding.
|
/// No private Plates, settings aliases, quantity updates or source subcall rebinding.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default)
|
public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default) =>
|
||||||
|
Capture(request, token, bestEffort: false);
|
||||||
|
|
||||||
|
internal static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token, bool bestEffort)
|
||||||
{
|
{
|
||||||
var placements = new List<FixedProgramPlacement>();
|
var placements = new List<FixedProgramPlacement>();
|
||||||
|
var warnings = new List<CuttingPlanFinding>();
|
||||||
Part source = null;
|
Part source = null;
|
||||||
int? ordinal = null;
|
int? ordinal = null;
|
||||||
try
|
try
|
||||||
@@ -107,7 +111,11 @@ public static class CuttingPlanService
|
|||||||
{
|
{
|
||||||
material = LeadMaterialSnapshot.Capture(ownedClean, location, token);
|
material = LeadMaterialSnapshot.Capture(ownedClean, location, token);
|
||||||
if (!material.IsComplete)
|
if (!material.IsComplete)
|
||||||
|
{
|
||||||
|
if (!bestEffort)
|
||||||
throw new NotSupportedException(material.Reason);
|
throw new NotSupportedException(material.Reason);
|
||||||
|
warnings.Add(new(index, source, null, null, PostVerificationKind.Incomplete, material.Reason));
|
||||||
|
}
|
||||||
if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source)))
|
if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source)))
|
||||||
prepared = PreparedContours.Capture(ownedClean, request.ConfirmedParameters, token);
|
prepared = PreparedContours.Capture(ownedClean, request.ConfirmedParameters, token);
|
||||||
}
|
}
|
||||||
@@ -122,13 +130,16 @@ public static class CuttingPlanService
|
|||||||
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
|
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
|
||||||
source = null;
|
source = null;
|
||||||
ordinal = null;
|
ordinal = null;
|
||||||
var dependencies = CuttingDependencyGraph.Build(nodes, request.Plate?.BoundingBox(includeParts: false), token);
|
var dependencies = CuttingDependencyGraph.Build(nodes, request.Plate?.BoundingBox(includeParts: false), token,
|
||||||
|
bestEffort ? exception => warnings.Add(new(exception.Ordinal, request.Parts[exception.Ordinal],
|
||||||
|
exception.Other, exception.Other is int other ? request.Parts[other] : null,
|
||||||
|
PostVerificationKind.Incomplete, exception.Message)) : null);
|
||||||
return new(placements, request.StartPoint, request.ExpansionBudget, regeneration: request.ConfirmedParameters != null,
|
return new(placements, request.StartPoint, request.ExpansionBudget, regeneration: request.ConfirmedParameters != null,
|
||||||
preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries,
|
preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries,
|
||||||
expansionObserver: request.ExpansionObserver, plateState: plateState,
|
expansionObserver: request.ExpansionObserver, plateState: plateState,
|
||||||
ownedParameters: request.ConfirmedParameters == null ? null
|
ownedParameters: request.ConfirmedParameters == null ? null
|
||||||
: OwnedCuttingParameters.Copy(request.ConfirmedParameters),
|
: OwnedCuttingParameters.Copy(request.ConfirmedParameters),
|
||||||
dependencies: dependencies);
|
dependencies: dependencies, findings: warnings, bestEffort: bestEffort);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -175,14 +186,15 @@ public static class CuttingPlanService
|
|||||||
|
|
||||||
// beforeInstall is the commit's install-boundary test seam.
|
// beforeInstall is the commit's install-boundary test seam.
|
||||||
internal static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token,
|
internal static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token,
|
||||||
Action<Plate, Part> beforeInstall)
|
Action<Plate, Part> beforeInstall, bool acceptUnverified = false)
|
||||||
{
|
{
|
||||||
var plans = new List<PlateCuttingPlan>();
|
var plans = new List<PlateCuttingPlan>();
|
||||||
foreach (var result in results ?? [])
|
foreach (var result in results ?? [])
|
||||||
{
|
{
|
||||||
var snapshot = result?.Snapshot;
|
var snapshot = result?.Snapshot;
|
||||||
if (result?.Status != CuttingPlanStatus.Ready || !result.IndependentlyReplayed
|
var eligible = result != null && (result.Status == CuttingPlanStatus.Ready && result.IndependentlyReplayed
|
||||||
|| snapshot?.PlateState == null || result.ProposedOrder.Count != snapshot.Placements.Count)
|
|| acceptUnverified && result.Status == CuttingPlanStatus.BestEffort);
|
||||||
|
if (!eligible || snapshot?.PlateState == null || result.ProposedOrder.Count != snapshot.Placements.Count)
|
||||||
return new(CuttingCommitStatus.InvalidInput,
|
return new(CuttingCommitStatus.InvalidInput,
|
||||||
"Only Ready, independently replayed plate-scoped proposals can be applied.");
|
"Only Ready, independently replayed plate-scoped proposals can be applied.");
|
||||||
var programs = new List<PlannedPartProgram>();
|
var programs = new List<PlannedPartProgram>();
|
||||||
@@ -211,6 +223,8 @@ public static class CuttingPlanService
|
|||||||
return new(CuttingPlanStatus.InvalidInput);
|
return new(CuttingPlanStatus.InvalidInput);
|
||||||
if (snapshot.Failure is { } failure)
|
if (snapshot.Failure is { } failure)
|
||||||
return new(failure, findings: snapshot.Findings);
|
return new(failure, findings: snapshot.Findings);
|
||||||
|
if (snapshot.BestEffort)
|
||||||
|
return BestEffortCuttingPlan.Plan(snapshot, token);
|
||||||
if (snapshot.Placements.Count == 0)
|
if (snapshot.Placements.Count == 0)
|
||||||
return snapshot.PlateState == null ? new(CuttingPlanStatus.InvalidInput)
|
return snapshot.PlateState == null ? new(CuttingPlanStatus.InvalidInput)
|
||||||
: new(CuttingPlanStatus.Ready, independentlyReplayed: true); // Empty plate: unchanged no-op.
|
: new(CuttingPlanStatus.Ready, independentlyReplayed: true); // Empty plate: unchanged no-op.
|
||||||
@@ -227,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));
|
||||||
}
|
}
|
||||||
@@ -265,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>();
|
||||||
@@ -299,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>();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -191,11 +191,13 @@ internal static class JointCuttingPlanSearch
|
|||||||
/// </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)
|
||||||
{
|
{
|
||||||
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));
|
||||||
|
try
|
||||||
|
{
|
||||||
while (stack.Count != 0)
|
while (stack.Count != 0)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -210,16 +212,24 @@ internal static class JointCuttingPlanSearch
|
|||||||
progressExpansions = Expansions;
|
progressExpansions = Expansions;
|
||||||
else if (stall is int limit && Expansions - progressExpansions > limit)
|
else if (stall is int limit && Expansions - progressExpansions > limit)
|
||||||
return attempt;
|
return attempt;
|
||||||
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly);
|
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly).GetEnumerator();
|
||||||
if (frame.Next == frame.Children.Length)
|
if (!frame.Children.MoveNext())
|
||||||
{
|
{
|
||||||
stack.Pop();
|
stack.Pop().Children.Dispose();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
stack.Push(new(frame.Children[frame.Next++].Node));
|
stack.Push(new(frame.Children.Current.Node));
|
||||||
}
|
}
|
||||||
return attempt;
|
return attempt;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Ready, stalled, cancelled and budget-exhausted searches can all leave
|
||||||
|
// suspended siblings. Release their captured prefixes on every exit.
|
||||||
|
foreach (var frame in stack)
|
||||||
|
frame.Children?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The NEXT cut's centre that the outside entry should face, or null for the last
|
/// The NEXT cut's centre that the outside entry should face, or null for the last
|
||||||
@@ -259,8 +269,14 @@ 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)
|
||||||
{
|
{
|
||||||
|
// 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.
|
||||||
|
if (sequence != null || node.Active != null)
|
||||||
|
return Expand(node, sequence, attempt, preferredOnly);
|
||||||
var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
|
var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
|
||||||
if (edges.Count <= 1)
|
if (edges.Count <= 1)
|
||||||
return edges.ToArray();
|
return edges.ToArray();
|
||||||
@@ -334,8 +350,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();
|
||||||
@@ -593,7 +611,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; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +290,63 @@ namespace OpenNest.Engine.Fill
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settles a copied placement against the plate in both axis orders, choosing
|
||||||
|
/// the group closest to the quadrant's work-area corner. A coarse box pass
|
||||||
|
/// runs only when no moving box starts inside an existing part's box.
|
||||||
|
/// </summary>
|
||||||
|
public static void SettlePlacement(
|
||||||
|
List<Part> movingParts,
|
||||||
|
Plate plate,
|
||||||
|
PushDirection horizontal,
|
||||||
|
PushDirection vertical,
|
||||||
|
int maxIterations = 20
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (movingParts.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var workArea = plate.WorkArea();
|
||||||
|
var skipBoxes = movingParts.Any(moving =>
|
||||||
|
plate.Parts.Any(obstacle => moving.BoundingBox.Intersects(obstacle.BoundingBox))
|
||||||
|
);
|
||||||
|
var bestScore = double.MaxValue;
|
||||||
|
Vector[] best = null;
|
||||||
|
|
||||||
|
foreach (var first in new[] { horizontal, vertical })
|
||||||
|
{
|
||||||
|
var second = first == horizontal ? vertical : horizontal;
|
||||||
|
var trial = movingParts.Select(p => (Part)p.Clone()).ToList();
|
||||||
|
if (!skipBoxes)
|
||||||
|
{
|
||||||
|
PushBoundingBox(trial, plate, first);
|
||||||
|
PushBoundingBox(trial, plate, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < maxIterations; i++)
|
||||||
|
{
|
||||||
|
var moved = Push(trial, plate, first) + Push(trial, plate, second);
|
||||||
|
if (moved < 0.01)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bounds = trial.GetBoundingBox();
|
||||||
|
var dx = horizontal == PushDirection.Left
|
||||||
|
? bounds.Left - workArea.Left : workArea.Right - bounds.Right;
|
||||||
|
var dy = vertical == PushDirection.Down
|
||||||
|
? bounds.Bottom - workArea.Bottom : workArea.Top - bounds.Top;
|
||||||
|
var score = dx * dx + dy * dy;
|
||||||
|
if (score < bestScore)
|
||||||
|
{
|
||||||
|
bestScore = score;
|
||||||
|
best = trial.Select(p => p.Location).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < movingParts.Count; i++)
|
||||||
|
movingParts[i].Location = best[i];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Repeatedly pushes parts left then down until total movement per
|
/// Repeatedly pushes parts left then down until total movement per
|
||||||
/// iteration falls below the given threshold.
|
/// iteration falls below the given threshold.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -188,7 +188,7 @@ public sealed class StockLadderNestingEngine : INestingEngine
|
|||||||
var demand = old.SelectMany(s => s.Placements)
|
var demand = old.SelectMany(s => s.Placements)
|
||||||
.GroupBy(p => p.PartId)
|
.GroupBy(p => p.PartId)
|
||||||
.ToDictionary(g => g.Key, g => g.Count());
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
var baseline = old.Sum(s => NestJobCost.NetSheetArea(job, s));
|
var baseline = NestJobCost.RequireFinite(old.Sum(s => NestJobCost.NetSheetCost(job, s)));
|
||||||
NestJobPlateResult replacement = null;
|
NestJobPlateResult replacement = null;
|
||||||
foreach (var stock in job.Plates)
|
foreach (var stock in job.Plates)
|
||||||
{
|
{
|
||||||
@@ -198,8 +198,8 @@ public sealed class StockLadderNestingEngine : INestingEngine
|
|||||||
continue;
|
continue;
|
||||||
// Even the maximum possible salvage credit cannot beat the incumbent.
|
// Even the maximum possible salvage credit cannot beat the incumbent.
|
||||||
var lowerBound =
|
var lowerBound =
|
||||||
stock.Size.Width * stock.Size.Length * (1 - job.Options.SalvageRate);
|
NestJobCost.GrossSheetCost(stock) * (1 - job.Options.SalvageRate);
|
||||||
if (lowerBound >= baseline - 1e-9)
|
if (lowerBound >= baseline - (stock.Cost.HasValue ? 0 : 1e-9))
|
||||||
continue;
|
continue;
|
||||||
if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id)))
|
if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id)))
|
||||||
continue;
|
continue;
|
||||||
@@ -215,8 +215,8 @@ public sealed class StockLadderNestingEngine : INestingEngine
|
|||||||
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value))
|
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value))
|
||||||
continue;
|
continue;
|
||||||
var trial = new NestJobPlateResult(index, stock, candidate.Placements);
|
var trial = new NestJobPlateResult(index, stock, candidate.Placements);
|
||||||
var cost = NestJobCost.NetSheetArea(job, trial);
|
var cost = NestJobCost.NetSheetCost(job, trial);
|
||||||
if (cost >= baseline - 1e-9)
|
if (cost >= baseline - (stock.Cost.HasValue ? 0 : 1e-9))
|
||||||
continue;
|
continue;
|
||||||
baseline = cost;
|
baseline = cost;
|
||||||
replacement = trial;
|
replacement = trial;
|
||||||
|
|||||||
@@ -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), []);
|
|
||||||
|
|
||||||
// Reserve map-grid gutters, then re-place what still fits at overview scale.
|
|
||||||
fit = Fit.Create(union, new XRect(GutterLeft, GutterTop, width - GutterLeft - Inset, overviewHeight - GutterTop - Inset));
|
|
||||||
labels = PlaceLabels(plate, shapes, obstacles, all, fit, overviewView, font, out failed);
|
|
||||||
var failedSet = failed.ToHashSet();
|
|
||||||
var required = failed.Max(index => RequiredScale(plate, index, font));
|
|
||||||
var inner = new XRect(Inset, Inset, width - 2 * Inset, detailHeight - 2 * Inset);
|
|
||||||
var legibleGrid = false;
|
|
||||||
foreach (var zoom in Zooms)
|
|
||||||
{
|
{
|
||||||
var scale = required * zoom;
|
var boxes = plate.Parts.Select(part => fit.Rect(part.Geometry.Bounds)).ToList();
|
||||||
// Cells evenly divide the sheet (no overrun); each is at most the size a detail view
|
var cutoffs = plate.Cutoffs.Select(cutoff => fit.Rect(cutoff.Bounds)).ToList();
|
||||||
// shows at the required scale, so the zoom only increases.
|
var groups = new List<DiagramGroup>();
|
||||||
var columns = System.Math.Max(1, (int)System.Math.Ceiling(union.Length * scale * (1 + 2 * DetailMargin) / inner.Width));
|
// A small screen-space gap also accommodates tightly packed parts with unset spacing.
|
||||||
var rows = System.Math.Max(1, (int)System.Math.Ceiling(union.Width * scale * (1 + 2 * DetailMargin) / inner.Height));
|
var gap = System.Math.Max(4, plate.PartSpacing * fit.Scale * 2);
|
||||||
var cellLength = union.Length / columns;
|
var remaining = failed.ToHashSet();
|
||||||
var cellWidth = union.Width / rows;
|
foreach (var seed in failed)
|
||||||
var grid = new DiagramGrid(union, cellLength, cellWidth, columns, rows, []);
|
{
|
||||||
// Every grid line and gutter name must stay readable on the overview.
|
if (!remaining.Remove(seed))
|
||||||
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);
|
|
||||||
// Parts missing from the overview claim label space first.
|
|
||||||
var members = all.Where(index => CellOf(index) == (row, column))
|
|
||||||
.OrderBy(index => failedSet.Contains(index) ? 0 : 1).ToList();
|
|
||||||
var detailLabels = PlaceLabels(plate, shapes, obstacles, members, detailFit,
|
|
||||||
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 (unplaced.Count == 0)
|
|
||||||
return new PlateDiagramPlan(new DiagramView(canvas, fit, labels, grid, null, null), details);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
if (remaining.Contains(candidate) && plate.Parts[candidate].ReportId == id
|
||||||
var x = (bounds.Left + bounds.Right) / 2;
|
&& nearby.IntersectsWith(boxes[candidate]))
|
||||||
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);
|
remaining.Remove(candidate);
|
||||||
var row = ReportText.Size(ReportText.RowName(grid.Rows - 1), font);
|
members.Add(candidate);
|
||||||
return grid.CellLength * fit.Scale >= column.Width + 2 && grid.CellWidth * fit.Scale >= row.Height + 1
|
}
|
||||||
&& row.Width + 2 <= GutterLeft && column.Height + 2 <= GutterTop;
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public sealed class NestDatabaseConcurrencyTests
|
|||||||
public async Task Concurrency_EveryOperationWaitsForTheSameMonitor(string operation)
|
public async Task Concurrency_EveryOperationWaitsForTheSameMonitor(string operation)
|
||||||
{
|
{
|
||||||
using var factory = new ServerFactory();
|
using var factory = new ServerFactory();
|
||||||
using var database = new NestDatabase(factory.DatabasePath);
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
var record = new NestRecord { Name = "Monitor ownership fixture" };
|
var record = new NestRecord { Name = "Monitor ownership fixture" };
|
||||||
database.Insert(id, record, new byte[] { 1, 2, 3 });
|
database.Insert(id, record, new byte[] { 1, 2, 3 });
|
||||||
@@ -75,7 +75,7 @@ public sealed class NestDatabaseConcurrencyTests
|
|||||||
public void Concurrency_AllSqlStepsIncludingReadbacks_OwnTheMonitor()
|
public void Concurrency_AllSqlStepsIncludingReadbacks_OwnTheMonitor()
|
||||||
{
|
{
|
||||||
using var factory = new ServerFactory();
|
using var factory = new ServerFactory();
|
||||||
using var database = new NestDatabase(factory.DatabasePath);
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||||
var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!
|
var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
.GetValue(database)!;
|
.GetValue(database)!;
|
||||||
var connection = (SqliteConnection)typeof(NestDatabase)
|
var connection = (SqliteConnection)typeof(NestDatabase)
|
||||||
@@ -125,7 +125,7 @@ public sealed class NestDatabaseConcurrencyTests
|
|||||||
public async Task Concurrency_DatabaseReadersAndWriters_PreserveExactRecords()
|
public async Task Concurrency_DatabaseReadersAndWriters_PreserveExactRecords()
|
||||||
{
|
{
|
||||||
using var factory = new ServerFactory();
|
using var factory = new ServerFactory();
|
||||||
using var database = new NestDatabase(factory.DatabasePath);
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||||
using var start = new Barrier(4);
|
using var start = new Barrier(4);
|
||||||
var workers = Enumerable.Range(0, 4).Select(lane => Task.Factory.StartNew(() =>
|
var workers = Enumerable.Range(0, 4).Select(lane => Task.Factory.StartNew(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
|
||||||
@@ -16,6 +15,7 @@ namespace OpenNest.Server.Tests;
|
|||||||
public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
||||||
{
|
{
|
||||||
private bool _cleanedUp;
|
private bool _cleanedUp;
|
||||||
|
private NestDatabase? _database;
|
||||||
|
|
||||||
public ServerFactory(string? databasePath = null)
|
public ServerFactory(string? databasePath = null)
|
||||||
{
|
{
|
||||||
@@ -34,7 +34,8 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
|||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
services.RemoveAll<NestDatabase>();
|
services.RemoveAll<NestDatabase>();
|
||||||
services.AddSingleton(_ => new NestDatabase(DatabasePath));
|
// Register an externally owned instance; this factory closes it before deleting its file.
|
||||||
|
services.AddSingleton(_database = new NestDatabase(DatabasePath, pooling: false));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +46,7 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
_cleanedUp = true;
|
_cleanedUp = true;
|
||||||
// Pooled connections would keep the file open on Windows after the host disposed it.
|
_database?.Dispose();
|
||||||
SqliteConnection.ClearAllPools();
|
|
||||||
if (Directory.Exists(DirectoryPath))
|
if (Directory.Exists(DirectoryPath))
|
||||||
Directory.Delete(DirectoryPath, recursive: true);
|
Directory.Delete(DirectoryPath, recursive: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,18 @@ public sealed class NestDatabase : IDisposable
|
|||||||
private readonly object _sync = new();
|
private readonly object _sync = new();
|
||||||
private readonly SqliteConnection _connection;
|
private readonly SqliteConnection _connection;
|
||||||
|
|
||||||
public NestDatabase(string databasePath)
|
public NestDatabase(string databasePath, bool pooling = true)
|
||||||
{
|
{
|
||||||
var directory = Path.GetDirectoryName(Path.GetFullPath(databasePath));
|
var directory = Path.GetDirectoryName(Path.GetFullPath(databasePath));
|
||||||
if (!string.IsNullOrEmpty(directory))
|
if (!string.IsNullOrEmpty(directory))
|
||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
_connection = new SqliteConnection($"Data Source={databasePath}");
|
// Test databases are short-lived files: disabling pooling releases Windows file handles on dispose.
|
||||||
|
_connection = new SqliteConnection(new SqliteConnectionStringBuilder
|
||||||
|
{
|
||||||
|
DataSource = databasePath,
|
||||||
|
Pooling = pooling,
|
||||||
|
}.ToString());
|
||||||
_connection.Open();
|
_connection.Open();
|
||||||
Execute("""
|
Execute("""
|
||||||
PRAGMA journal_mode=WAL;
|
PRAGMA journal_mode=WAL;
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.Engine.CuttingPlanning;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
public class BestEffortCuttingPlanTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TouchingContours_OfferAPreviewWithoutChangingTheDrawing()
|
||||||
|
{
|
||||||
|
var part = TouchingContours();
|
||||||
|
var plate = Plate(part);
|
||||||
|
var original = OwnedProgramCopy.Copy(part.BaseDrawing.Program);
|
||||||
|
var strict = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
||||||
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
||||||
|
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, strict.Status);
|
||||||
|
|
||||||
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
|
||||||
|
Assert.NotNull(proposal.BuildPreview(0));
|
||||||
|
Assert.False(proposal.CanApply);
|
||||||
|
Assert.False(part.HasManualLeadIns);
|
||||||
|
Assert.True(ProgramContent.Equal(original, part.BaseDrawing.Program));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnverifiedSummary_ShowsEveryWarningBeforeAcceptance()
|
||||||
|
{
|
||||||
|
var parts = Enumerable.Range(0, 6).Select(index =>
|
||||||
|
{
|
||||||
|
var part = TouchingContours();
|
||||||
|
part.Offset(index * 12, 0);
|
||||||
|
return part;
|
||||||
|
}).ToArray();
|
||||||
|
var proposal = CuttingPlanBatch.Capture([Plate(parts)], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance);
|
||||||
|
var findings = Assert.Single(proposal.Plates).Result.Findings.ToArray();
|
||||||
|
Assert.True(findings.Length > 5);
|
||||||
|
Assert.Contains(findings, finding => finding.SourceOrdinal == 5);
|
||||||
|
var summary = string.Join("\n", proposal.Describe("in"));
|
||||||
|
Assert.Contains("Part 6", summary);
|
||||||
|
Assert.DoesNotContain("... and", summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AcceptWarnings_InstallsOwnedProgramsAndLeavesStrictApplyClosed()
|
||||||
|
{
|
||||||
|
var part = TouchingContours();
|
||||||
|
var plate = Plate(part);
|
||||||
|
var source = OwnedProgramCopy.Copy(part.BaseDrawing.Program);
|
||||||
|
var pose = (part.Location, part.Rotation);
|
||||||
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
var result = Assert.Single(proposal.Plates).Result;
|
||||||
|
Assert.Equal(CuttingPlanStatus.BestEffort, result.Status);
|
||||||
|
Assert.False(result.IndependentlyReplayed);
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance);
|
||||||
|
Assert.Contains(result.Findings, f => f.Message.Contains("Material boundaries"));
|
||||||
|
Assert.Contains("unverified", string.Join("\n", proposal.Describe("in")));
|
||||||
|
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
|
||||||
|
Assert.Equal(CuttingCommitStatus.InvalidInput, CuttingPlanService.Apply([result]).Status);
|
||||||
|
var generated = Assert.Single(result.ProposedOrder);
|
||||||
|
Assert.True(generated.IsRegenerated);
|
||||||
|
Assert.Equal(3, generated.ContourChoices.Count);
|
||||||
|
Assert.Equal(2, generated.ContourChoices[^1].ContourOrdinal);
|
||||||
|
var originalCuts = ExecutionMotionReader.ReadSupported(source, part.Location, null).Motions
|
||||||
|
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Sum(m => m.Length);
|
||||||
|
var emittedCuts = ExecutionMotionReader.ReadSupported(generated.CopyProgram(), part.Location, null).Motions
|
||||||
|
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Sum(m => m.Length);
|
||||||
|
Assert.Equal(originalCuts, emittedCuts, 8);
|
||||||
|
var copy = generated.CopyProgram();
|
||||||
|
copy.Codes.Clear();
|
||||||
|
|
||||||
|
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: true).Status);
|
||||||
|
|
||||||
|
Assert.True(part.HasManualLeadIns);
|
||||||
|
Assert.True(ProgramContent.Equal(generated.CopyProgram(), part.Program));
|
||||||
|
Assert.True(ProgramContent.Equal(source, part.BaseDrawing.Program));
|
||||||
|
Assert.Equal(pose, (part.Location, part.Rotation));
|
||||||
|
Assert.Equal(CuttingCommitStatus.Stale, proposal.Apply(acceptWarnings: true).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("null")]
|
||||||
|
[InlineData("unknown")]
|
||||||
|
[InlineData("cycle")]
|
||||||
|
[InlineData("nonfinite")]
|
||||||
|
[InlineData("open")]
|
||||||
|
[InlineData("suppressed")]
|
||||||
|
public void UnreadableOrUnrepresentableInput_IsNeverWaived(string failure)
|
||||||
|
{
|
||||||
|
var part = TouchingContours();
|
||||||
|
var plate = Plate(part);
|
||||||
|
var program = part.BaseDrawing.Program;
|
||||||
|
switch (failure)
|
||||||
|
{
|
||||||
|
case "null": program.Codes = null; break;
|
||||||
|
case "unknown": program.Codes.Add(new CustomMove()); break;
|
||||||
|
case "cycle": program.Codes.Add(new SubProgramCall { Program = program }); break;
|
||||||
|
case "nonfinite": part.Location = new Vector(double.NaN, 1); break;
|
||||||
|
case "open": program.Codes.RemoveAt(program.Codes.Count - 1); break;
|
||||||
|
case "suppressed": program.Codes.OfType<Motion>().First().Suppressed = true; break;
|
||||||
|
}
|
||||||
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.False(proposal.CanApplyWithWarnings);
|
||||||
|
Assert.Null(proposal.BuildPreview(0));
|
||||||
|
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(acceptWarnings: true).Status);
|
||||||
|
Assert.False(part.HasManualLeadIns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MixedBatch_StaleOrCancelledChangesNothing()
|
||||||
|
{
|
||||||
|
var imperfect = TouchingContours();
|
||||||
|
var clean = new Part(new Drawing("clean", ExplicitContourTests.Square(false)), new Vector(1, 1));
|
||||||
|
var plates = new[] { Plate(clean), Plate(imperfect) };
|
||||||
|
var originals = new[] { clean.Program, imperfect.Program };
|
||||||
|
var batch = CuttingPlanBatch.Capture(plates, ExplicitContourTests.Parameters(), false);
|
||||||
|
var proposal = batch.Plan();
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance);
|
||||||
|
using var cancelled = new CancellationTokenSource();
|
||||||
|
cancelled.Cancel();
|
||||||
|
Assert.Equal(CuttingCommitStatus.Cancelled, proposal.Apply(true, cancelled.Token).Status);
|
||||||
|
Assert.False(batch.Plan(token: cancelled.Token).CanApplyWithWarnings);
|
||||||
|
imperfect.Offset(1, 0);
|
||||||
|
Assert.Null(proposal.BuildPreview(1));
|
||||||
|
Assert.Equal(CuttingCommitStatus.Stale, proposal.Apply(acceptWarnings: true).Status);
|
||||||
|
Assert.Equal(originals, plates.SelectMany(p => p.Parts).Select(p => p.Program));
|
||||||
|
Assert.All(plates, p => Assert.Null(p.CuttingParameters));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LockedPart_KeepsExactProgramWhileOtherPartsRegenerate()
|
||||||
|
{
|
||||||
|
var part = TouchingContours();
|
||||||
|
part.LeadInsLocked = true;
|
||||||
|
var program = part.Program;
|
||||||
|
var other = new Part(new Drawing("clean", ExplicitContourTests.Square(false)), new Vector(20, 1));
|
||||||
|
var proposal = CuttingPlanBatch.Capture([Plate(part, other)], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance);
|
||||||
|
Assert.False(proposal.Plates[0].Result.ProposedOrder[0].IsRegenerated);
|
||||||
|
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(true).Status);
|
||||||
|
Assert.Same(program, part.Program);
|
||||||
|
Assert.True(part.LeadInsLocked);
|
||||||
|
Assert.True(other.HasManualLeadIns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KnownPartOverlap_StillBlocksTheWholeBatch()
|
||||||
|
{
|
||||||
|
var first = new Part(new Drawing("first", ExplicitContourTests.Square(false)), new Vector(1, 1));
|
||||||
|
var second = new Part(new Drawing("second", ExplicitContourTests.Square(false)), new Vector(6, 6));
|
||||||
|
var proposal = CuttingPlanBatch.Capture([Plate(TouchingContours()), Plate(first, second)],
|
||||||
|
ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.False(proposal.CanApplyWithWarnings);
|
||||||
|
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(true).Status);
|
||||||
|
Assert.False(first.HasManualLeadIns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void ProvenCutoffDependencies_AreNotWaived(bool keepOrder)
|
||||||
|
{
|
||||||
|
var part = TouchingContours();
|
||||||
|
var cutProgram = new Program();
|
||||||
|
cutProgram.MoveTo(20, 0); cutProgram.LineTo(20, 20);
|
||||||
|
var cutoff = new Part(new Drawing("cutoff", cutProgram) { IsCutOff = true });
|
||||||
|
var plate = Plate(part, cutoff); // An orphaned cutoff must precede all ordinary parts.
|
||||||
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), keepOrder).Plan();
|
||||||
|
if (keepOrder)
|
||||||
|
{
|
||||||
|
Assert.False(proposal.CanApplyWithWarnings);
|
||||||
|
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(true).Status);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.True(proposal.CanApplyWithWarnings);
|
||||||
|
Assert.Same(cutoff, proposal.Plates[0].Result.ProposedOrder[0].SourcePart);
|
||||||
|
Assert.False(proposal.Plates[0].Result.ProposedOrder[0].IsRegenerated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelfIntersectingClosedContour_StillGetsEveryCutAndALead()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0); program.LineTo(8, 8); program.LineTo(0, 8);
|
||||||
|
program.LineTo(8, 0); program.LineTo(0, 0);
|
||||||
|
var part = new Part(new Drawing("crossed contour", program), new Vector(1, 1));
|
||||||
|
var proposal = CuttingPlanBatch.Capture([Plate(part)], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance);
|
||||||
|
var generated = Assert.Single(proposal.Plates[0].Result.ProposedOrder);
|
||||||
|
Assert.True(generated.IsRegenerated);
|
||||||
|
Assert.Single(generated.ContourChoices);
|
||||||
|
var moves = ExecutionMotionReader.ReadSupported(generated.CopyProgram(), part.Location, null).Motions;
|
||||||
|
Assert.Contains(moves, m => m.Layer == LayerType.Leadin);
|
||||||
|
var original = ExecutionMotionReader.ReadSupported(program, part.Location, null).Motions;
|
||||||
|
Assert.Equal(original.Where(m => !m.Rapid).Sum(m => m.Length),
|
||||||
|
moves.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Sum(m => m.Length), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProvenNestedPart_PrecedesItsHostEvenWithAnImperfectNeighbour()
|
||||||
|
{
|
||||||
|
var hostProgram = ExplicitContourTests.Square(false);
|
||||||
|
hostProgram.MoveTo(2, 2); hostProgram.LineTo(8, 2); hostProgram.LineTo(8, 8);
|
||||||
|
hostProgram.LineTo(2, 8); hostProgram.LineTo(2, 2);
|
||||||
|
var host = new Part(new Drawing("host", hostProgram));
|
||||||
|
var innerProgram = new Program();
|
||||||
|
innerProgram.MoveTo(0, 0); innerProgram.LineTo(0, 1); innerProgram.LineTo(1, 1);
|
||||||
|
innerProgram.LineTo(1, 0); innerProgram.LineTo(0, 0);
|
||||||
|
var inner = new Part(new Drawing("insert", innerProgram), new Vector(4, 4));
|
||||||
|
var imperfect = TouchingContours();
|
||||||
|
imperfect.Offset(20, 0);
|
||||||
|
var proposal = CuttingPlanBatch.Capture([Plate(host, inner, imperfect)],
|
||||||
|
ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance, string.Join("\n", proposal.Describe("in")));
|
||||||
|
var order = proposal.Plates[0].Result.ProposedOrder.Select(p => p.SourcePart).ToList();
|
||||||
|
Assert.True(order.IndexOf(inner) < order.IndexOf(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BestEffortInstallFailure_RollsBackEveryPlate()
|
||||||
|
{
|
||||||
|
var parts = new[] { TouchingContours(), TouchingContours() };
|
||||||
|
var plates = parts.Select(p => Plate(p)).ToArray();
|
||||||
|
var originals = parts.Select(p => p.Program).ToArray();
|
||||||
|
var proposal = CuttingPlanBatch.Capture(plates, ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance);
|
||||||
|
var commit = CuttingPlanService.Apply(proposal.Plates.Select(p => p.Result), default,
|
||||||
|
(plate, _) => { if (ReferenceEquals(plate, plates[1])) throw new InvalidOperationException("test install fault"); },
|
||||||
|
acceptUnverified: true);
|
||||||
|
Assert.Equal(CuttingCommitStatus.Failed, commit.Status);
|
||||||
|
Assert.Equal(originals, parts.Select(p => p.Program));
|
||||||
|
Assert.All(parts, p => Assert.False(p.HasManualLeadIns));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableTheory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(45)]
|
||||||
|
public void RealDxf_PlansAndAppliesWithoutChangingTheDrawing(double degrees)
|
||||||
|
{
|
||||||
|
var path = TestConfig.GetExistingPath("BestEffortDxfPath");
|
||||||
|
Skip.If(path == null, "BestEffortDxfPath not configured in test-config.json or file not found");
|
||||||
|
var drawing = OpenNest.IO.CadImporter.ImportDrawing(path);
|
||||||
|
var part = new Part(drawing, new Vector(1, 1));
|
||||||
|
part.Rotate(degrees * System.Math.PI / 180);
|
||||||
|
var plate = Plate(part);
|
||||||
|
var source = OwnedProgramCopy.Copy(drawing.Program);
|
||||||
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.True(proposal.CanApplyWithWarnings, string.Join("\n", proposal.Describe("in")));
|
||||||
|
Assert.NotNull(proposal.BuildPreview(0));
|
||||||
|
Assert.True(Assert.Single(proposal.Plates[0].Result.ProposedOrder).IsRegenerated);
|
||||||
|
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(true).Status);
|
||||||
|
Assert.True(ProgramContent.Equal(source, drawing.Program));
|
||||||
|
Assert.True(part.HasManualLeadIns);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CustomMove : LinearMove { }
|
||||||
|
|
||||||
|
internal static Part TouchingContours()
|
||||||
|
{
|
||||||
|
var program = ExplicitContourTests.Square(false);
|
||||||
|
// Two ordinary holes share an edge: closed executable contours, ambiguous material.
|
||||||
|
program.MoveTo(2, 2); program.LineTo(4, 2); program.LineTo(4, 4);
|
||||||
|
program.LineTo(2, 4); program.LineTo(2, 2);
|
||||||
|
program.MoveTo(4, 2); program.LineTo(6, 2); program.LineTo(6, 4);
|
||||||
|
program.LineTo(4, 4); program.LineTo(4, 2);
|
||||||
|
return new Part(new Drawing("touching contours", program), new Vector(1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Plate Plate(params Part[] parts)
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(100, 100));
|
||||||
|
foreach (var part in parts)
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
return plate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
if (zeroLength)
|
||||||
|
{
|
||||||
Assert.False(proposal.CanApply);
|
Assert.False(proposal.CanApply);
|
||||||
var result = Assert.Single(proposal.Plates).Result;
|
Assert.Contains("Lead motion is missing, degenerate or inconsistent", string.Join("\n", proposal.Describe("in")));
|
||||||
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
|
|
||||||
Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.MissingLeadIn);
|
|
||||||
var text = string.Join("\n", proposal.Describe("in"));
|
|
||||||
Assert.Contains("Plate 1: blocked: missing or zero-length lead-in.", text);
|
|
||||||
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.True(ProgramContent.Equal(original, part.Program));
|
||||||
Assert.Null(plate.CuttingParameters);
|
return;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -147,6 +147,74 @@ public class NestDetailsSessionTests
|
|||||||
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.LoadAsync(Guid.NewGuid()));
|
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.LoadAsync(Guid.NewGuid()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Prefetch_ThenLoad_ResolvesFromTheCacheWithoutASecondRequest()
|
||||||
|
{
|
||||||
|
var requested = new List<Guid>();
|
||||||
|
var details = new NestDetails();
|
||||||
|
using var session = new NestDetailsSession((id, _) =>
|
||||||
|
{
|
||||||
|
requested.Add(id);
|
||||||
|
return Task.FromResult(details);
|
||||||
|
});
|
||||||
|
var nestId = Guid.NewGuid();
|
||||||
|
|
||||||
|
session.Prefetch(nestId);
|
||||||
|
Assert.Equal(new[] { nestId }, requested);
|
||||||
|
|
||||||
|
Assert.True(await session.LoadAsync(nestId));
|
||||||
|
Assert.Same(details, session.Details);
|
||||||
|
Assert.Equal(new[] { nestId }, requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Prefetch_SkipsWhenAlreadyTheCurrentNest_OrAlreadyCached()
|
||||||
|
{
|
||||||
|
var requested = new List<Guid>();
|
||||||
|
using var session = new NestDetailsSession((id, _) =>
|
||||||
|
{
|
||||||
|
requested.Add(id);
|
||||||
|
return Task.FromResult(new NestDetails());
|
||||||
|
});
|
||||||
|
var nestId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await session.LoadAsync(nestId);
|
||||||
|
Assert.Equal(new[] { nestId }, requested);
|
||||||
|
|
||||||
|
// Already the current nest.
|
||||||
|
session.Prefetch(nestId);
|
||||||
|
Assert.Equal(new[] { nestId }, requested);
|
||||||
|
|
||||||
|
// No longer current, but its earlier load is still cached.
|
||||||
|
session.Clear();
|
||||||
|
session.Prefetch(nestId);
|
||||||
|
Assert.Equal(new[] { nestId }, requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Clear_CancelsAnInFlightPrefetch_SoItsResultIsNeverCached()
|
||||||
|
{
|
||||||
|
var loads = new ControlledLoads();
|
||||||
|
using var session = new NestDetailsSession(loads.Load);
|
||||||
|
var nestId = Guid.NewGuid();
|
||||||
|
|
||||||
|
session.Prefetch(nestId);
|
||||||
|
Assert.False(loads.Tokens[0].IsCancellationRequested);
|
||||||
|
|
||||||
|
session.Clear();
|
||||||
|
Assert.True(loads.Tokens[0].IsCancellationRequested);
|
||||||
|
loads.Results[0].SetResult(new NestDetails());
|
||||||
|
|
||||||
|
// Whether or not the cancelled prefetch's continuation has run yet, it must not have
|
||||||
|
// populated the cache, so loading the same nest issues a fresh request.
|
||||||
|
var fresh = new NestDetails();
|
||||||
|
var load = session.LoadAsync(nestId);
|
||||||
|
Assert.Equal(2, loads.Results.Count);
|
||||||
|
loads.Results[1].SetResult(fresh);
|
||||||
|
Assert.True(await load);
|
||||||
|
Assert.Same(fresh, session.Details);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Each load waits for its own result, which the test completes explicitly.</summary>
|
/// <summary>Each load waits for its own result, which the test completes explicitly.</summary>
|
||||||
private sealed class ControlledLoads
|
private sealed class ControlledLoads
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,6 +59,31 @@ public class NestStorageSettingsTests : IDisposable
|
|||||||
Assert.False(settings.IsDatabaseMode);
|
Assert.False(settings.IsDatabaseMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryNormalizeServerUrl_AcceptsHttpsWithoutChangingMode()
|
||||||
|
{
|
||||||
|
var settings = new NestStorageSettings { Mode = NestStorageMode.File };
|
||||||
|
|
||||||
|
Assert.True(NestStorageSettings.TryNormalizeServerUrl(" https://shop.example:8090/ ", out var url));
|
||||||
|
settings.ServerUrl = url;
|
||||||
|
settings.Save(FilePath);
|
||||||
|
|
||||||
|
var loaded = NestStorageSettings.Load(FilePath);
|
||||||
|
Assert.Equal("https://shop.example:8090", loaded.ServerUrl);
|
||||||
|
Assert.Equal(NestStorageMode.File, loaded.Mode);
|
||||||
|
Assert.False(loaded.IsDatabaseMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("ftp://shop.example")]
|
||||||
|
[InlineData("not-a-url")]
|
||||||
|
public void TryNormalizeServerUrl_RejectsInvalidAddress(string input)
|
||||||
|
{
|
||||||
|
Assert.False(NestStorageSettings.TryNormalizeServerUrl(input, out var url));
|
||||||
|
Assert.Equal("", url);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (Directory.Exists(_directory))
|
if (Directory.Exists(_directory))
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ public class PostVerificationAnalyzerTests
|
|||||||
var report = Analyze(Cutoff(30, 30), a, Scribe(40, 40), b);
|
var report = Analyze(Cutoff(30, 30), a, Scribe(40, 40), b);
|
||||||
var overlap = Assert.Single(Find(report, PostVerificationKind.Overlap));
|
var overlap = Assert.Single(Find(report, PostVerificationKind.Overlap));
|
||||||
Assert.Equal((1, 2, 4), (overlap.PlateNumber, overlap.PartNumber, overlap.OtherPartNumber));
|
Assert.Equal((1, 2, 4), (overlap.PlateNumber, overlap.PartNumber, overlap.OtherPartNumber));
|
||||||
|
Assert.NotNull(overlap.Overlap);
|
||||||
|
Assert.Equal(new Vector(1.5, 1.5), overlap.Location);
|
||||||
|
Assert.Equal(1, overlap.Overlap.Area, 6);
|
||||||
Assert.True(report.HasWarnings);
|
Assert.True(report.HasWarnings);
|
||||||
Assert.False(report.CanPost(false));
|
Assert.False(report.CanPost(false));
|
||||||
Assert.True(report.CanPost(true));
|
Assert.True(report.CanPost(true));
|
||||||
@@ -58,7 +61,8 @@ public class PostVerificationAnalyzerTests
|
|||||||
{
|
{
|
||||||
var part = Rectangle(0, 0, 4);
|
var part = Rectangle(0, 0, 4);
|
||||||
part.HasManualLeadIns = true;
|
part.HasManualLeadIns = true;
|
||||||
Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
|
var finding = Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
|
||||||
|
Assert.Equal(Vector.Zero, finding.Location);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -105,8 +109,12 @@ public class PostVerificationAnalyzerTests
|
|||||||
public void Rapid_ThroughPreviouslyCutPartWarns()
|
public void Rapid_ThroughPreviouslyCutPartWarns()
|
||||||
{
|
{
|
||||||
var report = Analyze(Rectangle(0, 0, 4), Scribe(-2, 2), Scribe(6, 2));
|
var report = Analyze(Rectangle(0, 0, 4), Scribe(-2, 2), Scribe(6, 2));
|
||||||
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f =>
|
var crossing = Assert.Single(Find(report, PostVerificationKind.RapidCrossing).Where(f =>
|
||||||
f.PartNumber == 3 && f.OtherPartNumber == 1);
|
f.PartNumber == 3 && f.OtherPartNumber == 1));
|
||||||
|
Assert.NotNull(crossing.RapidStart);
|
||||||
|
Assert.NotNull(crossing.RapidEnd);
|
||||||
|
Assert.Equal(new[] { new Vector(0, 2), new Vector(4, 2) }, crossing.ContactPoints);
|
||||||
|
Assert.Equal((crossing.RapidStart.Value + crossing.RapidEnd.Value) * 0.5, crossing.Location);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -169,8 +177,11 @@ public class PostVerificationAnalyzerTests
|
|||||||
public void Rapid_CutoffTravelStillCrossesEarlierRealCuts()
|
public void Rapid_CutoffTravelStillCrossesEarlierRealCuts()
|
||||||
{
|
{
|
||||||
var report = Analyze(Rectangle(0, 0, 4), Scribe(-2, 2), Cutoff(6, 2));
|
var report = Analyze(Rectangle(0, 0, 4), Scribe(-2, 2), Cutoff(6, 2));
|
||||||
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f =>
|
var crossing = Assert.Single(Find(report, PostVerificationKind.RapidCrossing).Where(f =>
|
||||||
f.PartNumber == 3 && f.OtherPartNumber == 1);
|
f.PartNumber == 3 && f.OtherPartNumber == 1));
|
||||||
|
Assert.NotNull(crossing.RapidStart);
|
||||||
|
Assert.NotNull(crossing.RapidEnd);
|
||||||
|
Assert.Equal((crossing.RapidStart.Value + crossing.RapidEnd.Value) * 0.5, crossing.Location);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Diagnostics;
|
||||||
|
|
||||||
|
public class RapidContactPointTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-2, 2, 6, 2, 2)]
|
||||||
|
[InlineData(-2, -2, 6, 6, 2)] // Shared corners are deduplicated.
|
||||||
|
[InlineData(-2, 4, 6, 4, 2)] // Collinear contact span.
|
||||||
|
[InlineData(-2, 2, 0, 2, 1)] // Endpoint arrival.
|
||||||
|
[InlineData(0, 0, -2, -2, 0)] // Start-only departure.
|
||||||
|
[InlineData(1, 1, 2, 2, 0)] // Entirely inside.
|
||||||
|
public void RectangleContacts(double x1, double y1, double x2, double y2, int count)
|
||||||
|
{
|
||||||
|
var vertices = new[] { new Vector(0, 0), new Vector(4, 0), new Vector(4, 4), new Vector(0, 4) };
|
||||||
|
var curves = Enumerable.Range(0, 4).Select(i =>
|
||||||
|
PostVerificationGeometry.Curve.Create(vertices[i], vertices[(i + 1) % 4], null, false)).ToArray();
|
||||||
|
var start = new Vector(x1, y1);
|
||||||
|
var end = new Vector(x2, y2);
|
||||||
|
var points = PostVerificationGeometry.ContactPoints(start, end, curves, default);
|
||||||
|
Assert.Equal(count, points.Count);
|
||||||
|
Assert.True(((ICollection<Vector>)points).IsReadOnly);
|
||||||
|
Assert.All(points, point =>
|
||||||
|
{
|
||||||
|
Assert.Contains(curves, curve => curve.Contains(point));
|
||||||
|
Assert.True(new Line(start, end).ClosestPointTo(point).DistanceTo(point) < 1e-8);
|
||||||
|
});
|
||||||
|
Assert.Equal(points.OrderBy(point => point.DistanceTo(start)), points);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 2)]
|
||||||
|
[InlineData(1, 1)] // Tangency has a single marker.
|
||||||
|
public void CircleContactsUseNativeCurve(double y, int count)
|
||||||
|
{
|
||||||
|
var circle = PostVerificationGeometry.Curve.Create(new Vector(1, 0), new Vector(1, 0), Vector.Zero, false);
|
||||||
|
var points = PostVerificationGeometry.ContactPoints(new Vector(-2, y), new Vector(2, y), [circle], default);
|
||||||
|
Assert.Equal(count, points.Count);
|
||||||
|
Assert.All(points, point => Assert.Equal(1, point.DistanceTo(Vector.Zero), 8));
|
||||||
|
Assert.Equal(new Vector(count == 1 ? 0 : -1, y), points[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ArcContactsExcludeUnusedCircleHalf()
|
||||||
|
{
|
||||||
|
var arc = PostVerificationGeometry.Curve.Create(new Vector(0, -1), new Vector(0, 1), Vector.Zero, false);
|
||||||
|
var points = PostVerificationGeometry.ContactPoints(new Vector(-2, 0), new Vector(2, 0), [arc], default);
|
||||||
|
Assert.Equal(new Vector(1, 0), Assert.Single(points));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Engine;
|
||||||
|
|
||||||
|
public class CutoutPipelinePersistenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ExpandedMultiSheetProposalCommitsAndReloadsAsSeparateDrawings()
|
||||||
|
{
|
||||||
|
var frame = new OpenNest.Shapes.RingShape { OuterDiameter = 20, InnerDiameter = 10 }
|
||||||
|
.GetDrawing();
|
||||||
|
frame.Name = "neutral-frame";
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(6, 0);
|
||||||
|
program.LineTo(6, 6);
|
||||||
|
program.LineTo(0, 6);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
var insert = new Drawing("neutral-insert", program);
|
||||||
|
var request = new NestPipelineRequest("Irregular", new[] {
|
||||||
|
new NestItem { Drawing = frame, Quantity = 2 },
|
||||||
|
new NestItem { Drawing = insert, Quantity = 2 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
|
||||||
|
new NestJobOptions(maxPlates: 2));
|
||||||
|
var ordinary = NestPipeline.Run(request);
|
||||||
|
Assert.Contains(ordinary.Raw.Fulfillment, f => f.Unplaced > 0);
|
||||||
|
var result = NestPipeline.RunCutoutPreview(request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(2, result.Raw.Plates.Count);
|
||||||
|
Assert.All(result.Raw.Plates, p => Assert.Equal(new[] { "part-0", "part-1" },
|
||||||
|
p.Placements.Select(x => x.PartId)));
|
||||||
|
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(frame);
|
||||||
|
nest.Drawings.Add(insert);
|
||||||
|
using (var manager = new PlateManager(nest))
|
||||||
|
{
|
||||||
|
var committed = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
|
||||||
|
Assert.Equal(2, committed.Count);
|
||||||
|
Assert.All(committed, plate => Assert.Equal(new[] { frame, insert },
|
||||||
|
plate.Parts.Select(p => p.BaseDrawing)));
|
||||||
|
}
|
||||||
|
Assert.Equal(2, frame.Quantity.Nested);
|
||||||
|
Assert.Equal(2, insert.Quantity.Nested);
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
Assert.True(new NestWriter(nest).Write(stream));
|
||||||
|
var loaded = new NestReader(new MemoryStream(stream.ToArray())).Read();
|
||||||
|
Assert.Equal(2, loaded.Plates.Count);
|
||||||
|
for (var i = 0; i < loaded.Plates.Count; i++)
|
||||||
|
{
|
||||||
|
var expected = nest.Plates[i];
|
||||||
|
var actual = loaded.Plates[i];
|
||||||
|
Assert.Equal(expected.Size, actual.Size);
|
||||||
|
Assert.Equal(expected.PartSpacing, actual.PartSpacing);
|
||||||
|
Assert.Equal(expected.Quadrant, actual.Quadrant);
|
||||||
|
Assert.Equal(expected.EdgeSpacing, actual.EdgeSpacing);
|
||||||
|
Assert.Equal(new[] { "neutral-frame", "neutral-insert" },
|
||||||
|
actual.Parts.Select(p => p.BaseDrawing.Name));
|
||||||
|
for (var j = 0; j < actual.Parts.Count; j++)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected.Parts[j].Location.X, actual.Parts[j].Location.X, 6);
|
||||||
|
Assert.Equal(expected.Parts[j].Location.Y, actual.Parts[j].Location.Y, 6);
|
||||||
|
Assert.Equal(expected.Parts[j].Rotation, actual.Parts[j].Rotation, 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "neutral-frame").Quantity.Nested);
|
||||||
|
Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "neutral-insert").Quantity.Nested);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -527,6 +527,72 @@ namespace OpenNest.Tests.Fill
|
|||||||
Assert.True(part.BoundingBox.Bottom < 1);
|
Assert.True(part.BoundingBox.Bottom < 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettlePlacement_RepeatsAfterVerticalMovementOpensHorizontalPath()
|
||||||
|
{
|
||||||
|
var plate = new Plate(100, 100);
|
||||||
|
plate.Parts.Add(MakeRectPart(20, 20, 20, 30));
|
||||||
|
var moving = MakeRectPart(60, 35, 10, 10);
|
||||||
|
|
||||||
|
Compactor.SettlePlacement(new List<Part> { moving }, plate,
|
||||||
|
PushDirection.Left, PushDirection.Down);
|
||||||
|
|
||||||
|
Assert.Equal(0, moving.BoundingBox.Left, 6);
|
||||||
|
Assert.Equal(0, moving.BoundingBox.Bottom, 6);
|
||||||
|
Assert.False(moving.Intersects(plate.Parts[0], out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettlePlacement_MovesCopiedGroupTogether()
|
||||||
|
{
|
||||||
|
var plate = new Plate(100, 100);
|
||||||
|
var left = MakeRectPart(40, 40, 5, 5);
|
||||||
|
var right = MakeRectPart(50, 40, 5, 5);
|
||||||
|
var originalGap = right.Location.X - left.Location.X;
|
||||||
|
|
||||||
|
Compactor.SettlePlacement(new List<Part> { left, right }, plate,
|
||||||
|
PushDirection.Left, PushDirection.Down);
|
||||||
|
|
||||||
|
Assert.Equal(0, left.BoundingBox.Left, 6);
|
||||||
|
Assert.Equal(0, left.BoundingBox.Bottom, 6);
|
||||||
|
Assert.Equal(originalGap, right.Location.X - left.Location.X, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettlePlacement_GeometryRepeatsWhenInitialBoxPassIsSkipped()
|
||||||
|
{
|
||||||
|
var plate = new Plate(100, 100);
|
||||||
|
plate.Parts.Add(MakeRectPart(20, 20, 20, 30));
|
||||||
|
// Its bounding box covers the start, but its material is above it.
|
||||||
|
plate.Parts.Add(MakeTrianglePart(new Vector(0, 100),
|
||||||
|
new Vector(100, 100), new Vector(100, 40)));
|
||||||
|
var moving = MakeRectPart(60, 35, 10, 10);
|
||||||
|
|
||||||
|
Compactor.SettlePlacement(new List<Part> { moving }, plate,
|
||||||
|
PushDirection.Left, PushDirection.Down);
|
||||||
|
|
||||||
|
Assert.Equal(0, moving.BoundingBox.Left, 6);
|
||||||
|
Assert.Equal(0, moving.BoundingBox.Bottom, 6);
|
||||||
|
Assert.All(plate.Parts, obstacle => Assert.False(moving.Intersects(obstacle, out _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettlePlacement_SkipsBoxPassWhenAlreadyInsideObstacleBounds()
|
||||||
|
{
|
||||||
|
// Triangle material sits below the diagonal; the free pocket at (25,25)
|
||||||
|
// is within its bounding box. A box-only slide would falsely block it.
|
||||||
|
var plate = new Plate(100, 100);
|
||||||
|
plate.Parts.Add(MakeTrianglePart(new Vector(0, 0),
|
||||||
|
new Vector(40, 0), new Vector(0, 40)));
|
||||||
|
var moving = MakeRectPart(25, 25, 5, 5);
|
||||||
|
var start = moving.Location;
|
||||||
|
|
||||||
|
Compactor.SettlePlacement(new List<Part> { moving }, plate,
|
||||||
|
PushDirection.Left, PushDirection.Down, maxIterations: 0);
|
||||||
|
|
||||||
|
Assert.Equal(start, moving.Location);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PushBoundingBox_Left_MovesPartTowardEdge()
|
public void PushBoundingBox_Left_MovesPartTowardEdge()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
|
public class PartColorPaletteTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 216, 49, 49)]
|
||||||
|
[InlineData(1, 98, 160, 234)]
|
||||||
|
[InlineData(2, 253, 150, 167)]
|
||||||
|
[InlineData(3, 157, 211, 226)]
|
||||||
|
public void GoldenAngleMatchesWideRangeHslSamples(int index, int r, int g, int b)
|
||||||
|
=> Assert.Equal(Color.FromArgb(r, g, b), PartColorPalette.GoldenAngle(index));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GoldenAngleContinuesPastPaletteLengthAndStaysOutOfEtchBand()
|
||||||
|
{
|
||||||
|
var colors = Enumerable.Range(0, 1000).Select(PartColorPalette.GoldenAngle).ToArray();
|
||||||
|
Assert.NotEqual(colors[0], colors[12]);
|
||||||
|
Assert.All(colors, color =>
|
||||||
|
{
|
||||||
|
Assert.Equal(255, color.A);
|
||||||
|
// The green band belongs to etch strokes; no generated fill may land in it.
|
||||||
|
var hue = color.GetHue();
|
||||||
|
Assert.True(hue < 95 || hue >= 165, $"Fill hue {hue:F0} collides with the etch-green band.");
|
||||||
|
});
|
||||||
|
Assert.Equal(colors, Enumerable.Range(0, 1000).Select(PartColorPalette.GoldenAngle));
|
||||||
|
Assert.InRange(PartColorPalette.GoldenAngle(int.MaxValue).GetBrightness(), 0.4f, 0.85f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeighboringPartsStayFarApartInRgb()
|
||||||
|
{
|
||||||
|
static double Distance(Color a, Color b) =>
|
||||||
|
System.Math.Sqrt((a.R - b.R) * (double)(a.R - b.R)
|
||||||
|
+ (a.G - b.G) * (a.G - b.G)
|
||||||
|
+ (a.B - b.B) * (a.B - b.B));
|
||||||
|
|
||||||
|
var colors = Enumerable.Range(0, 48).Select(PartColorPalette.GoldenAngle).ToArray();
|
||||||
|
Assert.Equal(48, colors.Select(c => c.ToArgb()).Distinct().Count());
|
||||||
|
for (var i = 0; i < colors.Length - 1; i++)
|
||||||
|
Assert.True(Distance(colors[i], colors[i + 1]) >= 90,
|
||||||
|
$"Parts {i} and {i + 1} are too similar: {Distance(colors[i], colors[i + 1]):F0}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GoldenAngleRejectsNegativeIndices()
|
||||||
|
=> Assert.Throws<ArgumentOutOfRangeException>(() => PartColorPalette.GoldenAngle(-1));
|
||||||
|
}
|
||||||
@@ -32,12 +32,12 @@ public sealed class NestPdfLayoutTests : IDisposable
|
|||||||
var summary = pages[0];
|
var summary = pages[0];
|
||||||
Assert.Matches(@"Distinct layouts:\s+4", summary);
|
Assert.Matches(@"Distinct layouts:\s+4", summary);
|
||||||
Assert.Matches(@"Total physical sheets:\s+7", summary);
|
Assert.Matches(@"Total physical sheets:\s+7", summary);
|
||||||
// Bracket R001: 2/sheet x 2 + 1 x 3 + 1 x 1 = 8. Same-named R003: 1 x 2 + 1 x 1 = 3.
|
// Bracket 1: 2/sheet x 2 + 1 x 3 + 1 x 1 = 8. Same-named 3: 1 x 2 + 1 x 1 = 3.
|
||||||
Assert.Matches(@"R001\s+Bracket\s+5\s+8\s+0\s+3\s+1-2, 4", summary);
|
Assert.Matches(@"1\s+Bracket\s+5\s+8\s+0\s+3\s+1-2, 4", summary);
|
||||||
Assert.Matches(@"R002\s+Rotated\s+1\s+3\s+0\s+2\s+1, 3", summary);
|
Assert.Matches(@"2\s+Rotated\s+1\s+3\s+0\s+2\s+1, 3", summary);
|
||||||
Assert.Matches(@"R003\s+Bracket\s+7\s+3\s+4\s+0\s+1, 3", summary);
|
Assert.Matches(@"3\s+Bracket\s+7\s+3\s+4\s+0\s+1, 3", summary);
|
||||||
Assert.Matches(@"R004\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summary);
|
Assert.Matches(@"4\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summary);
|
||||||
Assert.Matches(@"3\s+60 x 30 in\s+1\s+2", summary);
|
Assert.Matches(@"3\s+30 x 60 in\s+1\s+2", summary);
|
||||||
for (var plate = 1; plate <= 4; plate++)
|
for (var plate = 1; plate <= 4; plate++)
|
||||||
{
|
{
|
||||||
var page = pages[plate];
|
var page = pages[plate];
|
||||||
@@ -45,10 +45,10 @@ public sealed class NestPdfLayoutTests : IDisposable
|
|||||||
Assert.Contains($"Page {plate + 1} of 5", page);
|
Assert.Contains($"Page {plate + 1} of 5", page);
|
||||||
Assert.Contains("Nest report: Report test job", page);
|
Assert.Contains("Nest report: Report test job", page);
|
||||||
}
|
}
|
||||||
Assert.Matches(@"R001\s+Bracket\s+1\s+3", pages[2]);
|
Assert.Matches(@"1\s+Bracket\s+1\s+3", pages[2]);
|
||||||
Assert.Matches(@"R002\s+Rotated\s+1\s+1", pages[3]);
|
Assert.Matches(@"2\s+Rotated\s+1\s+1", pages[3]);
|
||||||
Assert.Matches(@"R003\s+Bracket\s+1\s+1", pages[3]);
|
Assert.Matches(@"3\s+Bracket\s+1\s+1", pages[3]);
|
||||||
Assert.DoesNotContain("R001", pages[3]);
|
Assert.DoesNotMatch(@"(?m)^1\s+Bracket", pages[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
@@ -72,23 +72,23 @@ public sealed class NestPdfLayoutTests : IDisposable
|
|||||||
var summaryText = string.Join("\n", summaryPages);
|
var summaryText = string.Join("\n", summaryPages);
|
||||||
// Every row exactly once; the Parts heading row repeats on every continuation page.
|
// Every row exactly once; the Parts heading row repeats on every continuation page.
|
||||||
for (var id = 1; id <= 74; id++)
|
for (var id = 1; id <= 74; id++)
|
||||||
Assert.Single(Regex.Matches(summaryText, $@"(?m)^\s*R{id:D3}\s"));
|
Assert.Single(Regex.Matches(summaryText, $@"(?m)^\s*{id}\s+(?:Bracket|Rotated|Unplaced|Demand)\b"));
|
||||||
Assert.All(summaryPages.Skip(1), page => Assert.Matches(@"ID\s+Part\s+Drawing\s+Required\s+Nested\s+Shortage", page));
|
Assert.All(summaryPages.Skip(1), page => Assert.Matches(@"ID\s+Part\s+Drawing\s+Required\s+Nested\s+Shortage", page));
|
||||||
// Placements first (R004-R043 on plate 1), then unplaced demand by ordinal name.
|
// Placements first (4-43 on plate 1), then unplaced demand by ordinal name.
|
||||||
Assert.Matches(@"R043\s+Demand 39\s+40\s+2\s+38\s+0\s+1", summaryText);
|
Assert.Matches(@"43\s+Demand 39\s+40\s+2\s+38\s+0\s+1", summaryText);
|
||||||
Assert.Matches(@"R073\s+Demand 69\s+70\s+0\s+70\s+0\s+-", summaryText);
|
Assert.Matches(@"73\s+Demand 69\s+70\s+0\s+70\s+0\s+-", summaryText);
|
||||||
Assert.Matches(@"R074\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summaryText);
|
Assert.Matches(@"74\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summaryText);
|
||||||
|
|
||||||
var platePages = pages.Where(page => page.Contains("Plate 1 of 4")).ToList();
|
var platePages = pages.Where(page => page.Contains("Plate 1 of 4")).ToList();
|
||||||
Assert.True(platePages.Count >= 2, $"plate 1 pages: {platePages.Count}");
|
Assert.True(platePages.Count >= 2, $"plate 1 pages: {platePages.Count}");
|
||||||
Assert.All(platePages, page => Assert.Contains("Nest report: Report test job", page));
|
Assert.All(platePages, page => Assert.Contains("Nest report: Report test job", page));
|
||||||
// Every page carrying plate-table rows repeats the table heading.
|
// Every page carrying plate-table rows repeats the table heading.
|
||||||
var tablePages = platePages.Where(page => Regex.IsMatch(page, @"(?m)^R\d{3}\s+Demand \d{2}\s+1\s+2$")).ToList();
|
var tablePages = platePages.Where(page => Regex.IsMatch(page, @"(?m)^\d+\s+Demand \d{2}\s+1\s+2$")).ToList();
|
||||||
Assert.True(tablePages.Count >= 2, $"plate table pages: {tablePages.Count}");
|
Assert.True(tablePages.Count >= 2, $"plate table pages: {tablePages.Count}");
|
||||||
Assert.All(tablePages, page => Assert.Matches(@"ID\s+Drawing\s+Qty/sheet\s+Total \(2 copies\)", page));
|
Assert.All(tablePages, page => Assert.Matches(@"ID\s+Drawing\s+Parts per plate\s+Total \(2 plates\)", page));
|
||||||
var plateText = string.Join("\n", platePages);
|
var plateText = string.Join("\n", platePages);
|
||||||
for (var i = 0; i < 40; i++)
|
for (var i = 0; i < 40; i++)
|
||||||
Assert.Matches($@"R{i + 4:D3}\s+Demand {i:D2}\s+1\s+2", plateText);
|
Assert.Matches($@"{i + 4}\s+Demand {i:D2}\s+1\s+2", plateText);
|
||||||
Assert.DoesNotContain("Demand 40", plateText);
|
Assert.DoesNotContain("Demand 40", plateText);
|
||||||
for (var page = 1; page <= total; page++)
|
for (var page = 1; page <= total; page++)
|
||||||
Assert.Contains($"Page {page} of {total}", pages[page - 1]);
|
Assert.Contains($"Page {page} of {total}", pages[page - 1]);
|
||||||
@@ -110,11 +110,11 @@ public sealed class NestPdfLayoutTests : IDisposable
|
|||||||
Assert.Equal(Enumerable.Range(0, 900), notes);
|
Assert.Equal(Enumerable.Range(0, 900), notes);
|
||||||
Assert.Contains(string.Concat(Enumerable.Repeat("X", 120)), Regex.Replace(all, @"\s+", ""));
|
Assert.Contains(string.Concat(Enumerable.Repeat("X", 120)), Regex.Replace(all, @"\s+", ""));
|
||||||
// The long drawing name is wrapped inside its column on the summary and plate tables.
|
// The long drawing name is wrapped inside its column on the summary and plate tables.
|
||||||
var summaryPage = Array.FindIndex(pages, page => page.Contains("Required") && page.Contains("R002"));
|
var summaryPage = Array.FindIndex(pages, page => page.Contains("Required") && page.Contains("2"));
|
||||||
var platePage = Array.FindIndex(pages, page => page.Contains("Plate 1 of 4"));
|
var platePage = Array.FindIndex(pages, page => page.Contains("Plate 1 of 4"));
|
||||||
Assert.True(summaryPage >= 0 && platePage > summaryPage);
|
Assert.True(summaryPage >= 0 && platePage > summaryPage);
|
||||||
Assert.Equal(longName.Replace(" ", ""), ColumnText(path, summaryPage + 1, "Drawing", "Required"));
|
Assert.Equal(longName.Replace(" ", ""), ColumnText(path, summaryPage + 1, "Drawing", "Required"));
|
||||||
Assert.Equal(longName.Replace(" ", ""), ColumnText(path, platePage + 1, "Drawing", "Qty/sheet"));
|
Assert.Equal(longName.Replace(" ", ""), ColumnText(path, platePage + 1, "Drawing", "Parts"));
|
||||||
// Column headings stay on one line.
|
// Column headings stay on one line.
|
||||||
Assert.Matches(@"ID\s+Part\s+Drawing\s+Required\s+Nested\s+Shortage\s+Extra\s+Plates", pages[summaryPage]);
|
Assert.Matches(@"ID\s+Part\s+Drawing\s+Required\s+Nested\s+Shortage\s+Extra\s+Plates", pages[summaryPage]);
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ public sealed class NestPdfLayoutTests : IDisposable
|
|||||||
Assert.Equal(5, pages.Length);
|
Assert.Equal(5, pages.Length);
|
||||||
Assert.Contains("millimeters (mm)", pages[0]);
|
Assert.Contains("millimeters (mm)", pages[0]);
|
||||||
Assert.Contains("3 mm", pages[0]);
|
Assert.Contains("3 mm", pages[0]);
|
||||||
Assert.Matches(@"48 x 24 mm", pages[2]);
|
Assert.Matches(@"24 x 48 mm", pages[2]);
|
||||||
using var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import);
|
using var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import);
|
||||||
var stream = ReportPdf.ContentStreams(pdf.Pages[1]).Last();
|
var stream = ReportPdf.ContentStreams(pdf.Pages[1]).Last();
|
||||||
stream = stream[(stream.IndexOf("W* n", StringComparison.Ordinal) + 4)..];
|
stream = stream[(stream.IndexOf("W* n", StringComparison.Ordinal) + 4)..];
|
||||||
@@ -164,56 +164,73 @@ public sealed class NestPdfLayoutTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public void Write_TinyRepeatedPartsGetMapGridDetailViewsWithEveryIdLegible()
|
public void Write_TinyRepeatedPartsShareOneDottedGroupOnThePlatePage()
|
||||||
{
|
{
|
||||||
var nest = NestReportTestData.CreateDenseNest();
|
var nest = NestReportTestData.CreateDenseNest();
|
||||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||||
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320, 400);
|
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320);
|
||||||
Assert.NotNull(plan.Overview.Grid);
|
var group = Assert.Single(plan.Groups);
|
||||||
Assert.NotEmpty(plan.Details);
|
Assert.Equal("2", group.Label.Id);
|
||||||
// Every placement is labeled at full size somewhere; none are dropped.
|
Assert.Equal(Enumerable.Range(2, 24), group.Parts.Order());
|
||||||
var labeled = plan.Overview.Labels.Select(label => label.Part)
|
Assert.Equal(2, plan.Labels.Count);
|
||||||
.Concat(plan.Details.SelectMany(detail => detail.Labels.Select(label => label.Part))).ToHashSet();
|
Assert.DoesNotContain(plan.Labels, label => label.Id == "2");
|
||||||
Assert.Equal(Enumerable.Range(0, snapshot.Plates[0].Parts.Length), labeled.Order());
|
Assert.All(group.Parts, index => Assert.True(group.Boundary.Contains(plan.Fit.Rect(snapshot.Plates[0].Parts[index].Geometry.Bounds))));
|
||||||
|
Assert.False(group.Boundary.IntersectsWith(group.Label.Box));
|
||||||
|
var labels = plan.Labels.Append(group.Label).ToList();
|
||||||
var font = NestReportDiagram.LabelFont();
|
var font = NestReportDiagram.LabelFont();
|
||||||
Assert.Equal(NestReportDiagram.LabelFontSize, font.Size);
|
foreach (var label in labels)
|
||||||
foreach (var view in plan.Details.Prepend(plan.Overview))
|
|
||||||
{
|
|
||||||
foreach (var label in view.Labels)
|
|
||||||
{
|
{
|
||||||
Assert.True(label.Box.Width >= ReportText.Size(label.Id, font).Width);
|
Assert.True(label.Box.Width >= ReportText.Size(label.Id, font).Width);
|
||||||
Assert.True(label.Box.X >= 0 && label.Box.Right <= view.Size.Width && label.Box.Y >= 0 && label.Box.Bottom <= view.Size.Height);
|
Assert.True(new PdfSharp.Drawing.XRect(0, 0, 720, 320).Contains(label.Box));
|
||||||
Assert.DoesNotContain(view.Labels, other => other != label && other.Box.IntersectsWith(label.Box));
|
Assert.DoesNotContain(labels, other => other != label && other.Box.IntersectsWith(label.Box));
|
||||||
// Labels sit on material, not in the washer hole.
|
|
||||||
var center = view.Fit.Model(new PdfSharp.Drawing.XPoint(label.Box.X + label.Box.Width / 2, label.Box.Y + label.Box.Height / 2));
|
|
||||||
var part = snapshot.Plates[0].Parts[label.Part].Geometry;
|
|
||||||
var hole = part.Contours.SelectMany(contour => contour.Segments).Single(segment => segment.Center != null);
|
|
||||||
var dx = center.X - hole.Center!.X;
|
|
||||||
var dy = center.Y - hole.Center.Y;
|
|
||||||
Assert.True(dx * dx + dy * dy > hole.Radius * hole.Radius, $"{label.Id} label is inside its hole");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var path = Write(nest);
|
var path = Write(nest);
|
||||||
var pages = ReportPdf.Pages(path);
|
var pages = ReportPdf.Pages(path);
|
||||||
var plateText = string.Join("\n", pages.Skip(1));
|
Assert.Equal(2, pages.Length);
|
||||||
Assert.Contains("cells outlined dash-dot have detail views", plateText);
|
Assert.Contains("A dotted outline groups like parts under one ID", pages[1]);
|
||||||
foreach (var detail in plan.Details)
|
Assert.DoesNotContain("detail", pages[1]);
|
||||||
Assert.Contains($"Plate 1 detail {detail.Cell}:", plateText);
|
using var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import);
|
||||||
var washerLabels = pages.Skip(1).Sum(page => Regex.Matches(page, @"\bR002\b").Count);
|
var diagram = ReportPdf.ContentStreams(pdf.Pages[1]).Last();
|
||||||
// 24 diagram labels plus one table row.
|
Assert.Matches(@"\[0\.6 1\.8\]\s*0\s+d", diagram);
|
||||||
Assert.True(washerLabels >= 25, $"R002 occurrences: {washerLabels}");
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void Plan_GroupDoesNotEncloseUnrelatedPartsOrCutoffs(bool cutoff)
|
||||||
|
{
|
||||||
|
var nest = NestReportTestData.CreateDenseNest();
|
||||||
|
var drawing = NestReportTestData.Rectangle("Obstacle", 0.1, 0.1);
|
||||||
|
drawing.IsCutOff = cutoff;
|
||||||
|
nest.Plates[0].Parts.Add(new Part(drawing, new Vector(63.2, 31.2)));
|
||||||
|
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||||
|
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320);
|
||||||
|
Assert.Empty(plan.Groups);
|
||||||
|
Assert.Equal(2, plan.Labels.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plan_SeparatedClustersOfTheSamePartGetSeparateGroups()
|
||||||
|
{
|
||||||
|
var nest = NestReportTestData.CreateDenseNest();
|
||||||
|
var plate = nest.Plates[0];
|
||||||
|
for (var i = 14; i < 26; i++)
|
||||||
|
plate.Parts[i].Offset(25, 0);
|
||||||
|
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||||
|
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320);
|
||||||
|
Assert.Equal(2, plan.Groups.Count);
|
||||||
|
Assert.All(plan.Groups, group => Assert.Equal(12, group.Parts.Count));
|
||||||
|
Assert.False(plan.Groups[0].Boundary.IntersectsWith(plan.Groups[1].Boundary));
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public void Write_LabelOnHoledPartAvoidsTheHole()
|
public void Write_LabelOnHoledPartAvoidsTheHole()
|
||||||
{
|
{
|
||||||
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt);
|
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt);
|
||||||
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320, 400);
|
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320);
|
||||||
Assert.Empty(plan.Details);
|
var label = plan.Labels.First(label => label.Part == 0);
|
||||||
var label = plan.Overview.Labels.First(label => label.Part == 0);
|
|
||||||
var hole = snapshot.Plates[0].Parts[0].Geometry.Contours.SelectMany(c => c.Segments).Single(s => s.Center != null);
|
var hole = snapshot.Plates[0].Parts[0].Geometry.Contours.SelectMany(c => c.Segments).Single(s => s.Center != null);
|
||||||
var holeBox = plan.Overview.Fit.Rect(new ReportBounds(hole.Center!.X - hole.Radius, hole.Center.Y - hole.Radius,
|
var holeBox = plan.Fit.Rect(new ReportBounds(hole.Center!.X - hole.Radius, hole.Center.Y - hole.Radius,
|
||||||
hole.Center.X + hole.Radius, hole.Center.Y + hole.Radius));
|
hole.Center.X + hole.Radius, hole.Center.Y + hole.Radius));
|
||||||
Assert.False(label.Box.IntersectsWith(holeBox));
|
Assert.False(label.Box.IntersectsWith(holeBox));
|
||||||
}
|
}
|
||||||
@@ -235,10 +252,9 @@ public sealed class NestPdfLayoutTests : IDisposable
|
|||||||
nest.Plates.Add(plate);
|
nest.Plates.Add(plate);
|
||||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||||
|
|
||||||
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320, 400);
|
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320);
|
||||||
|
|
||||||
Assert.Empty(plan.Details);
|
foreach (var label in plan.Labels)
|
||||||
foreach (var label in plan.Overview.Labels)
|
|
||||||
{
|
{
|
||||||
var geometry = snapshot.Plates[0].Parts[label.Part].Geometry;
|
var geometry = snapshot.Plates[0].Parts[label.Part].Geometry;
|
||||||
var origin = new Vector(geometry.Bounds.Left, geometry.Bounds.Bottom);
|
var origin = new Vector(geometry.Bounds.Left, geometry.Bounds.Bottom);
|
||||||
@@ -248,14 +264,14 @@ public sealed class NestPdfLayoutTests : IDisposable
|
|||||||
foreach (var ring in rings)
|
foreach (var ring in rings)
|
||||||
ring.Offset(-origin.X, -origin.Y);
|
ring.Offset(-origin.X, -origin.Y);
|
||||||
var pole = PolyLabel.Find(rings[0], rings.Skip(1).ToList(), NestReportDiagram.PolePrecision(geometry.Bounds));
|
var pole = PolyLabel.Find(rings[0], rings.Skip(1).ToList(), NestReportDiagram.PolePrecision(geometry.Bounds));
|
||||||
var expected = plan.Overview.Fit.Point(pole.X + origin.X, pole.Y + origin.Y);
|
var expected = plan.Fit.Point(pole.X + origin.X, pole.Y + origin.Y);
|
||||||
// Pole of the placement-independent copy, to within the search precision.
|
// Pole of the placement-independent copy, to within the search precision.
|
||||||
var tolerance = NestReportDiagram.PolePrecision(geometry.Bounds) * plan.Overview.Fit.Scale;
|
var tolerance = NestReportDiagram.PolePrecision(geometry.Bounds) * plan.Fit.Scale;
|
||||||
Assert.InRange(label.Box.X + label.Box.Width / 2 - expected.X, -tolerance, tolerance);
|
Assert.InRange(label.Box.X + label.Box.Width / 2 - expected.X, -tolerance, tolerance);
|
||||||
Assert.InRange(label.Box.Y + label.Box.Height / 2 - expected.Y, -tolerance, tolerance);
|
Assert.InRange(label.Box.Y + label.Box.Height / 2 - expected.Y, -tolerance, tolerance);
|
||||||
}
|
}
|
||||||
var l = plan.Overview.Labels.Single(label => label.Part == 0);
|
var l = plan.Labels.Single(label => label.Part == 0);
|
||||||
var center = plan.Overview.Fit.Model(new PdfSharp.Drawing.XPoint(l.Box.X + l.Box.Width / 2, l.Box.Y + l.Box.Height / 2));
|
var center = plan.Fit.Model(new PdfSharp.Drawing.XPoint(l.Box.X + l.Box.Width / 2, l.Box.Y + l.Box.Height / 2));
|
||||||
// The pole of this L is in its corner square, about 1.17 in from both outer edges.
|
// The pole of this L is in its corner square, about 1.17 in from both outer edges.
|
||||||
Assert.InRange(center.X - 4, 0.9, 1.4);
|
Assert.InRange(center.X - 4, 0.9, 1.4);
|
||||||
Assert.InRange(center.Y - 4, 0.9, 1.4);
|
Assert.InRange(center.Y - 4, 0.9, 1.4);
|
||||||
@@ -266,14 +282,12 @@ public sealed class NestPdfLayoutTests : IDisposable
|
|||||||
{
|
{
|
||||||
// A square with a central hole has four equally deep poles; every copy must pick the same one.
|
// A square with a central hole has four equally deep poles; every copy must pick the same one.
|
||||||
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateDenseNest(), NestReportTestData.GeneratedAt);
|
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateDenseNest(), NestReportTestData.GeneratedAt);
|
||||||
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320, 400);
|
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320);
|
||||||
foreach (var view in plan.Details.Prepend(plan.Overview))
|
var offsets = plan.Labels.GroupBy(label => label.Id).SelectMany(group =>
|
||||||
{
|
|
||||||
var offsets = view.Labels.GroupBy(label => label.Id).SelectMany(group =>
|
|
||||||
{
|
{
|
||||||
var relative = group.Select(label =>
|
var relative = group.Select(label =>
|
||||||
{
|
{
|
||||||
var bounds = view.Fit.Rect(snapshot.Plates[0].Parts[label.Part].Geometry.Bounds);
|
var bounds = plan.Fit.Rect(snapshot.Plates[0].Parts[label.Part].Geometry.Bounds);
|
||||||
return (X: label.Box.X - bounds.X, Y: label.Box.Y - bounds.Y);
|
return (X: label.Box.X - bounds.X, Y: label.Box.Y - bounds.Y);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
return relative.Select(offset => (offset.X - relative[0].X, offset.Y - relative[0].Y));
|
return relative.Select(offset => (offset.X - relative[0].X, offset.Y - relative[0].Y));
|
||||||
@@ -283,8 +297,7 @@ public sealed class NestPdfLayoutTests : IDisposable
|
|||||||
Assert.InRange(delta.Item1, -0.01, 0.01);
|
Assert.InRange(delta.Item1, -0.01, 0.01);
|
||||||
Assert.InRange(delta.Item2, -0.01, 0.01);
|
Assert.InRange(delta.Item2, -0.01, 0.01);
|
||||||
});
|
});
|
||||||
}
|
Assert.Equal(2, plan.Labels.Count(label => label.Id == "1"));
|
||||||
Assert.Equal(24, plan.Details.Sum(detail => detail.Labels.Count(label => label.Id == "R002")));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static OpenNest.Geometry.Shape ToShape(ReportContour contour)
|
private static OpenNest.Geometry.Shape ToShape(ReportContour contour)
|
||||||
@@ -401,14 +414,14 @@ public sealed class NestPdfLayoutTests : IDisposable
|
|||||||
Assert.Contains("second line", lines);
|
Assert.Contains("second line", lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Text of the R002 row inside one table column, read from word boxes in reading order.</summary>
|
/// <summary>Text of the 2 row inside one table column, read from word boxes in reading order.</summary>
|
||||||
private static string ColumnText(string path, int page, string column, string next)
|
private static string ColumnText(string path, int page, string column, string next)
|
||||||
{
|
{
|
||||||
var words = ReportPdf.Words(path, page);
|
var words = ReportPdf.Words(path, page);
|
||||||
var heading = words.First(word => word.Text == column);
|
var heading = words.First(word => word.Text == column);
|
||||||
var limit = words.First(word => word.Text == next && System.Math.Abs(word.Top - heading.Top) < 1);
|
var limit = words.First(word => word.Text == next && System.Math.Abs(word.Top - heading.Top) < 1);
|
||||||
var above = words.First(word => word.Text == "R001" && word.Top > heading.Top);
|
var above = words.First(word => word.Text == "1" && word.Left < heading.Left && word.Top > heading.Top);
|
||||||
var below = words.First(word => word.Text == "R003" && word.Top > above.Top);
|
var below = words.First(word => word.Text == "3" && word.Left < heading.Left && word.Top > above.Top);
|
||||||
var cell = words.Where(word => word.Left >= heading.Left - 1 && word.Right <= limit.Left
|
var cell = words.Where(word => word.Left >= heading.Left - 1 && word.Right <= limit.Left
|
||||||
&& word.Top > above.Bottom && word.Bottom < below.Top)
|
&& word.Top > above.Bottom && word.Bottom < below.Top)
|
||||||
.OrderBy(word => word.Top).ThenBy(word => word.Left);
|
.OrderBy(word => word.Top).ThenBy(word => word.Left);
|
||||||
|
|||||||
@@ -47,20 +47,24 @@ public sealed class NestPdfWriterTests : IDisposable
|
|||||||
Assert.Contains("2026-09-29 12:00 +00:00", summary);
|
Assert.Contains("2026-09-29 12:00 +00:00", summary);
|
||||||
Assert.Matches(@"Distinct layouts:\s+1", summary);
|
Assert.Matches(@"Distinct layouts:\s+1", summary);
|
||||||
Assert.Matches(@"Total physical sheets:\s+2", summary);
|
Assert.Matches(@"Total physical sheets:\s+2", summary);
|
||||||
Assert.Matches(@"1\s+48 x 24 in\s+2\s+4\s+\d+\.\d%", summary);
|
Assert.Matches(@"1\s+24 x 48 in\s+2\s+4\s+\d+\.\d%", summary);
|
||||||
Assert.Matches(@"R001\s+Bracket\s+5\s+4\s+1\s+0\s+1", summary);
|
Assert.Matches(@"1\s+Bracket\s+5\s+4\s+1\s+0\s+1", summary);
|
||||||
Assert.Matches(@"R002\s+Rotated\s+1\s+2\s+0\s+1\s+1", summary);
|
Assert.Matches(@"2\s+Rotated\s+1\s+2\s+0\s+1\s+1", summary);
|
||||||
Assert.Matches(@"R003\s+Bracket\s+7\s+2\s+5\s+0\s+1", summary);
|
Assert.Matches(@"3\s+Bracket\s+7\s+2\s+5\s+0\s+1", summary);
|
||||||
Assert.Matches(@"R004\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summary);
|
Assert.Matches(@"4\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summary);
|
||||||
Assert.Contains("Page 1 of 2", summary);
|
Assert.Contains("Page 1 of 2", summary);
|
||||||
|
Assert.Matches(@"Stock size\s+Quantity\s+Parts per plate", summary);
|
||||||
|
Assert.DoesNotMatch(@"\bR\d+\b", summary + plate);
|
||||||
|
Assert.DoesNotContain("Copies", summary + plate);
|
||||||
|
Assert.DoesNotContain("Parts/sheet", summary + plate);
|
||||||
|
|
||||||
Assert.Contains("Plate 1", plate);
|
Assert.Contains("Plate 1", plate);
|
||||||
Assert.Contains("Report test job", plate);
|
Assert.Contains("Report test job", plate);
|
||||||
Assert.Matches(@"Copies:\s+2", plate);
|
Assert.Matches(@"Quantity:\s+2", plate);
|
||||||
Assert.Matches(@"R001\s+Bracket\s+2\s+4", plate);
|
Assert.Matches(@"1\s+Bracket\s+2\s+4", plate);
|
||||||
Assert.Matches(@"R002\s+Rotated\s+1\s+2", plate);
|
Assert.Matches(@"2\s+Rotated\s+1\s+2", plate);
|
||||||
Assert.Matches(@"R003\s+Bracket\s+1\s+2", plate);
|
Assert.Matches(@"3\s+Bracket\s+1\s+2", plate);
|
||||||
Assert.DoesNotContain("R004", plate);
|
Assert.DoesNotMatch(@"(?m)^4\s+Unplaced", plate);
|
||||||
Assert.DoesNotContain("Cutoff test", plate + summary);
|
Assert.DoesNotContain("Cutoff test", plate + summary);
|
||||||
Assert.Contains("not a dimensioned cutting drawing", plate);
|
Assert.Contains("not a dimensioned cutting drawing", plate);
|
||||||
Assert.Contains("not a geometry or CNC approval", plate);
|
Assert.Contains("not a geometry or CNC approval", plate);
|
||||||
@@ -75,6 +79,7 @@ public sealed class NestPdfWriterTests : IDisposable
|
|||||||
public void Write_EmptyAndDemandOnlyJobsProduceExplicitSummaryOnlyReports()
|
public void Write_EmptyAndDemandOnlyJobsProduceExplicitSummaryOnlyReports()
|
||||||
{
|
{
|
||||||
var nest = new Nest("Empty test") { Units = Units.Millimeters };
|
var nest = new Nest("Empty test") { Units = Units.Millimeters };
|
||||||
|
nest.Plates.Add(new Plate(24, 48) { Quantity = 1 });
|
||||||
var empty = ReportPdf.Text(WriteReport(nest, "empty.pdf"), 1);
|
var empty = ReportPdf.Text(WriteReport(nest, "empty.pdf"), 1);
|
||||||
Assert.Contains("No plates in this job.", empty);
|
Assert.Contains("No plates in this job.", empty);
|
||||||
Assert.Contains("No parts in this job.", empty);
|
Assert.Contains("No parts in this job.", empty);
|
||||||
@@ -83,10 +88,25 @@ public sealed class NestPdfWriterTests : IDisposable
|
|||||||
nest.Drawings.Add(NestReportTestData.Rectangle("Alpha", 20, 10, 2));
|
nest.Drawings.Add(NestReportTestData.Rectangle("Alpha", 20, 10, 2));
|
||||||
var demand = ReportPdf.Text(WriteReport(nest, "demand.pdf"), 1);
|
var demand = ReportPdf.Text(WriteReport(nest, "demand.pdf"), 1);
|
||||||
Assert.Contains("No plates in this job.", demand);
|
Assert.Contains("No plates in this job.", demand);
|
||||||
Assert.Matches(@"R001\s+Alpha\s+2\s+0\s+2\s+0\s+-", demand);
|
Assert.Matches(@"1\s+Alpha\s+2\s+0\s+2\s+0\s+-", demand);
|
||||||
Assert.Contains("Page 1 of 1", demand);
|
Assert.Contains("Page 1 of 1", demand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Write_TrailingEmptyStockDoesNotAddPagesOrCountTowardTotals()
|
||||||
|
{
|
||||||
|
var nest = NestReportTestData.CreateMultiPlateNest();
|
||||||
|
var expected = ReportPdf.Pages(WriteReport(nest, "without-empty-stock.pdf"));
|
||||||
|
nest.Plates.Add(new Plate(24, 48) { Quantity = 5 });
|
||||||
|
nest.Plates.Add(new Plate(30, 60) { Quantity = 2 });
|
||||||
|
var before = NestReportBuilderTests.Fingerprint(nest);
|
||||||
|
|
||||||
|
var actual = ReportPdf.Pages(WriteReport(nest));
|
||||||
|
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
Assert.Equal(before, NestReportBuilderTests.Fingerprint(nest));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Write_TabbedLeadInPartStrokesEachOpenContourSeparately()
|
public void Write_TabbedLeadInPartStrokesEachOpenContourSeparately()
|
||||||
{
|
{
|
||||||
@@ -128,19 +148,25 @@ public sealed class NestPdfWriterTests : IDisposable
|
|||||||
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Write_UnlabelableTinyPartKeepsItsTableRowWithoutAddingPages()
|
||||||
|
{
|
||||||
|
var nest = NestReportTestData.CreateMultiPlateNest();
|
||||||
|
nest.Plates[3].Parts.Add(new Part(NestReportTestData.Rectangle("Tiny", 0.01, 0.01), new Vector(44, 20)));
|
||||||
|
var pages = ReportPdf.Pages(WriteReport(nest));
|
||||||
|
Assert.Equal(5, pages.Length);
|
||||||
|
Assert.Matches(@"4\s+Tiny\s+1\s+1", pages[4]);
|
||||||
|
Assert.DoesNotContain("detail", string.Join("\n", pages));
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("illegible-label", "Plate 4, part 2 (R004)")]
|
[InlineData("cell-overflow", "Drawing 2")]
|
||||||
[InlineData("cell-overflow", "Drawing R002")]
|
|
||||||
[InlineData("header-overflow", "Page header")]
|
[InlineData("header-overflow", "Page header")]
|
||||||
public void Write_UnsupportedLayoutFailsBeforeReplacingDestination(string scenario, string identified)
|
public void Write_UnsupportedLayoutFailsBeforeReplacingDestination(string scenario, string identified)
|
||||||
{
|
{
|
||||||
var nest = NestReportTestData.CreateMultiPlateNest();
|
var nest = NestReportTestData.CreateMultiPlateNest();
|
||||||
switch (scenario)
|
switch (scenario)
|
||||||
{
|
{
|
||||||
case "illegible-label":
|
|
||||||
// Too small for a legible label even in a detail view, on the LAST plate.
|
|
||||||
nest.Plates[3].Parts.Add(new Part(NestReportTestData.Rectangle("Tiny", 0.01, 0.01), new Vector(44, 20)));
|
|
||||||
break;
|
|
||||||
case "cell-overflow":
|
case "cell-overflow":
|
||||||
// A row taller than a page would be silently clipped by MigraDoc.
|
// A row taller than a page would be silently clipped by MigraDoc.
|
||||||
nest.Plates[0].Parts[2].BaseDrawing.Name = string.Concat(Enumerable.Repeat("0123456789", 60));
|
nest.Plates[0].Parts[2].BaseDrawing.Name = string.Concat(Enumerable.Repeat("0123456789", 60));
|
||||||
@@ -174,7 +200,7 @@ public sealed class NestPdfWriterTests : IDisposable
|
|||||||
|
|
||||||
var error = Assert.Throws<InvalidOperationException>(() => NestPdfWriter.Write(snapshot, path));
|
var error = Assert.Throws<InvalidOperationException>(() => NestPdfWriter.Write(snapshot, path));
|
||||||
|
|
||||||
Assert.Contains("R002", error.Message);
|
Assert.Contains("2", error.Message);
|
||||||
Assert.Contains("U+4E2D", error.Message);
|
Assert.Contains("U+4E2D", error.Message);
|
||||||
Assert.Equal("keep", File.ReadAllText(path));
|
Assert.Equal("keep", File.ReadAllText(path));
|
||||||
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class NestReportBuilderTests
|
|||||||
Assert.Equal(0.125, snapshot.Thickness);
|
Assert.Equal(0.125, snapshot.Thickness);
|
||||||
Assert.Equal("in", snapshot.Units);
|
Assert.Equal("in", snapshot.Units);
|
||||||
Assert.Equal(NestReportTestData.GeneratedAt, snapshot.GeneratedAt);
|
Assert.Equal(NestReportTestData.GeneratedAt, snapshot.GeneratedAt);
|
||||||
Assert.Equal(new[] { "R001", "R002", "R003", "R004" }, snapshot.Drawings.Select(d => d.Id));
|
Assert.Equal(new[] { "1", "2", "3", "4" }, snapshot.Drawings.Select(d => d.Id));
|
||||||
Assert.Equal(new[] { "Bracket", "Rotated", "Bracket", "Unplaced" }, snapshot.Drawings.Select(d => d.Name));
|
Assert.Equal(new[] { "Bracket", "Rotated", "Bracket", "Unplaced" }, snapshot.Drawings.Select(d => d.Name));
|
||||||
Assert.Equal(new long[] { 5, 1, 7, 3 }, snapshot.Drawings.Select(d => d.Required));
|
Assert.Equal(new long[] { 5, 1, 7, 3 }, snapshot.Drawings.Select(d => d.Required));
|
||||||
Assert.Equal(new long[] { 4, 2, 2, 0 }, snapshot.Drawings.Select(d => d.Nested));
|
Assert.Equal(new long[] { 4, 2, 2, 0 }, snapshot.Drawings.Select(d => d.Nested));
|
||||||
@@ -31,7 +31,7 @@ public class NestReportBuilderTests
|
|||||||
Assert.All(snapshot.Drawings.Take(3), d => Assert.Equal(new[] { 1 }, d.Plates));
|
Assert.All(snapshot.Drawings.Take(3), d => Assert.Equal(new[] { 1 }, d.Plates));
|
||||||
Assert.Empty(snapshot.Drawings[3].Plates);
|
Assert.Empty(snapshot.Drawings[3].Plates);
|
||||||
var plate = Assert.Single(snapshot.Plates);
|
var plate = Assert.Single(snapshot.Plates);
|
||||||
Assert.Equal(new[] { "R001", "R001", "R002", "R003" }, plate.Parts.Select(p => p.ReportId));
|
Assert.Equal(new[] { "1", "1", "2", "3" }, plate.Parts.Select(p => p.ReportId));
|
||||||
Assert.Single(plate.Cutoffs);
|
Assert.Single(plate.Cutoffs);
|
||||||
Assert.Equal(2, snapshot.TotalSheets);
|
Assert.Equal(2, snapshot.TotalSheets);
|
||||||
Assert.Equal(new ReportBounds(0, 0, 48, 24), plate.Bounds);
|
Assert.Equal(new ReportBounds(0, 0, 48, 24), plate.Bounds);
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ public static class NestReportTestData
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Four layouts: "Bracket" R001 on plates 1, 2 and 4; R002 on plates 1 and 3; the distinct
|
/// Four layouts: "Bracket" 1 on plates 1, 2 and 4; 2 on plates 1 and 3; the distinct
|
||||||
/// same-named "Bracket" R003 on plates 1 and 3; R004 demanded but unplaced.
|
/// same-named "Bracket" 3 on plates 1 and 3; 4 demanded but unplaced.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Nest CreateMultiPlateNest()
|
public static Nest CreateMultiPlateNest()
|
||||||
{
|
{
|
||||||
@@ -73,7 +73,7 @@ public static class NestReportTestData
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One 120 x 60 sheet: two large parts and a cluster of tiny holed washers whose IDs cannot
|
/// One 120 x 60 sheet: two large parts and a cluster of tiny holed washers whose IDs cannot
|
||||||
/// be labeled legibly at overview scale, so they need detail views.
|
/// be labeled legibly at overview scale, so they share a group label.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Nest CreateDenseNest()
|
public static Nest CreateDenseNest()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,22 +28,36 @@ public static class ReportPdf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Layout text of one page; skipped where poppler-utils is not installed.</summary>
|
/// <summary>Visual-row text of one page; bbox coordinates avoid platform-specific -layout order.</summary>
|
||||||
public static string Text(string path, int page) =>
|
public static string Text(string path, int page) =>
|
||||||
Regex.Replace(Run("pdftotext", "-layout", "-f", Page(page), "-l", Page(page), path, "-"), "[ \t]+\n", "\n");
|
LayoutPage(Run("pdftotext", "-bbox", "-f", Page(page), "-l", Page(page), path, "-"));
|
||||||
|
|
||||||
/// <summary>Every page's layout text, in order.</summary>
|
/// <summary>Every page's visual-row text, in order.</summary>
|
||||||
public static string[] Pages(string path)
|
public static string[] Pages(string path)
|
||||||
{
|
{
|
||||||
var pages = Run("pdftotext", "-layout", path, "-").Split('\f');
|
var html = Run("pdftotext", "-bbox", path, "-");
|
||||||
// pdftotext terminates the last page with a form feed.
|
return Regex.Matches(html, @"<page\b[^>]*>.*?</page>", RegexOptions.Singleline)
|
||||||
return pages.Take(pages.Length - 1).Select(page => Regex.Replace(page, "[ \t]+\n", "\n")).ToArray();
|
.Select(match => LayoutPage(match.Value)).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LayoutPage(string html)
|
||||||
|
{
|
||||||
|
var rows = new List<(double Top, List<(double Left, string Text)> Words)>();
|
||||||
|
foreach (var word in ParseWords(html).OrderBy(word => word.Top).ThenBy(word => word.Left))
|
||||||
|
{
|
||||||
|
if (rows.Count == 0 || System.Math.Abs(word.Top - rows[^1].Top) > 1.5)
|
||||||
|
rows.Add((word.Top, []));
|
||||||
|
rows[^1].Words.Add((word.Left, word.Text));
|
||||||
|
}
|
||||||
|
return string.Join("\n", rows.Select(row => string.Join(" ", row.Words.OrderBy(word => word.Left).Select(word => word.Text)))) + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Words with page-space boxes (points, Y down).</summary>
|
/// <summary>Words with page-space boxes (points, Y down).</summary>
|
||||||
public static List<(double Left, double Top, double Right, double Bottom, string Text)> Words(string path, int page)
|
public static List<(double Left, double Top, double Right, double Bottom, string Text)> Words(string path, int page) =>
|
||||||
|
ParseWords(Run("pdftotext", "-bbox", "-f", Page(page), "-l", Page(page), path, "-"));
|
||||||
|
|
||||||
|
private static List<(double Left, double Top, double Right, double Bottom, string Text)> ParseWords(string xml)
|
||||||
{
|
{
|
||||||
var xml = Run("pdftotext", "-bbox", "-f", Page(page), "-l", Page(page), path, "-");
|
|
||||||
return Regex.Matches(xml, "<word xMin=\"([\\d.]+)\" yMin=\"([\\d.]+)\" xMax=\"([\\d.]+)\" yMax=\"([\\d.]+)\">([^<]*)</word>")
|
return Regex.Matches(xml, "<word xMin=\"([\\d.]+)\" yMin=\"([\\d.]+)\" xMax=\"([\\d.]+)\" yMax=\"([\\d.]+)\">([^<]*)</word>")
|
||||||
.Select(match => (Number(match.Groups[1].Value), Number(match.Groups[2].Value), Number(match.Groups[3].Value),
|
.Select(match => (Number(match.Groups[1].Value), Number(match.Groups[2].Value), Number(match.Groups[3].Value),
|
||||||
Number(match.Groups[4].Value), System.Net.WebUtility.HtmlDecode(match.Groups[5].Value)))
|
Number(match.Groups[4].Value), System.Net.WebUtility.HtmlDecode(match.Groups[5].Value)))
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Sequencing;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Sequencing;
|
||||||
|
|
||||||
|
public class ManualPartSequencingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Move_SetsExactOneBasedSequenceWithoutChangingQuantity()
|
||||||
|
{
|
||||||
|
var drawing = TestHelpers.MakeSquareDrawing();
|
||||||
|
var first = new Part(drawing, new Vector(0, 0));
|
||||||
|
var second = new Part(drawing, new Vector(20, 0));
|
||||||
|
var third = new Part(drawing, new Vector(40, 0));
|
||||||
|
var plate = TestHelpers.MakePlate(60, 120, first, second, third);
|
||||||
|
var nested = drawing.Quantity.Nested;
|
||||||
|
var reorders = 0;
|
||||||
|
plate.PartsReordered += (_, _) => reorders++;
|
||||||
|
|
||||||
|
Assert.True(ManualPartSequencing.Move(plate, third, 1));
|
||||||
|
Assert.Equal(new[] { third, first, second }, plate.Parts);
|
||||||
|
Assert.True(ManualPartSequencing.Move(plate, first, 3));
|
||||||
|
Assert.Equal(new[] { third, second, first }, plate.Parts);
|
||||||
|
Assert.Equal(nested, drawing.Quantity.Nested);
|
||||||
|
Assert.Equal(2, reorders);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Move_RejectsAbsentPartAndOutOfRangeWithoutChangingPlate()
|
||||||
|
{
|
||||||
|
var drawing = TestHelpers.MakeSquareDrawing();
|
||||||
|
var part = new Part(drawing, new Vector(0, 0));
|
||||||
|
var plate = TestHelpers.MakePlate(60, 120, part);
|
||||||
|
var reorders = 0;
|
||||||
|
plate.PartsReordered += (_, _) => reorders++;
|
||||||
|
|
||||||
|
Assert.False(ManualPartSequencing.Move(plate, part, 0));
|
||||||
|
Assert.False(ManualPartSequencing.Move(plate, part, 2));
|
||||||
|
Assert.False(ManualPartSequencing.Move(plate, new Part(drawing, new Vector(0, 0)), 1));
|
||||||
|
Assert.Equal(new[] { part }, plate.Parts);
|
||||||
|
Assert.Equal(0, reorders);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Controls;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Program = OpenNest.CNC.Program;
|
||||||
|
|
||||||
|
namespace OpenNest.WinForms.Tests;
|
||||||
|
|
||||||
|
public class EtchDisplayTests
|
||||||
|
{
|
||||||
|
private static readonly Color EtchColor = Color.Lime;
|
||||||
|
private static readonly Color CutColor = Color.FromArgb(48, 48, 48);
|
||||||
|
private static readonly Color PartColor = Color.FromArgb(216, 49, 49);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, false, Mode.Absolute)]
|
||||||
|
[InlineData(false, true, Mode.Absolute)]
|
||||||
|
[InlineData(true, false, Mode.Absolute)]
|
||||||
|
[InlineData(true, true, Mode.Absolute)]
|
||||||
|
[InlineData(false, false, Mode.Incremental)]
|
||||||
|
[InlineData(false, true, Mode.Incremental)]
|
||||||
|
[InlineData(true, false, Mode.Incremental)]
|
||||||
|
[InlineData(true, true, Mode.Incremental)]
|
||||||
|
public void PlateSeparatesEtchFromCutWithoutChangingProgram(bool leads, bool selected, Mode mode)
|
||||||
|
=> StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var program = MixedProgram();
|
||||||
|
program.Mode = mode;
|
||||||
|
var before = Snapshot(program);
|
||||||
|
var drawing = new Drawing("Synthetic etch display", program) { Color = PartColor };
|
||||||
|
var part = new Part(drawing, new Vector(10, 10)) { HasManualLeadIns = leads };
|
||||||
|
using var view = new PlateView(ColorSchemeRegistry.Get("Workshop"));
|
||||||
|
view.Matrix = new Matrix();
|
||||||
|
var layout = LayoutPart.Create(part, view);
|
||||||
|
layout.IsSelected = selected;
|
||||||
|
using var image = new Bitmap(130, 130);
|
||||||
|
using var g = Graphics.FromImage(image);
|
||||||
|
g.Clear(Color.White);
|
||||||
|
layout.Draw(g);
|
||||||
|
|
||||||
|
var renderedFill = image.GetPixel(20, 20);
|
||||||
|
if (!selected)
|
||||||
|
Assert.Equal(PartColor.ToArgb(), renderedFill.ToArgb());
|
||||||
|
else
|
||||||
|
Assert.NotEqual(PartColor.ToArgb(), renderedFill.ToArgb());
|
||||||
|
Assert.True(Luminance(CutColor) < Luminance(renderedFill));
|
||||||
|
Assert.True(Distinctness(EtchColor, renderedFill) >= 15);
|
||||||
|
// A closed etch is not a hole; an actual closed cut still is.
|
||||||
|
Assert.Equal(image.GetPixel(20, 20), image.GetPixel(40, 40));
|
||||||
|
Assert.Equal(Color.White.ToArgb(), image.GetPixel(90, 90).ToArgb());
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), image.GetPixel(30, 40).ToArgb());
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), image.GetPixel(40, 70).ToArgb());
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), image.GetPixel(75, 40).ToArgb());
|
||||||
|
Assert.Equal(CutColor.ToArgb(), image.GetPixel(10, 50).ToArgb());
|
||||||
|
Assert.Equal(CutColor.ToArgb(), image.GetPixel(80, 90).ToArgb());
|
||||||
|
// Suppression must not leave a visible cut or etch stroke.
|
||||||
|
Assert.Equal(image.GetPixel(20, 20), image.GetPixel(40, 90));
|
||||||
|
Assert.Equal(before, Snapshot(program));
|
||||||
|
}, TimeSpan.FromMinutes(1), "Etch display test timed out.");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActualCutWinsWhenEtchOccupiesTheSameLine() => StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var program = MixedProgram();
|
||||||
|
program.MoveTo(20, 60);
|
||||||
|
program.Codes.Add(new LinearMove(40, 60) { Layer = LayerType.Cut });
|
||||||
|
using var view = new PlateView(ColorSchemeRegistry.Get("Workshop"));
|
||||||
|
view.Matrix = new Matrix();
|
||||||
|
var layout = LayoutPart.Create(new Part(new Drawing("Overlaid cut", program)), view);
|
||||||
|
using var image = new Bitmap(120, 120);
|
||||||
|
using var g = Graphics.FromImage(image);
|
||||||
|
layout.Draw(g);
|
||||||
|
Assert.Equal(CutColor.ToArgb(), image.GetPixel(30, 60).ToArgb());
|
||||||
|
}, TimeSpan.FromMinutes(1), "Overlaid cut test timed out.");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ThumbnailKeepsEtchOutOfFillAndUsesDedicatedColor()
|
||||||
|
{
|
||||||
|
var program = MixedProgram();
|
||||||
|
using var fill = new SolidBrush(Color.FromArgb(184, 207, 224));
|
||||||
|
using var image = (Bitmap)program.GetImage(new System.Drawing.Size(110, 110), Pens.Black, fill);
|
||||||
|
Assert.Equal(image.GetPixel(15, 95), image.GetPixel(35, 75));
|
||||||
|
Assert.Equal(0, image.GetPixel(85, 25).A);
|
||||||
|
Assert.Equal(ColorScheme.Default.EtchColor.ToArgb(), image.GetPixel(25, 75).ToArgb());
|
||||||
|
Assert.Equal(Color.Black.ToArgb(), image.GetPixel(75, 25).ToArgb());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlateRendererKeepsEtchesInNumberedPartsAndPlacementPreviews() => StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
using var view = new PlateView(ColorSchemeRegistry.Get("Workshop"))
|
||||||
|
{
|
||||||
|
Size = new System.Drawing.Size(250, 130),
|
||||||
|
DrawBounds = false,
|
||||||
|
};
|
||||||
|
view.Matrix = new Matrix();
|
||||||
|
view.Plate.Parts.Add(new Part(new Drawing("Placed", MixedProgram())));
|
||||||
|
view.Previews.SetActiveParts(new List<Part>
|
||||||
|
{
|
||||||
|
new(new Drawing("Preview", MixedProgram()), new Vector(120, 0)),
|
||||||
|
});
|
||||||
|
using var image = new Bitmap(250, 130);
|
||||||
|
using var g = Graphics.FromImage(image);
|
||||||
|
g.Clear(Color.White);
|
||||||
|
view.Renderer.DrawParts(g);
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), image.GetPixel(20, 30).ToArgb());
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), image.GetPixel(140, 30).ToArgb());
|
||||||
|
Assert.Equal(CutColor.ToArgb(), image.GetPixel(0, 50).ToArgb());
|
||||||
|
}, TimeSpan.FromMinutes(1), "Plate preview test timed out.");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorkshopIsAnAdditionalPaletteNotAReplacementForSavedSchemes()
|
||||||
|
{
|
||||||
|
var scheme = ColorSchemeRegistry.Get("Workshop");
|
||||||
|
Assert.Equal("Workshop", scheme.Name);
|
||||||
|
Assert.Equal("Classic", ColorSchemeRegistry.Get("Classic").Name);
|
||||||
|
Assert.Equal("Pastel", ColorSchemeRegistry.Get("Pastel").Name);
|
||||||
|
Assert.Equal("Dark", ColorSchemeRegistry.Get("Dark").Name);
|
||||||
|
Assert.NotEmpty(scheme.PartColors);
|
||||||
|
Assert.True(scheme.UseGoldenAngleColors);
|
||||||
|
Assert.Equal(PartColor, scheme.PartColors[0]);
|
||||||
|
Assert.Equal(Color.FromArgb(242, 242, 242), scheme.LayoutFillColor);
|
||||||
|
Assert.True(scheme.SelectedPartColor.IsEmpty);
|
||||||
|
Assert.All(Enumerable.Range(0, 1000).Select(PartColorPalette.GoldenAngle), color =>
|
||||||
|
{
|
||||||
|
Assert.True(Distinctness(color, EtchColor) >= 15, "Etch must stay distinguishable from generated fills.");
|
||||||
|
Assert.True(Distinctness(color, CutColor) >= 15, "Cuts must stay distinguishable from generated fills.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SchemeRoundTripAndOldFilesPreserveOutlineFallback()
|
||||||
|
{
|
||||||
|
var workshop = ColorSchemeRegistry.Get("Workshop");
|
||||||
|
var restored = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(workshop));
|
||||||
|
Assert.Equal(CutColor.ToArgb(), restored.PartOutlineColor.ToArgb());
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), restored.EtchColor.ToArgb());
|
||||||
|
Assert.True(restored.SelectedPartColor.IsEmpty);
|
||||||
|
Assert.True(restored.UseGoldenAngleColors);
|
||||||
|
Assert.Equal(workshop.PartColors.Select(c => c.ToArgb()), restored.PartColors.Select(c => c.ToArgb()));
|
||||||
|
var legacy = System.Text.Json.Nodes.JsonNode.Parse(ColorSchemeSerializer.Serialize(ColorSchemeRegistry.Get("Classic")))!.AsObject();
|
||||||
|
legacy.Remove("etchColor");
|
||||||
|
legacy.Remove("partOutlineColor");
|
||||||
|
legacy.Remove("selectedPartColor");
|
||||||
|
legacy.Remove("useGoldenAngleColors");
|
||||||
|
var oldScheme = ColorSchemeSerializer.Deserialize(legacy.ToJsonString());
|
||||||
|
Assert.True(oldScheme.PartOutlineColor.IsEmpty);
|
||||||
|
Assert.True(oldScheme.SelectedPartColor.IsEmpty);
|
||||||
|
Assert.False(oldScheme.UseGoldenAngleColors);
|
||||||
|
Assert.Equal(System.Windows.Forms.ControlPaint.Dark(Color.Coral), oldScheme.GetPartOutlineColor(Color.Coral));
|
||||||
|
Assert.Equal(new ColorScheme().EtchColor.ToArgb(), oldScheme.EtchColor.ToArgb());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PaletteChangeRepaintsExistingPartWithoutMovingIt() => StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var scheme = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorSchemeRegistry.Get("Classic")));
|
||||||
|
var drawing = new Drawing("Recolor", MixedProgram()) { Color = Color.Coral };
|
||||||
|
using var view = new PlateView(scheme);
|
||||||
|
view.Matrix = new Matrix();
|
||||||
|
var layout = LayoutPart.Create(new Part(drawing), view);
|
||||||
|
var path = layout.Path;
|
||||||
|
scheme.PartOutlineColor = CutColor;
|
||||||
|
scheme.EtchColor = EtchColor;
|
||||||
|
drawing.Color = Color.FromArgb(184, 207, 224);
|
||||||
|
using var image = new Bitmap(120, 120);
|
||||||
|
using var g = Graphics.FromImage(image);
|
||||||
|
layout.Draw(g);
|
||||||
|
Assert.Same(path, layout.Path);
|
||||||
|
Assert.Equal(drawing.Color.ToArgb(), image.GetPixel(10, 10).ToArgb());
|
||||||
|
Assert.Equal(CutColor.ToArgb(), image.GetPixel(0, 50).ToArgb());
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), image.GetPixel(20, 30).ToArgb());
|
||||||
|
}, TimeSpan.FromMinutes(1), "Scheme repaint test timed out.");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LayerChangesAndSubprogramsDoNotCreateConnectingStrokes()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(10, 10);
|
||||||
|
program.Codes.Add(new LinearMove(20, 10) { Layer = LayerType.Cut });
|
||||||
|
program.Codes.Add(new LinearMove(30, 10) { Layer = LayerType.Scribe });
|
||||||
|
program.Codes.Add(new LinearMove(40, 10) { Layer = LayerType.Cut });
|
||||||
|
program.Codes.Add(new LinearMove(50, 10) { Layer = LayerType.Leadin });
|
||||||
|
program.Codes.Add(new LinearMove(60, 10) { Layer = LayerType.Leadout });
|
||||||
|
var child = new Program(Mode.Incremental);
|
||||||
|
child.MoveTo(2, 3);
|
||||||
|
child.Codes.Add(new LinearMove(6, 0) { Layer = LayerType.Scribe });
|
||||||
|
program.Codes.Add(new SubProgramCall(child, 0) { Offset = new Vector(70, 20) });
|
||||||
|
var before = Snapshot(program);
|
||||||
|
program.GetDisplayPaths(new Vector(5, 5), out var cuts, out var leads, out var etches);
|
||||||
|
using (cuts)
|
||||||
|
using (leads)
|
||||||
|
using (etches)
|
||||||
|
using (var pen = new Pen(Color.Black, 1))
|
||||||
|
{
|
||||||
|
Assert.True(cuts.IsOutlineVisible(20, 15, pen));
|
||||||
|
Assert.True(cuts.IsOutlineVisible(40, 15, pen));
|
||||||
|
Assert.False(cuts.IsOutlineVisible(30, 15, pen));
|
||||||
|
Assert.True(etches.IsOutlineVisible(30, 15, pen));
|
||||||
|
Assert.False(etches.IsOutlineVisible(40, 15, pen));
|
||||||
|
Assert.True(leads.IsOutlineVisible(50, 15, pen));
|
||||||
|
Assert.True(leads.IsOutlineVisible(60, 15, pen));
|
||||||
|
Assert.False(cuts.IsOutlineVisible(50, 15, pen));
|
||||||
|
Assert.True(etches.IsOutlineVisible(80, 28, pen));
|
||||||
|
Assert.False(etches.IsOutlineVisible(60, 21, pen));
|
||||||
|
}
|
||||||
|
Assert.Equal(before, Snapshot(program));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Contrast(Color a, Color b)
|
||||||
|
{
|
||||||
|
var first = Luminance(a);
|
||||||
|
var second = Luminance(b);
|
||||||
|
return (System.Math.Max(first, second) + 0.05) / (System.Math.Min(first, second) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Luminance(Color color)
|
||||||
|
{
|
||||||
|
static double Linear(byte channel)
|
||||||
|
{
|
||||||
|
var value = channel / 255.0;
|
||||||
|
return value <= 0.04045 ? value / 12.92 : System.Math.Pow((value + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
return 0.2126 * Linear(color.R) + 0.7152 * Linear(color.G) + 0.0722 * Linear(color.B);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Distinctness(Color a, Color b) => System.Math.Sqrt(
|
||||||
|
(a.R - (double)b.R) * (a.R - b.R)
|
||||||
|
+ (a.G - (double)b.G) * (a.G - b.G)
|
||||||
|
+ (a.B - (double)b.B) * (a.B - b.B));
|
||||||
|
|
||||||
|
private static Program MixedProgram()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
AddSquare(program, 0, 0, 100, LayerType.Cut);
|
||||||
|
AddSquare(program, 20, 20, 20, LayerType.Scribe);
|
||||||
|
AddSquare(program, 70, 70, 20, LayerType.Cut);
|
||||||
|
program.MoveTo(20, 60);
|
||||||
|
program.Codes.Add(new LinearMove(40, 60) { Layer = LayerType.Scribe });
|
||||||
|
program.MoveTo(65, 30);
|
||||||
|
program.Codes.Add(new ArcMove(65, 30, 60, 30) { Layer = LayerType.Scribe });
|
||||||
|
program.MoveTo(20, 80);
|
||||||
|
program.Codes.Add(new LinearMove(40, 80) { Layer = LayerType.Scribe, Suppressed = true });
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSquare(Program program, double x, double y, double size, LayerType layer)
|
||||||
|
{
|
||||||
|
program.MoveTo(x, y);
|
||||||
|
foreach (var end in new[] { new Vector(x + size, y), new Vector(x + size, y + size), new Vector(x, y + size), new Vector(x, y) })
|
||||||
|
program.Codes.Add(new LinearMove(end) { Layer = layer });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] Snapshot(Program program) => program.Codes.Select(code => code switch
|
||||||
|
{
|
||||||
|
ArcMove arc => $"arc:{arc.Layer}:{arc.Suppressed}:{arc.EndPoint.X:R}:{arc.EndPoint.Y:R}:{arc.CenterPoint.X:R}:{arc.CenterPoint.Y:R}:{arc.Rotation}",
|
||||||
|
LinearMove line => $"line:{line.Layer}:{line.Suppressed}:{line.EndPoint.X:R}:{line.EndPoint.Y:R}",
|
||||||
|
Motion motion => $"{motion.Type}:{motion.Suppressed}:{motion.EndPoint.X:R}:{motion.EndPoint.Y:R}",
|
||||||
|
_ => code.ToString()!
|
||||||
|
}).Prepend(program.Mode.ToString()).ToArray();
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Reflection;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Controls;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.WinForms.Tests;
|
||||||
|
|
||||||
|
public class EtchVisibilityTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, false)]
|
||||||
|
[InlineData(false, true)]
|
||||||
|
[InlineData(true, false)]
|
||||||
|
[InlineData(true, true)]
|
||||||
|
public void EtchRemainsVisibleWhenNoCutIntersectsViewport(bool includeCut, bool preview)
|
||||||
|
=> StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var program = new CNC.Program();
|
||||||
|
if (includeCut)
|
||||||
|
AddSquare(program, 0, LayerType.Cut);
|
||||||
|
AddSquare(program, 100, LayerType.Scribe);
|
||||||
|
var part = new Part(new Drawing("Panned etch", program));
|
||||||
|
using var view = new PannedView();
|
||||||
|
if (preview)
|
||||||
|
view.Previews.SetActiveParts(new List<Part> { part });
|
||||||
|
else
|
||||||
|
view.Plate.Parts.Add(part);
|
||||||
|
var layout = preview ? Assert.Single(view.PreviewParts) : Assert.Single(view.LayoutParts);
|
||||||
|
Assert.False(layout.Path.GetBounds().IntersectsWith(view.GetViewBounds()));
|
||||||
|
using var image = new Bitmap(30, 30);
|
||||||
|
using var g = Graphics.FromImage(image);
|
||||||
|
g.Clear(Color.White);
|
||||||
|
g.TranslateTransform(-95, -95);
|
||||||
|
view.Renderer.DrawParts(g);
|
||||||
|
Assert.Equal(Color.Lime.ToArgb(), image.GetPixel(5, 15).ToArgb());
|
||||||
|
Assert.Equal(Color.White.ToArgb(), image.GetPixel(8, 8).ToArgb());
|
||||||
|
if (!includeCut)
|
||||||
|
Assert.Equal(0, layout.Path.PointCount);
|
||||||
|
}, TimeSpan.FromMinutes(1), "Panned etch test timed out.");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EtchOnlyPart_RemainsSelectableAndHoverableWithoutCutMaterial()
|
||||||
|
=> StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var program = new CNC.Program();
|
||||||
|
AddSquare(program, 100, LayerType.Scribe);
|
||||||
|
var part = new Part(new Drawing("etch-only", program));
|
||||||
|
using var view = new PannedView();
|
||||||
|
view.Plate.Parts.Add(part);
|
||||||
|
var layout = Assert.Single(view.LayoutParts);
|
||||||
|
Assert.Equal(0, layout.Path.PointCount);
|
||||||
|
Assert.True(layout.EtchPath.PointCount > 0);
|
||||||
|
|
||||||
|
Assert.Same(layout, view.Selection.GetPartAtGraphPoint(new PointF(110, 110)));
|
||||||
|
Assert.Null(view.Selection.GetPartAtGraphPoint(new PointF(50, 50)));
|
||||||
|
Assert.Contains(layout, view.Selection.GetPartsFromWindow(
|
||||||
|
new RectangleF(105, 105, 10, 10), SelectionType.Intersect));
|
||||||
|
Assert.Contains(layout, view.Selection.GetPartsFromWindow(
|
||||||
|
new RectangleF(95, 95, 30, 30), SelectionType.Contains));
|
||||||
|
|
||||||
|
typeof(PlateView).GetField("hoverPending", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
|
.SetValue(view, true);
|
||||||
|
typeof(PlateView).GetField("hoverPoint", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
|
.SetValue(view, new Point(15, 15));
|
||||||
|
typeof(PlateView).GetMethod("HoverCheck", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
|
.Invoke(view, null);
|
||||||
|
Assert.Same(layout, typeof(PlateView).GetField("hoveredPart",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(view));
|
||||||
|
}, TimeSpan.FromMinutes(1), "Etch selection test timed out.");
|
||||||
|
|
||||||
|
private static void AddSquare(CNC.Program program, double origin, LayerType layer)
|
||||||
|
{
|
||||||
|
program.MoveTo(origin, origin);
|
||||||
|
foreach (var end in new[]
|
||||||
|
{
|
||||||
|
new Vector(origin + 20, origin), new Vector(origin + 20, origin + 20),
|
||||||
|
new Vector(origin, origin + 20), new Vector(origin, origin),
|
||||||
|
})
|
||||||
|
program.Codes.Add(new LinearMove(end) { Layer = layer });
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PannedView : PlateView
|
||||||
|
{
|
||||||
|
public PannedView() : base(ColorSchemeRegistry.Get("Workshop"))
|
||||||
|
{
|
||||||
|
Size = new System.Drawing.Size(30, 30);
|
||||||
|
DrawBounds = false;
|
||||||
|
Matrix = new Matrix();
|
||||||
|
origin = new PointF(-95, -95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,94 @@ namespace OpenNest.WinForms.Tests.Forms;
|
|||||||
|
|
||||||
public class AutoNestFormStockOptionsTests
|
public class AutoNestFormStockOptionsTests
|
||||||
{
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("invalid", false)]
|
||||||
|
[InlineData("-1", false)]
|
||||||
|
[InlineData("NaN", false)]
|
||||||
|
[InlineData("27", true)]
|
||||||
|
public void PendingCostEditsAreValidatedBeforePublishing(string text, bool valid)
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
using var form = new AutoNestForm(new Nest());
|
||||||
|
form.LoadPlateOptions(new List<PlateOption> { new() { Width = 48, Length = 96, Cost = 5 } }, 0.5);
|
||||||
|
var grid = GetGrid(form);
|
||||||
|
((TabControl)form.Controls.Find("tabControl", true).Single()).SelectedIndex = 1;
|
||||||
|
form.Show();
|
||||||
|
grid.Focus();
|
||||||
|
grid.CurrentCell = grid.Rows[0].Cells[1];
|
||||||
|
Assert.True(grid.BeginEdit(false));
|
||||||
|
grid.EditingControl.Text = text;
|
||||||
|
Assert.Equal(valid, form.TryGetPlateOptions(out var options, out var error));
|
||||||
|
if (valid)
|
||||||
|
Assert.Equal(27, Assert.Single(options).Cost);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.Empty(options);
|
||||||
|
Assert.Contains("cost", error);
|
||||||
|
}
|
||||||
|
grid.CancelEdit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 0, true)]
|
||||||
|
[InlineData(5, 8, true)]
|
||||||
|
[InlineData(5, 0, false)]
|
||||||
|
[InlineData(0, 5, false)]
|
||||||
|
[InlineData(-1, -1, false)]
|
||||||
|
[InlineData(double.NaN, 5, false)]
|
||||||
|
[InlineData(double.PositiveInfinity, 5, false)]
|
||||||
|
public void CostRowsRequireConsistentFiniteValues(double first, double second, bool valid)
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
using var form = new AutoNestForm(new Nest());
|
||||||
|
form.LoadPlateOptions(new List<PlateOption>
|
||||||
|
{
|
||||||
|
new() { Width = 48, Length = 96, Cost = first },
|
||||||
|
new() { Width = 60, Length = 120, Cost = second },
|
||||||
|
}, 0.5);
|
||||||
|
Assert.Equal(valid, form.TryGetPlateOptions(out var options, out var error));
|
||||||
|
if (!valid)
|
||||||
|
{
|
||||||
|
Assert.Empty(options);
|
||||||
|
Assert.Contains("cost", error);
|
||||||
|
}
|
||||||
|
AssertNewRow(GetGrid(form));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void StockRowsCanBeSelectedAndRemoved(bool loadSavedOptions)
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
using var form = new AutoNestForm(new Nest());
|
||||||
|
if (loadSavedOptions)
|
||||||
|
form.LoadPlateOptions(new List<PlateOption>
|
||||||
|
{
|
||||||
|
new() { Width = 48, Length = 96, Cost = 25 },
|
||||||
|
new() { Width = 60, Length = 120, Cost = 40 },
|
||||||
|
}, 0.5);
|
||||||
|
form.OptimizePlateSize = true;
|
||||||
|
var grid = GetGrid(form);
|
||||||
|
|
||||||
|
Assert.True(grid.RowHeadersVisible);
|
||||||
|
Assert.True(grid.AllowUserToDeleteRows);
|
||||||
|
Assert.Equal(DataGridViewSelectionMode.RowHeaderSelect, grid.SelectionMode);
|
||||||
|
grid.Rows[0].Selected = true;
|
||||||
|
Assert.True(grid.Rows[0].Selected);
|
||||||
|
|
||||||
|
var remaining = form.GetPlateOptions().Count;
|
||||||
|
Assert.IsAssignableFrom<IList>(grid.DataSource).RemoveAt(grid.SelectedRows[0].Index);
|
||||||
|
Assert.Equal(remaining - 1, form.GetPlateOptions().Count);
|
||||||
|
AssertNewRow(grid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(false)]
|
[InlineData(false)]
|
||||||
[InlineData(true)]
|
[InlineData(true)]
|
||||||
@@ -32,7 +120,7 @@ public class AutoNestFormStockOptionsTests
|
|||||||
Assert.True(rows.AllowNew);
|
Assert.True(rows.AllowNew);
|
||||||
var added = rows.AddNew()!;
|
var added = rows.AddNew()!;
|
||||||
SetValue(added, "Size", "84 x 168");
|
SetValue(added, "Size", "84 x 168");
|
||||||
SetValue(added, "Cost", 123.5);
|
SetValue(added, "Cost", loadSavedOptions ? 123.5 : 0);
|
||||||
((ICancelAddNew)rows).EndNew(rows.Count - 1);
|
((ICancelAddNew)rows).EndNew(rows.Count - 1);
|
||||||
|
|
||||||
var options = form.GetPlateOptions();
|
var options = form.GetPlateOptions();
|
||||||
@@ -40,7 +128,7 @@ public class AutoNestFormStockOptionsTests
|
|||||||
var option = options.Last();
|
var option = options.Last();
|
||||||
Assert.Equal(84, option.Width);
|
Assert.Equal(84, option.Width);
|
||||||
Assert.Equal(168, option.Length);
|
Assert.Equal(168, option.Length);
|
||||||
Assert.Equal(123.5, option.Cost);
|
Assert.Equal(loadSavedOptions ? 123.5 : 0, option.Cost);
|
||||||
AssertNewRow(grid);
|
AssertNewRow(grid);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -98,6 +186,7 @@ public class AutoNestFormStockOptionsTests
|
|||||||
var rows = Assert.IsAssignableFrom<IBindingList>(grid.DataSource);
|
var rows = Assert.IsAssignableFrom<IBindingList>(grid.DataSource);
|
||||||
var row = rows.AddNew()!;
|
var row = rows.AddNew()!;
|
||||||
SetValue(row, "Size", invalid);
|
SetValue(row, "Size", invalid);
|
||||||
|
SetValue(row, "Cost", 25);
|
||||||
((ICancelAddNew)rows).EndNew(rows.Count - 1);
|
((ICancelAddNew)rows).EndNew(rows.Count - 1);
|
||||||
|
|
||||||
Assert.False(form.TryGetPlateOptions(out var options, out var error));
|
Assert.False(form.TryGetPlateOptions(out var options, out var error));
|
||||||
|
|||||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user