Author SHA1 Message Date
aj 08acb092a7 fix(cutting): anchor near-corner entries to their native edge 2026-10-11 01:14:50 -04:00
aj dc142cc88d docs(readme): streamline engine guidance and tabulate CLI options 2026-10-10 21:31:11 -04:00
aj 6799e08356 docs(readme): remove stray plate header note 2026-10-10 21:28:25 -04:00
aj ca6966906f Support direct contour piercing and correct planner geometry checks 2026-10-10 16:44:03 -04:00
aj c28ccd7568 Move zoom controls into centered plate view header 2026-10-10 16:04:58 -04:00
aj 90ae3b6a84 Fix false self-intersection warnings at rounded contour joints 2026-10-10 15:57:16 -04:00
aj 72776886cb Remember saved nest browser sort preferences 2026-10-10 15:39:11 -04:00
aj d59c03f5ab Highlight validation contacts and mute unrelated parts 2026-10-10 15:11:41 -04:00
aj bb108e9341 Add interactive nest validation with animated issue highlights 2026-10-10 14:51:47 -04:00
aj 80c647bf07 fix(report): omit trailing empty stock plates 2026-10-10 13:23:58 -04:00
aj 24be90ace9 fix(report): replace closeups with best-effort grouped labels 2026-10-10 13:14:45 -04:00
aj 4feada094c fix(report): use plain numeric part IDs 2026-10-10 13:14:39 -04:00
aj 4e846007a2 fix(report): use parts per plate consistently 2026-10-10 13:14:31 -04:00
aj 8ebb48694f fix(report): rename plate copies to quantity 2026-10-10 13:14:25 -04:00
aj afa50c5a12 fix(report): display stock dimensions as width by length 2026-10-10 13:14:19 -04:00
aj ef6ca80e6d Merge branch 'master' of https://github.com/ajisaacs/OpenNest 2026-10-10 12:38:50 -04:00
aj 0c99455489 feat(ui): validate Auto Nest stock prices and report costs 2026-10-10 12:38:48 -04:00
aj c34ac2bc0e feat(mcp): support priced stock and preserve accepted job data 2026-10-10 12:38:36 -04:00
aj 076b794dda feat(api): accept stock prices and archive cost summaries 2026-10-10 12:38:30 -04:00
aj 5dbf887b68 feat(engine): score nesting jobs by per-sheet stock cost 2026-10-10 12:38:20 -04:00
aj 17e504fb70 feat(cutting): navigate diagnostic rows in enlarged planner 2026-10-10 11:37:57 -04:00
aj 69304d3851 feat(settings): set nest server URL from Options 2026-10-09 21:54:10 -04:00
aj 1c6d02c480 perf(cutting): try a bounded entry catalogue before full search 2026-10-09 21:26:40 -04:00
aj d328c5f691 perf(cutting): generate ranked alternatives only when visited 2026-10-09 20:40:10 -04:00
aj e897258a85 feat(cutouts): reconcile repeated frames and inserts through one solve 2026-10-09 20:36:43 -04:00
aj 1b9d8a36d8 fix(pipeline): certify bound programs and cancellation before commit 2026-10-09 20:28:05 -04:00
aj b750475228 fix(pipeline): refuse changed drawings before binding or commit 2026-10-09 20:15:17 -04:00
aj 138c86526a perf(cutting): reuse native curve wrappers for lead queries 2026-10-09 19:15:28 -04:00
aj 07e682a654 test(cutting): add opt-in complete-route performance probe 2026-10-09 19:15:09 -04:00
aj 0b29c683b9 feat(cutouts): trial physical composite reconciliation in pipeline 2026-10-09 18:57:31 -04:00
aj 71bd964344 test(cutouts): trace accepted lattice and NFP poses in one run 2026-10-09 17:53:22 -04:00
aj 1d50c6ce60 feat(cutouts): compare measured ratio routes and certify leftovers 2026-10-09 17:47:12 -04:00
aj 962ae8c825 feat(cutouts): propose bounded mixed lattice and NFP inserts 2026-10-09 17:20:41 -04:00
aj d3e325f36c fix(ui): restore manual part sequencing and close control 2026-10-09 17:06:17 -04:00
aj 575412a464 feat(cutouts): propose certified single inserts with inner-fit geometry 2026-10-09 16:59:11 -04:00
aj 60fb009329 fix(ui): expose Auto Nest stock row selectors for deletion 2026-10-09 13:16:40 -04:00
aj 41737a4f4c feat(mcp): complete jobs with finite mixed sheet stock 2026-10-09 13:14:34 -04:00
aj f1d1df3f17 fix(mcp): preserve job constraints and compatible stock 2026-10-09 12:34:30 -04:00
aj 81404fc673 feat(mcp): complete whole jobs across matching plates 2026-10-09 12:22:40 -04:00
aj a5b6c8a00e fix(mcp): warn on unmet quantities rather than sheet ratio 2026-10-09 11:09:51 -04:00
aj 2a6e5504cd fix(mcp): report zero packing demand accurately 2026-10-09 10:52:41 -04:00
aj 3d5c73a353 fix(mcp): report requested and placed drawing mix 2026-10-09 10:48:59 -04:00
aj 8b94e49711 feat(mcp): delete unused session plates 2026-10-09 10:45:28 -04:00
aj 2623517219 fix(mcp): reject negative plate indices 2026-10-09 10:42:35 -04:00
aj eff148c24e fix(test): unpool direct transient server databases 2026-10-08 20:03:10 -04:00
aj 648ffbca65 fix(test): own and close private server database before cleanup 2026-10-08 19:55:56 -04:00
aj 4d94273a00 fix(test): release transient SQLite handles for Windows server tests 2026-10-08 19:49:09 -04:00
aj 028adee046 ci(release): pin Poppler for Windows PDF verification 2026-10-08 19:42:13 -04:00
aj 3ddfff242d fix(test): read PDF text by visual rows across platforms 2026-10-08 19:36:33 -04:00
aj 5fc563bbca test(report): retain Windows PDF extraction with diagnostic 2026-10-08 19:30:15 -04:00
aj 785a861ab9 test(report): place diagnostic PDF in CI artifacts 2026-10-08 19:26:31 -04:00
aj 986ba3dee1 test(report): retain Windows PDF for failed release diagnosis 2026-10-08 19:22:25 -04:00
aj a73a85ebc0 ci(server): skip image publication for GitHub prereleases 2026-10-08 19:17:15 -04:00
aj 8628c69098 fix(ui): settle shift-click cloned parts with repeated directional pushes 2026-10-08 18:57:39 -04:00
aj c1c4b18f17 fix(ui): keep closed etch-only parts selectable 2026-10-08 13:50:12 -04:00
aj 7e887921ea fix(cutting): expose every unverified warning before consent 2026-10-08 13:50:12 -04:00
aj f50705c491 merge: integrate golden-angle Workshop colors 2026-10-08 13:34:23 -04:00
aj 81f3c006ab merge: integrate best-effort cutting on current master 2026-10-08 13:34:05 -04:00
aj b83518feac merge: integrate reviewed cutting-start planner fixes (#10)
Preserve reviewed cutting-start and plate-label commits; operator accepted A1-A4 and B1-B7. Excludes best-effort cutting and color trials.
2026-10-08 11:01:21 -04:00
aj 1826e6d5b5 feat(ui): add Workshop palette with distinct drawing colors
Keep cut outlines dark and etch strokes bright green. Assign drawing fills by a golden-angle hue walk with varied saturation/lightness tiers, preserving legacy schemes and rendering-only behavior. Include palette, visibility, persistence, and contrast regressions.
2026-10-08 10:24:11 -04:00
ajandClaude Sonnet 5 47140cc59e perf(ui): prefetch nearby nests in the Database Open dialog
Browsing the saved-nest list was slow because arrowing to each row
downloaded that nest's archive from scratch. NestDetailsSession now
keeps a small LRU cache and exposes Prefetch(id); SavedNestsForm warms
the rows around the highlight once its own details are showing, so
arrowing through the page mostly resolves from cache instead of
re-downloading each nest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-07 13:13:25 -04:00
aj 91aef95b1b fix(tests): acknowledge native BOM dialog without assuming button id
Fix DuplicateRows_CreateNestsButtonImportsOneDrawingWithCombinedQuantity timeout from Windows run 37526243901. Native trace on DesktopPC showed the sole OK button uses control ID 2, not IDOK 1. Preserve real dialog text and nest assertions; find the sole button and stop polling only after successful posting. All 392 WinForms and 49 FrontEnd tests pass in an interactive Windows session.
2026-10-06 16:52:52 -04:00
aj 9110cd62e0 fix(ci): retain readable results from unsuccessful Windows runs 2026-10-06 16:11:32 -04:00
aj 8e8b1de25f fix(ci): reject contradictory Windows test counters 2026-10-06 16:09:26 -04:00
aj 1add37d3e0 test(windows): require shown-form acceptance lifecycle cases 2026-10-06 16:00:48 -04:00
aj 71f41e23e4 test(ui): cover shown BOM import and nest info persistence 2026-10-06 15:59:57 -04:00
aj b591528aa2 ci(windows): retain fail-closed desktop acceptance evidence 2026-10-06 15:56:27 -04:00
aj 12206ed8fd feat(cutting): offer unverified best-effort plans for imperfect geometry 2026-10-06 14:44:53 -04:00
156 changed files with 10426 additions and 1031 deletions

No files matched your search

+5
View File
@@ -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
+1 -1
View File
@@ -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
+14
View File
@@ -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
+3
View File
@@ -5,6 +5,9 @@ namespace OpenNest.Api;
/// <summary>One explicit physical-stock type for a whole nesting job.</summary> /// <summary>One explicit physical-stock type for a whole nesting job.</summary>
public class NestRequestPlate public class NestRequestPlate
{ {
/// <summary>Positive per-sheet cost in common units; omit on every row for area scoring.</summary>
public double? Cost { get; init; }
public string Id { get; init; } public string Id { get; init; }
public Size Size { get; init; } public Size Size { get; init; }
+4
View File
@@ -44,6 +44,7 @@ public class NestResponse
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = []; public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = []; public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = []; public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
public NestCostSummary Costs { get; init; }
public Nest Nest { get; init; } public Nest Nest { get; init; }
public NestRequest Request { get; init; } public NestRequest Request { get; init; }
@@ -86,6 +87,7 @@ public class NestResponse
Fulfillment = Fulfillment is null Fulfillment = Fulfillment is null
? [] ? []
: new List<NestPartFulfillment>(Fulfillment), : new List<NestPartFulfillment>(Fulfillment),
Costs = Costs,
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage), StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
PlateStockMappings = PlateStockMappings is null PlateStockMappings = PlateStockMappings is null
? [] ? []
@@ -171,6 +173,7 @@ public class NestResponse
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [], Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [], StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [], PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
Costs = archive.Costs,
Nest = nest, Nest = nest,
Request = request, Request = request,
}; };
@@ -178,6 +181,7 @@ public class NestResponse
private sealed class NestResponseArchiveDto private sealed class NestResponseArchiveDto
{ {
public NestCostSummary Costs { get; init; }
public int SchemaVersion { get; init; } public int SchemaVersion { get; init; }
public int SheetCount { get; init; } public int SheetCount { get; init; }
public double Utilization { get; init; } public double Utilization { get; init; }
+8 -2
View File
@@ -28,6 +28,7 @@ public static class NestRunner
if (requestParts.Count == 0) if (requestParts.Count == 0)
throw new ArgumentException("Request must contain at least one part.", nameof(request)); throw new ArgumentException("Request must contain at least one part.", nameof(request));
var stock = CreateStock(request);
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var parts = IdentifyParts(requestParts); var parts = IdentifyParts(requestParts);
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal); var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
@@ -82,7 +83,6 @@ public static class NestRunner
}); });
} }
var stock = CreateStock(request);
var engineName = request.Engine ?? ResolvePlacementStrategy(request); var engineName = request.Engine ?? ResolvePlacementStrategy(request);
var jobProgress = progress == null ? null : new JobProgressBridge(progress); var jobProgress = progress == null ? null : new JobProgressBridge(progress);
var result = NestPipeline.Run(new NestPipelineRequest( var result = NestPipeline.Run(new NestPipelineRequest(
@@ -91,6 +91,9 @@ public static class NestRunner
// API returns a detached proposal, not an acceptance/commit to a caller's nest. // API returns a detached proposal, not an acceptance/commit to a caller's nest.
// Invalid but representable proposals retain every pose and carry explicit validation status. // Invalid but representable proposals retain every pose and carry explicit validation status.
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) }; var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
if (request.Plates != null)
nest.PlateOptions = stock.Where(s => s.Quantity != 0).Select(s => new PlateOption
{ Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
foreach (var item in items) foreach (var item in items)
nest.Drawings.Add(item.Drawing); nest.Drawings.Add(item.Drawing);
foreach (var proposed in result.Plates) foreach (var proposed in result.Plates)
@@ -144,6 +147,7 @@ public static class NestRunner
Violations = result.Violations, Violations = result.Violations,
Fulfillment = fulfillment, Fulfillment = fulfillment,
StockUsage = usage, StockUsage = usage,
Costs = result.CanKeep && result.IsValid ? NestCostSummary.FromAccepted(result) : null,
PlateStockMappings = result.Plates.Select((value, index) => PlateStockMappings = result.Plates.Select((value, index) =>
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(), new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
Nest = nest, Nest = nest,
@@ -209,11 +213,13 @@ public static class NestRunner
plate.Quantity, plate.Quantity,
plate.PartSpacing, plate.PartSpacing,
plate.EdgeSpacing, plate.EdgeSpacing,
plate.Quadrant plate.Quadrant,
plate.Cost
) )
); );
} }
NestJobValidator.ValidateStockCosts(stock);
return stock; return stock;
} }
@@ -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");
+10 -5
View File
@@ -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()) { }
+57
View File
@@ -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;
}
}
}
+4 -1
View File
@@ -15,7 +15,8 @@ public sealed class NestBrowseSession : IDisposable
private int _generation; private int _generation;
private bool _disposed; private bool _disposed;
public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit) public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit,
NestSortField sort = NestSortField.SavedAt, bool descending = true)
{ {
ArgumentNullException.ThrowIfNull(repository); ArgumentNullException.ThrowIfNull(repository);
if (pageSize < 1 || pageSize > NestQuery.MaxLimit) if (pageSize < 1 || pageSize > NestQuery.MaxLimit)
@@ -23,6 +24,8 @@ public sealed class NestBrowseSession : IDisposable
_repository = repository; _repository = repository;
PageSize = pageSize; PageSize = pageSize;
Sort = Enum.IsDefined(sort) ? sort : NestSortField.SavedAt;
Descending = descending;
} }
public int PageSize { get; } public int PageSize { get; }
+81 -3
View File
@@ -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()
+12
View File
@@ -32,6 +32,18 @@ public sealed class NestStorageSettings
public bool IsDatabaseMode => public bool IsDatabaseMode =>
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl); Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
/// <summary>Validate and normalize a nest server base URL without changing storage mode.</summary>
public static bool TryNormalizeServerUrl(string? url, out string normalized)
{
normalized = NormalizeUrl(url);
if (Uri.TryCreate(normalized, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return true;
normalized = "";
return false;
}
/// <summary>%APPDATA%\OpenNest\storage.json.</summary> /// <summary>%APPDATA%\OpenNest\storage.json.</summary>
public static string DefaultPath => Path.Combine( public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
@@ -0,0 +1,154 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutNfpProposalTests
{
private const double Spacing = 0.25;
[Fact]
public void LargeInsertFitsRoundCutoutAndRemainsValidAfterFrameRotation()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1, RotationPolicy.Fixed(System.Math.PI / 2));
var insert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(0));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.Equal(insert.Id, pose.PartId);
Assert.Equal(0, pose.InstanceIndex);
Assert.True(insert.Rotation.Allows(pose.Rotation));
AssertSquareInRoundHole(pose, 6, Spacing);
var localFrame = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), localFrame,
JobPartGeometry.Read(insert.Geometry), pose, Spacing));
var framePose = new NestJobPlacement(frame.Id, 0, 11, 11, System.Math.PI / 2);
var movedInsert = pose with
{
X = framePose.X - pose.Y,
Y = framePose.Y + pose.X,
Rotation = pose.Rotation + framePose.Rotation,
};
// This insert is fixed at zero for the local proposal; the rotated variant needs
// a correspondingly legal global policy before it can be handed to a job.
var rotatedInsert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(System.Math.PI / 2));
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), framePose,
JobPartGeometry.Read(insert.Geometry), movedInsert, Spacing));
AssertSquareInRoundHole(movedInsert, 6, Spacing, framePose.X, framePose.Y);
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
var job = JobBuilder.Job(new[] { frame, rotatedInsert }, new[] { stock });
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { framePose, movedInsert }) },
new[] { new PartFulfillment(frame.Id, 1, 1, 0), new PartFulfillment(insert.Id, 1, 1, 0) },
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
}
[Fact]
public void LargeInsertFitsSquareCutoutWithoutUsingFillLattice()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
var frame = JobBuilder.Part("square-frame", program, 1);
var insert = JobBuilder.Rectangle("large", 9, 9, 1, RotationPolicy.Fixed(0));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.All(new[] { (0.0, 0.0), (9.0, 0.0), (9.0, 9.0), (0.0, 9.0) }, corner =>
{
Assert.InRange(pose.X + corner.Item1, 5 + Spacing, 15 - Spacing);
Assert.InRange(pose.Y + corner.Item2, 5 + Spacing, 15 - Spacing);
});
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry),
new NestJobPlacement(frame.Id, 0, 0, 0, 0), JobPartGeometry.Read(insert.Geometry), pose, Spacing));
Assert.Null(CutoutNfpProposal.Find(frame, 0, JobBuilder.Rectangle("oversized", 9.6, 9.6, 1),
Spacing));
}
[Fact]
public void ObliqueFixedInsertRotationIsUsedInInnerFit()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
var frame = JobBuilder.Part("frame", program, 1);
var insert = JobBuilder.Rectangle("insert", 3, 2, 1, RotationPolicy.Fixed(System.Math.PI / 4));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.True(insert.Rotation.Allows(pose.Rotation));
foreach (var (x, y) in new[] { (0.0, 0.0), (3.0, 0.0), (3.0, 2.0), (0.0, 2.0) })
{
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
Assert.InRange(rx, 5 + Spacing, 15 - Spacing);
Assert.InRange(ry, 5 + Spacing, 15 - Spacing);
}
}
[Theory]
[InlineData(9.51)]
[InlineData(10.0)]
[InlineData(9.6)]
public void OversizedInsertIsNeverProposed(double side)
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", side, side, 1);
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, Spacing));
}
[Fact]
public void UnsupportedCoordinateScaleFailsClosed()
{
const double scale = 1e15;
var program = TestShapes.Polyline((-scale, -scale), (scale, -scale),
(scale, scale), (-scale, scale));
program.MoveTo(-scale / 2, -scale / 2);
program.LineTo(-scale / 2, scale / 2);
program.LineTo(scale / 2, scale / 2);
program.LineTo(scale / 2, -scale / 2);
program.LineTo(-scale / 2, -scale / 2);
Assert.Null(CutoutNfpProposal.Find(JobBuilder.Part("huge", program, 1), 0,
JobBuilder.Rectangle("insert", 6, 6, 1), Spacing));
}
[Fact]
public void ImpossibleHugeSpacingFailsBeforeAnyOffset()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, 1e12));
}
private static void AssertSquareInRoundHole(NestJobPlacement pose, double side, double spacing,
double centerX = 0, double centerY = 0)
{
var sin = System.Math.Sin(pose.Rotation);
var cos = System.Math.Cos(pose.Rotation);
foreach (var (x, y) in new[] { (0.0, 0.0), (side, 0.0), (side, side), (0.0, side) })
{
var dx = pose.X + x * cos - y * sin - centerX;
var dy = pose.Y + x * sin + y * cos - centerY;
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) <= 5 - spacing + 0.00001,
$"corner ({dx}, {dy}) does not clear the circular wall");
}
}
[Fact]
public void CancellationIsNotReportedAsNoFit()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
Assert.Throws<OperationCanceledException>(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing,
cancelled.Token));
}
}
@@ -0,0 +1,749 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
[CollectionDefinition("Cutout pipeline registry", DisableParallelization = true)]
public sealed class CutoutPipelineRegistryCollection { }
[Collection("Cutout pipeline registry")]
public class CutoutPipelinePreviewTests
{
private static NestPipelineRequest Request(string engine = "Irregular")
{
var frame = new Drawing("frame", TestShapes.Ring(20, 10));
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
return new NestPipelineRequest(engine,
new[] { new NestItem { Drawing = frame, Quantity = 1 },
new NestItem { Drawing = insert, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
}
[Theory]
[InlineData("Irregular")]
[InlineData("Default")]
public void RegisteredEngineExpandsOnlyPhysicalOriginalDrawings(string engine)
{
var request = Request(engine);
var result = NestPipeline.RunCutoutPreview(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.True(result.CanKeep);
var plate = Assert.Single(result.Plates);
Assert.Equal(2, plate.Parts.Count);
Assert.Same(request.Items[0].Drawing, plate.Parts[0].BaseDrawing);
Assert.Same(request.Items[1].Drawing, plate.Parts[1].BaseDrawing);
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates[0].Placements.Select(p => p.PartId));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 1, 0), (f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(0, request.Items[0].Drawing.Quantity.Nested);
Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested);
}
private sealed class Stub(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => solve(job);
}
private static NestJobResult Result(NestJob job, params NestJobPlacement[] poses)
{
var placed = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count());
var complete = job.Parts.All(p => placed.GetValueOrDefault(p.Id) == p.Quantity);
return new NestJobResult(complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
complete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound,
poses.Length == 0 ? Array.Empty<NestJobPlateResult>() :
new[] { new NestJobPlateResult(0, job.Plates[0], poses) },
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity,
placed.GetValueOrDefault(p.Id), p.Quantity - placed.GetValueOrDefault(p.Id))),
new[] { new StockUsage(job.Plates[0].Id, poses.Length == 0 ? 0 : 1,
job.Plates[0].Quantity - (poses.Length == 0 ? 0 : 1)) });
}
[Fact]
public void SingleSolveReceivesOneProxyAndNoDuplicatedInsertDemand()
{
var called = 0;
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
called++;
var proxy = Assert.Single(job.Parts);
Assert.StartsWith("__cutout-proxy-", proxy.Id);
Assert.True(proxy.Rotation.Allows(0));
Assert.False(proxy.Rotation.Allows(System.Math.PI / 2));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request);
Assert.Equal(1, called);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Single().Parts.Count);
}
[Fact]
public void IncompleteEngineDoesNotBindReservedInsertEvenWithInvalidConsent()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job => Result(job)), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("did not complete"));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 0, 1),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested);
AssertCannotCommit(result, request);
}
[Fact]
public void ForgedFulfillmentOrDuplicateIndexDoesNotBindComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var id = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")).Id;
var pose = new NestJobPlacement(id, 1, 11, 11, 0);
return Result(job, pose);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
}
[Fact]
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
{
var request = Request("stub");
request.Items[1].Quantity = 2;
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } };
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var residual = job.Parts.Single(p => p.Id == "part-1");
Assert.Equal(1, residual.Quantity);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement(residual.Id, 0, 22, 1, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.Single().Placements
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
Assert.All(result.Plates.Single().Parts.Where(p => p.BaseDrawing == request.Items[1].Drawing),
p => Assert.Same(request.Items[1].Drawing, p.BaseDrawing));
}
[Fact]
public void UntrustedStockOverdrawOrRotationCannotBindAComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11,
System.Math.PI / 2));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("rotation constraint"));
var overdraw = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, job.Plates[0], new[] {
new NestJobPlacement(job.Parts[0].Id, 0, 11, 11, 0) }),
new NestJobPlateResult(1, job.Plates[0], new[] {
new NestJobPlacement(job.Parts[0].Id, 1, 11, 11, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>())), "stub", request);
Assert.False(overdraw.CanKeep);
Assert.Empty(overdraw.Plates);
Assert.Contains(overdraw.Violations, v => v.Contains("only 1 are available"));
}
[Fact]
public void CancellationBeforePreparationDoesNotCallEngine()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
var engine = new Stub(_ => throw new Exception("Must not call Solve"));
Assert.Throws<OperationCanceledException>(() =>
NestPipeline.RunCutoutPreview(engine, "stub", Request("stub"), token: cts.Token));
}
[Fact]
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
{
var request = Request("stub");
request.Items[1].Quantity = 2;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement("part-1", 0, 11, 11, 0));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("required spacing"));
AssertCannotCommit(result, request);
}
private static void AssertCannotCommit(NestPipelineResult result, NestPipelineRequest request)
{
var nest = new Nest();
foreach (var item in request.Items)
nest.Drawings.Add(item.Drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
Assert.Throws<InvalidOperationException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
}
private sealed class CollectProgress : IProgress<NestJobProgress>
{
public List<NestJobProgress> Events { get; } = new();
public void Report(NestJobProgress value) => Events.Add(value);
}
[Fact]
public void EvaluationProgressSurvivesButUnverifiedCommitsNeverEscape()
{
var request = Request("stub");
var progress = new CollectProgress();
var result = NestPipeline.RunCutoutPreview(new ProgressStub(), "stub", request, progress);
Assert.False(result.CanKeep);
Assert.Single(progress.Events);
Assert.Equal(NestJobStage.EvaluatingCandidate, progress.Events[0].Stage);
Assert.Equal(0, progress.Events[0].CommittedPlates);
Assert.Equal(0, progress.Events[0].CommittedParts);
}
private sealed class ProgressStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
job.Plates[0].Id, 0, 1, 1));
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
job.Plates[0].Id, 0, 1, 1));
return Result(job);
}
}
[Fact]
public void ReorderedButContiguousEngineIndicesAreAccepted()
{
var request = Request("stub");
request.Items[1].Quantity = 3;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) } };
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
Assert.Equal(2, job.Parts.Single(p => p.Id == "part-1").Quantity);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement("part-1", 1, 22, 1, 0),
new NestJobPlacement("part-1", 0, 22, 9, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { 0, 1, 2 }, result.Raw.Plates.Single().Placements
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ForgedFulfillmentOrStockMetadataCannotBind(bool fulfillment)
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var good = Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
return new NestJobResult(good.Status, good.StopReason, good.Plates,
fulfillment ? new[] { new PartFulfillment(proxy.Id, 1, 0, 1) } : good.Fulfillment,
fulfillment ? good.StockUsage : new[] { new StockUsage("sheet", 0, 1) });
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
}
[Fact]
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
{
var initial = Request("stub");
var request = initial with
{
Items = new[] { initial.Items[0], initial.Items[1],
new NestItem { Drawing = initial.Items[1].Drawing, Quantity = 1 } },
Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) }
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var residual = job.Parts.Where(p => p.Id != proxy.Id)
.Select((p, i) => new NestJobPlacement(p.Id, 0, 22, 1 + 5 * i, 0));
return Result(job, new[] { new NestJobPlacement(proxy.Id, 0, 11, 11, 0) }
.Concat(residual).ToArray());
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1", "part-2" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
Assert.Same(result.Plates.Single().Parts[1].BaseDrawing,
result.Plates.Single().Parts[2].BaseDrawing);
}
[Fact]
public void RepeatedFrameReservationsExpandOnTwoPhysicalSheets()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with
{
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
Options = new NestJobOptions(maxPlates: 2)
};
var calls = 0;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
calls++;
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
Assert.Equal(2, proxies.Length);
Assert.Equal(2, job.Parts.Count);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in proxies)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.Equal(1, calls);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Raw.Plates.Count);
Assert.All(result.Raw.Plates, plate => Assert.Equal(new[] { "part-0", "part-1" },
plate.Placements.Select(p => p.PartId)));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 2, 0),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
}
[Fact]
public void UnplacedFrameReleasesInsertDemandWithoutABindablePartialProposal()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } };
var calls = 0;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
calls++;
var first = job.Parts.Single(p => p.Id.EndsWith("-0"));
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[0], new[] { (first.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.NoPlacementFound);
}), "stub", request);
Assert.Equal(1, calls);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Equal(NestJobStatus.Incomplete, result.Raw.Status);
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 1, 1),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId));
AssertCannotCommit(result, request);
}
[Fact]
public void DifferentFrameRequirementsShareScarceInsertsByIdentity()
{
var initial = Request("stub");
var frame2 = new Drawing("second-frame", TestShapes.Ring(18, 10));
initial.Items[1].Quantity = 2;
initial.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
var request = initial with
{
Items = new[] { initial.Items[0],
new NestItem { Drawing = frame2, Quantity = 1 }, initial.Items[1] },
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
Assert.Equal(2, proxies.Length);
Assert.Equal(2, job.Parts.Count);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in proxies)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-2", "part-1", "part-2" },
result.Raw.Plates.SelectMany(p => p.Placements).Select(p => p.PartId));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
.Where(p => p.PartId == "part-2").Select(p => p.InstanceIndex));
}
[Fact]
public void FixedQuarterTurnComposesLocalInsertRotationAndTranslation()
{
var frame = TestShapes.Rectangle(20, 20);
frame.MoveTo(5, 5);
frame.LineTo(5, 15);
frame.LineTo(15, 15);
frame.LineTo(15, 5);
frame.LineTo(5, 5);
var quarter = System.Math.PI / 2;
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 1,
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1,
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
Assert.True(proxy.Rotation.Allows(quarter));
Assert.False(proxy.Rotation.Allows(0));
return Result(job, new NestJobPlacement(proxy.Id, 0, 21, 1, quarter));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var poses = result.Raw.Plates.Single().Placements;
Assert.Equal(quarter, poses[0].Rotation, 8);
Assert.Equal(quarter, poses[1].Rotation, 8);
// Independent square-hole oracle, not the production clearance predicate.
var localX = poses[1].Y - poses[0].Y;
var localY = -(poses[1].X - poses[0].X);
foreach (var dx in new[] { 0.0, 3.0 })
foreach (var dy in new[] { 0.0, 3.0 })
{
Assert.InRange(localX + dx, 5.25, 14.75);
Assert.InRange(localY + dy, 5.25, 14.75);
}
}
[Fact]
public void HeterogeneousStockUsesConservativeSpacingAndOriginalQuadrant()
{
var request = Request("stub") with
{
Stock = new[] {
new NestPlateStock("tight", new Size(22, 22), 1, 0.25),
new NestPlateStock("wide", new Size(22, 22), 1, 0.6, quadrant: 3) }
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[1], new[] { (proxy.Id, -11.0, -11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal("wide", result.Raw.Plates.Single().StockId);
Assert.Equal(3, result.Plates.Single().Stock.Quadrant);
Assert.Equal(0.6, result.Plates.Single().Stock.PartSpacing);
Assert.Equal(2, result.Plates.Single().Parts.Count);
}
[Fact]
public void RegisteredPluginRunsTheSamePhysicalExpansionBoundary()
{
var name = "CutoutPlugin-" + Guid.NewGuid().ToString("N");
var calls = 0;
NestingEngineRegistry.Register(name, "test plugin", () => new Stub(job =>
{
calls++;
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}));
try
{
var result = NestPipeline.RunCutoutPreview(Request(name));
Assert.Equal(1, calls);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
}
finally
{
var registry = Assert.IsType<List<NestingEngineInfo>>(NestingEngineRegistry.AvailableEngines);
Assert.Equal(1, registry.RemoveAll(info => info.Name == name));
}
}
[Fact]
public void TwoCutoutsReserveDifferentCopiesWithinOneFrame()
{
var program = TestShapes.Rectangle(20, 20);
foreach (var origin in new[] { 2.0, 12.0 })
{
program.MoveTo(origin, origin);
program.LineTo(origin, origin + 6);
program.LineTo(origin + 6, origin + 6);
program.LineTo(origin + 6, origin);
program.LineTo(origin, origin);
}
var frame = new Drawing("two-hole-frame", program);
var insert = new Drawing("insert", TestShapes.Rectangle(4, 4));
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = frame, Quantity = 1 },
new NestItem { Drawing = insert, Quantity = 2 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 1, 1, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var inserts = result.Raw.Plates.Single().Placements.Skip(1).ToArray();
Assert.Equal(2, inserts.Length);
Assert.All(inserts, p =>
{
var localX = p.X - 1;
var localY = p.Y - 1;
Assert.Contains(new[] { 2.0, 12.0 }, origin =>
localX >= origin + 0.25 && localX + 4 <= origin + 5.75
&& localY >= origin + 0.25 && localY + 4 <= origin + 5.75);
});
}
[Fact]
public void DifferentPrioritiesRemainIndependentInsteadOfElevatingAnInsert()
{
var request = Request("stub");
request.Items[1].Priority = 3;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job);
}), "stub", request);
Assert.True(result.CanKeep);
Assert.Empty(result.Plates);
}
[Theory]
[InlineData(1, 11.0, 11.0)]
[InlineData(2, -11.0, 11.0)]
[InlineData(3, -11.0, -11.0)]
[InlineData(4, 11.0, -11.0)]
public void AllQuadrantsKeepPhysicalStockAndExpansion(int quadrant, double x, double y)
{
var request = Request("stub") with
{
Stock = new[] {
new NestPlateStock("sheet", new Size(22, 22), 1, 0.25,
new Spacing(0.5, 0.5), quadrant) },
Options = new NestJobOptions(maxPlates: 1)
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, x, y, 0))),
"stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(quadrant, result.Plates.Single().Stock.Quadrant);
Assert.Equal((x, y), (result.Raw.Plates.Single().Placements[0].X,
result.Raw.Plates.Single().Placements[0].Y));
}
[Fact]
public void CancellationAfterAnEngineIgnoresStopDiscardsEveryBundle()
{
var request = Request("stub");
using var cts = new CancellationTokenSource();
Assert.Throws<OperationCanceledException>(() => NestPipeline.RunCutoutPreview(
new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
cts.Cancel();
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request, token: cts.Token));
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
}
[Fact]
public void CompleteStatusWithMissingProxyIsUntrustedAndNotBindable()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
NestJobStatus.Complete, NestJobStopReason.Completed,
Array.Empty<NestJobPlateResult>(),
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)),
job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)))), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
AssertCannotCommit(result, request);
}
[Fact]
public void SuccessfulCompositeReportsOnlyValidatedPhysicalCommitCounts()
{
var request = Request("stub");
var progress = new CollectProgress();
var result = NestPipeline.RunCutoutPreview(new ReportingStub(), "stub", request, progress);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { NestJobStage.EvaluatingCandidate, NestJobStage.PlateCommitted },
progress.Events.Select(e => e.Stage));
Assert.Equal(2, progress.Events.Last().CommittedParts);
Assert.Equal(1, progress.Events.Last().CommittedPlates);
}
private sealed class ReportingStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var proxy = Assert.Single(job.Parts);
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
job.Plates[0].Id, 0, 0, 0));
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
job.Plates[0].Id, 0, 1, 1));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}
}
[Fact]
public void AutomaticBundleRetainsStockFeasibleQuarterTurn()
{
var program = TestShapes.Rectangle(20, 10);
program.MoveTo(2, 2);
program.LineTo(2, 8);
program.LineTo(18, 8);
program.LineTo(18, 2);
program.LineTo(2, 2);
var request = new NestPipelineRequest("Irregular", new[] {
new NestItem { Drawing = new Drawing("long-frame", program), Quantity = 1 },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1 } },
new[] { new NestPlateStock("narrow", new Size(22, 12), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var frame = result.Raw.Plates.Single().Placements[0];
Assert.Equal(System.Math.PI / 2, frame.Rotation, 7);
Assert.Equal(new[] { "part-0", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
}
[Fact]
public void ChangedLiveInsertDuringSolveNeverBindsUncheckedComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
request.Items[1].Drawing.Program = TestShapes.Rectangle(40, 40);
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("insert"));
AssertCannotCommit(result, request);
}
[Fact]
public void NondefaultOptionsPassUnchangedAndPlateCapRejectsOtherwiseValidOutput()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
var options = new NestJobOptions("Strip", maxPlates: 1,
salvageRate: 0.4, minimumSalvageDimension: 2);
request = request with
{
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
Options = options
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.Same(options, job.Options);
Assert.Equal("Strip", job.Options.PlacementStrategy);
Assert.Equal(0.4, job.Options.SalvageRate);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in job.Parts)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("MaxPlates"));
AssertCannotCommit(result, request);
}
[Fact]
public void ObliqueFramePreservesLegalGlobalInsertRotationAndWallClearance()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
const double angle = 0.3;
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", program), Quantity = 1,
StepAngle = 1, RotationStart = angle, RotationEnd = angle },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)),
Quantity = 1, StepAngle = 1, RotationStart = angle, RotationEnd = angle } },
new[] { new NestPlateStock("sheet", new Size(40, 40), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, 15, 5, angle))),
"stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var poses = result.Raw.Plates.Single().Placements;
Assert.Equal(angle, poses[1].Rotation, 8);
var dx = poses[1].X - poses[0].X;
var dy = poses[1].Y - poses[0].Y;
var cosine = System.Math.Cos(angle);
var sine = System.Math.Sin(angle);
var localX = dx * cosine + dy * sine;
var localY = -dx * sine + dy * cosine;
foreach (var x in new[] { 0.0, 3.0 })
foreach (var y in new[] { 0.0, 3.0 })
{
Assert.InRange(localX + x, 5.25, 14.75);
Assert.InRange(localY + y, 5.25, 14.75);
}
}
[Fact]
public void LargeDemandLeavesExcessFramesAndInsertsIndependentAfterBoundedPreparation()
{
var frame = TestShapes.Rectangle(20, 20);
frame.MoveTo(5, 5);
frame.LineTo(5, 15);
frame.LineTo(15, 15);
frame.LineTo(15, 5);
frame.LineTo(5, 5);
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 40,
StepAngle = 1, RotationStart = 0, RotationEnd = 0 },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(6, 6)),
Quantity = 40, StepAngle = 1, RotationStart = 0, RotationEnd = 0 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 40, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.Equal(32, job.Parts.Count(p => p.Id.StartsWith("__cutout-proxy-")));
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-0").Quantity);
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-1").Quantity);
return Result(job);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((40, 0, 40),
(f.Requested, f.Placed, f.Unplaced)));
}
[Fact]
public void OrdinaryPipelineIsStillUnchanged()
{
var request = Request("stub");
var result = NestPipeline.Run(new Stub(job =>
{
Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job);
}), "stub", request);
Assert.True(result.CanKeep);
Assert.Empty(result.Plates);
}
}
@@ -0,0 +1,228 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutRouterTests
{
private static NestJobPart Frame()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
return JobBuilder.Part("frame", program, 1);
}
[Fact]
public void SmallLatticeAndLargeResidualKeepTheirOriginalRequirementIds()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var small = JobBuilder.Part("small", TestShapes.Rectangle(1, 1), 3);
var large = JobBuilder.Rectangle("large", 3, 3, 1, RotationPolicy.Fixed(0));
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25,
(pose, lattice) => origins.Add((pose, lattice)));
Assert.Equal(3, poses.Count(p => p.PartId == small.Id));
Assert.Single(poses, p => p.PartId == large.Id);
Assert.Contains(origins, row => row.Pose.PartId == small.Id && row.Lattice);
Assert.Contains(origins, row => row.Pose.PartId == large.Id && !row.Lattice);
Assert.Equal(poses, origins.Select(row => row.Pose));
Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == small.Id).Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { small, large }, poses);
}
[Fact]
public void ImpossibleRemainderNeverDisplacesCertifiedCopies()
{
var frame = Frame();
var small = JobBuilder.Rectangle("small", 1, 1, 3, RotationPolicy.Fixed(0));
var impossible = JobBuilder.Rectangle("impossible", 9.6, 9.6, 1, RotationPolicy.Fixed(0));
var together = CutoutRouter.Fill(frame, 0, new[] { small, impossible }, 0.25);
Assert.Equal(3, together.Count);
Assert.All(together, p => Assert.Equal(small.Id, p.PartId));
Assert.Equal(Enumerable.Range(0, 3), together.Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { small, impossible }, together);
}
[Fact]
public void PriorCopiesAndQuantityCannotBeBypassedByResidualSearch()
{
var frame = Frame();
var small = JobBuilder.Rectangle("small", 2, 2, 4, RotationPolicy.Fixed(0));
var large = JobBuilder.Rectangle("large", 7, 7, 1, RotationPolicy.Fixed(0));
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25);
Assert.InRange(poses.Count(p => p.PartId == small.Id), 0, small.Quantity);
Assert.InRange(poses.Count(p => p.PartId == large.Id), 0, large.Quantity);
AssertPhysical(frame, new[] { small, large }, poses);
}
[Fact]
public void ResidualNfpDoesNotOverlapTheFirstLargeInsert()
{
var frame = Frame();
var insert = JobBuilder.Rectangle("large", 6, 6, 2, RotationPolicy.Fixed(0));
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Single(poses);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void NfpAddsOneMoreCopyToPartialShiftedLattice()
{
// Neutral 8-inch round hole, 2x1 insert, quarter-inch spacing: the shifted
// lattice keeps 11; an independently certified NFP pose occupies its leftover.
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12);
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25,
(pose, lattice) => origins.Add((pose, lattice)));
Assert.Equal(12, poses.Count);
Assert.Equal(11, origins.Count(row => row.Lattice));
Assert.Single(origins, row => !row.Lattice);
Assert.Equal(poses, origins.Select(row => row.Pose));
Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void MiddleRatioChoosesTwoLatticeCopiesOverOneNfpCopy()
{
// 4x4 / (pi*5^2) = 0.204. A hard NFP-only threshold at 0.20 loses one copy.
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(4, 4), 3);
Assert.InRange(16 / (System.Math.PI * 25), CutoutRouter.SmallRatio, CutoutRouter.LargeRatio);
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Equal(2, poses.Count);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void LargeRatioKeepsAnNfpPoseWhenFillCannotProduceALattice()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
var insert = JobBuilder.Rectangle("insert", 4.5, 4.5, 3, RotationPolicy.Fixed(0));
Assert.True(20.25 / (System.Math.PI * 16) > CutoutRouter.LargeRatio);
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, insert, 3, 0.25));
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Single(poses);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void DuplicateRequirementIdsAreRejectedBeforePlacement()
{
var frame = Frame();
var insert = JobBuilder.Rectangle("same", 1, 1, 3);
Assert.Throws<ArgumentException>(() => CutoutRouter.Fill(frame, 0,
new[] { insert, insert }, 0.25));
}
[Fact]
public void OccupiedAwareLatticeFillsAfterAnEarlierCopyBlocksItsFirstPose()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var first = JobBuilder.Part("first", TestShapes.Rectangle(1, 1), 1);
var more = JobBuilder.Part("more", TestShapes.Rectangle(1, 1), 3);
var early = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25);
Assert.Equal(3, early.Count);
var blocked = early[0] with { PartId = first.Id };
var occupied = new[] { (JobPartGeometry.Read(first.Geometry), blocked) };
var later = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25,
CutoutLatticeFill.DefaultShiftSteps, occupied, default);
Assert.Equal(3, later.Count);
Assert.All(later, p => Assert.True(NestLayoutCheck.Clears(occupied[0].Item1, blocked,
JobPartGeometry.Read(more.Geometry), p, 0.25)));
}
[Fact]
public void TinyInsertDoesNotAllocateAnUnboundedLattice()
{
var frame = Frame();
var tiny = JobBuilder.Rectangle("tiny", 0.001, 0.001, 3, RotationPolicy.Fixed(0));
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, tiny, 3, 0));
var poses = CutoutRouter.Fill(frame, 0, new[] { tiny }, 0);
Assert.Equal(3, poses.Count);
}
[Fact]
public void CancellationIsNotNoFit()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.Throws<OperationCanceledException>(() => CutoutRouter.Fill(Frame(), 0,
new[] { JobBuilder.Rectangle("small", 1, 1, 3) }, 0.25, cts.Token));
}
private static void AssertPhysical(NestJobPart frame, NestJobPart[] inserts,
IReadOnlyList<NestJobPlacement> poses)
{
var stock = JobBuilder.Stock("sheet", 22, 22, 0.25);
var job = JobBuilder.Job(new[] { frame }.Concat(inserts).ToArray(), new[] { stock });
var frameBounds = JobPartGeometry.Read(frame.Geometry).Bounds;
var offsetX = 1 - frameBounds.Left;
var offsetY = 1 - frameBounds.Bottom;
var placements = new[] { new NestJobPlacement(frame.Id, 0, offsetX, offsetY, 0) }
.Concat(poses.Select(p => p with { X = p.X + offsetX, Y = p.Y + offsetY })).ToArray();
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, placements) },
new[] { new PartFulfillment(frame.Id, 1, 1, 0) }
.Concat(inserts.Select(part => new PartFulfillment(part.Id, part.Quantity,
poses.Count(p => p.PartId == part.Id), part.Quantity - poses.Count(p => p.PartId == part.Id))))
.ToArray(),
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
foreach (var pose in poses)
{
var geometry = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry);
Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, 0.25));
// Independent square-wall oracle: every corner of each rectangular insert clears the hole.
var bounds = geometry.Bounds;
foreach (var (x, y) in new[] { (bounds.Left, bounds.Bottom), (bounds.Right, bounds.Bottom),
(bounds.Right, bounds.Top), (bounds.Left, bounds.Top) })
{
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
if (frameBounds.Left >= 0)
{
Assert.InRange(rx, 5.25, 14.75);
Assert.InRange(ry, 5.25, 14.75);
}
else
{
var radius = System.Math.Abs(frameGeometry.Cutouts[0].BoundingBox.Right);
Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= radius - 0.25 + 0.00001);
}
}
}
// Independent rectangle-vs-rectangle distance, not the production Clears predicate.
for (var i = 0; i < poses.Count; i++)
for (var j = i + 1; j < poses.Count; j++)
{
var a = Bounds(poses[i]);
var b = Bounds(poses[j]);
var dx = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right));
var dy = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top));
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.25 - 0.00001,
$"independent material gap too small: {poses[i]} vs {poses[j]}");
}
(double Left, double Right, double Bottom, double Top) Bounds(NestJobPlacement pose)
{
var box = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry).Bounds;
var corners = new[] { (box.Left, box.Bottom), (box.Right, box.Bottom),
(box.Right, box.Top), (box.Left, box.Top) };
var x = corners.Select(c => pose.X + c.Item1 * System.Math.Cos(pose.Rotation)
- c.Item2 * System.Math.Sin(pose.Rotation)).ToArray();
var y = corners.Select(c => pose.Y + c.Item1 * System.Math.Sin(pose.Rotation)
+ c.Item2 * System.Math.Cos(pose.Rotation)).ToArray();
return (x.Min(), x.Max(), y.Min(), y.Max());
}
}
}
+11 -4
View File
@@ -9,9 +9,9 @@ public class NestJobCostTests
public static IEnumerable<object[]> EdgeCases() public static IEnumerable<object[]> EdgeCases()
{ {
foreach (var rate in new[] { 0.0, 0.5, 1.0 }) foreach (var rate in new[] { 0.0, 0.5, 1.0 })
foreach (var edge in new[] { "bottom", "top", "left", "right" }) foreach (var edge in new[] { "bottom", "top", "left", "right" })
foreach (var quadrant in new[] { 1, 2, 3, 4 }) foreach (var quadrant in new[] { 1, 2, 3, 4 })
yield return new object[] { rate, edge, quadrant }; yield return new object[] { rate, edge, quadrant };
} }
[Theory] [Theory]
@@ -84,7 +84,14 @@ public class NestJobCostTests
TestDrawingFactory.Rectangle(4, 3)), 5); TestDrawingFactory.Rectangle(4, 3)), 5);
var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options); var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options);
foreach (var sheet in result.Plates) foreach (var sheet in result.Plates)
Assert.Equal(LegacyNestJobCost.EstimateNetArea(cleanJob, sheet), NestJobCost.NetSheetArea(job, sheet)); {
var area = LegacyNestJobCost.EstimateNetArea(cleanJob, sheet);
Assert.Equal(area, NestJobCost.NetSheetArea(job, sheet));
var priced = new NestPlateStock(sheet.Stock.Id, sheet.Stock.Size, sheet.Stock.Quantity,
sheet.Stock.PartSpacing, sheet.Stock.EdgeSpacing, sheet.Stock.Quadrant, 37);
var pricedSheet = new NestJobPlateResult(sheet.PlateIndex, priced, sheet.Placements);
Assert.Equal(37 * (area / sheet.Stock.Area), NestJobCost.NetSheetCost(job, pricedSheet));
}
Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job)); Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job));
Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000, Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000,
NestJobCost.Evaluate(job, result)); NestJobCost.Evaluate(job, result));
@@ -75,6 +75,73 @@ public class NestPipelineCommitTests
Assert.Equal(2, Assert.Single(applied).Parts.Count); Assert.Equal(2, Assert.Single(applied).Parts.Count);
} }
[Fact]
public void DrawingChangedAfterValidationIsRefusedBeforeCommitEvenWithConsent()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new PreMutationStub(), "stub",
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
drawing.Program = TestDrawingFactory.Rectangle(100, 100);
Assert.Throws<InvalidOperationException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.Equal(0, drawing.Quantity.Nested);
}
private sealed class PreMutationStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[0], new[] { (job.Parts[0].Id, 1.0, 1.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}
}
private sealed class CancelDuringSnapshot(double x, double y, CancellationTokenSource source)
: OpenNest.CNC.LinearMove(x, y)
{
public override OpenNest.CNC.CodeType Type
{
get
{
source.Cancel();
return base.Type;
}
}
}
[Fact]
public void CancellationDuringFreshnessScanDoesNotMutateNest()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new PreMutationStub(), "stub",
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
Assert.True(result.IsValid);
using var cts = new CancellationTokenSource();
var proposed = Assert.Single(Assert.Single(result.Plates).Parts);
proposed.Program.Codes[1] = new CancelDuringSnapshot(10, 0, cts);
Assert.Throws<OperationCanceledException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, token: cts.Token));
Assert.True(cts.IsCancellationRequested);
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.Equal(0, drawing.Quantity.Nested);
}
[Fact] [Fact]
public void CancelledCommitDoesNotCreateAnyPlate() public void CancelledCommitDoesNotCreateAnyPlate()
{ {
@@ -258,6 +258,44 @@ public class NestPipelineTests
); );
} }
[Fact]
public void DrawingChangedDuringSolveCannotBindGeometryOtherThanTheValidatedSnapshot()
{
var item = Item("bracket", 1);
var result = NestPipeline.Run(new StubEngine(job =>
{
item.Drawing.Program = TestDrawingFactory.Rectangle(100, 100);
return OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0));
}), "Mutator", Request("Mutator", item));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("bracket"));
}
private sealed class TransientCloneMove(double x, double y) : LinearMove(x, y)
{
public override ICode Clone()
{
var before = EndPoint;
EndPoint = new Vector(100, 100);
try { return base.Clone(); }
finally { EndPoint = before; }
}
}
[Fact]
public void BindingTransientDrawingEditCannotReturnOrCommitUncheckedGeometry()
{
var item = Item("bracket", 1);
item.Drawing.Program.Codes[^1] = new TransientCloneMove(0, 0);
var result = NestPipeline.Run(new StubEngine(job =>
OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0))),
"Transient", Request("Transient", item));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("Bound part") && v.Contains("bracket"));
}
[Fact] [Fact]
public void CancellationPropagatesWithoutAResult() public void CancellationPropagatesWithoutAResult()
{ {
@@ -0,0 +1,165 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class StockCostTests
{
public static IEnumerable<object[]> Engines()
{
foreach (var engine in new[] { "Rectangles", "Irregular", "StockLadder", "Default", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" })
foreach (var factor in new[] { 1e-12, 1.0, 100.0 })
yield return new object[] { engine, factor };
}
private static NestPlateStock Stock(string id, double cost, int quantity = 2, double size = 10) =>
new(id, new Size(size, size), quantity, 0.1, default, 1, cost);
private static NestJob Job(params NestPlateStock[] stocks) => new(
new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 1) }, stocks);
[Fact]
public void ValidationRejectsForgedStockPrice()
{
var job = Job(Stock("offered", 10));
var forged = Stock("offered", 1);
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, forged, new[] { new NestJobPlacement("part", 0, 0, 0, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.NotEmpty(NestLayoutCheck.Violations(job, result));
}
[Fact]
public void UnavailablePriceDoesNotChangeAreaModeOrLegacyPenalty()
{
var job = Job(new NestPlateStock("available", new Size(10, 10)), Stock("unavailable", 10000, 0));
Assert.False(NestJobCost.UsesExplicitCosts(job));
Assert.Equal(100, NestJobCost.UnplacedPartPenalty(job));
}
[Fact]
public void FixedStrategyUsesSalvageOnlyForPricedStock()
{
var poses = new[] { new NestJobPlacement("part", 0, 0, 0, 0) };
var candidate = new PlateCandidate(poses);
var priced = Job(Stock("small", 10, size: 5), Stock("large", 12, size: 10));
var options = new NestJobOptions(salvageRate: 1, minimumSalvageDimension: 1);
var pricedJob = new NestJob(priced.Parts, priced.Plates, options);
Assert.True(new NestJobCandidateComparer(pricedJob).Compare(candidate, priced.Plates[1], 1,
candidate, priced.Plates[0], 0) > 0);
var unpriced = new NestJob(priced.Parts, new[] { new NestPlateStock("small", new Size(5, 5)),
new NestPlateStock("large", new Size(10, 10)) }, options);
Assert.True(new NestJobCandidateComparer(unpriced).Compare(candidate, unpriced.Plates[0], 0,
candidate, unpriced.Plates[1], 1) > 0);
}
[Theory]
[InlineData(0, 20, 0)]
[InlineData(0.5, 14, 6)]
public void SummarySeparatesGrossCreditAndNet(double rate, double net, double credit)
{
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(4, 4));
var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20);
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, new[] { stock },
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 6)));
Assert.Empty(result.Violations);
var summary = NestCostSummary.FromAccepted(result);
Assert.Equal("supplied-cost", summary.Basis);
Assert.Equal(20, summary.GrossTotal);
Assert.Equal(net, summary.NetScore, 10);
Assert.Equal(credit, summary.SalvageCredit, 10);
Assert.Equal(1, Assert.Single(summary.Stock).Used);
}
[Theory]
[MemberData(nameof(Engines))]
public void PricesReverseSelectionWithoutChangingFulfillment(string engine, double factor)
{
foreach (var cheap in new[] { "small", "large" })
{
var job = Job(Stock("small", (cheap == "small" ? 1 : 10) * factor),
Stock("large", (cheap == "large" ? 1 : 10) * factor, size: 12));
var result = NestingEngineRegistry.Create(engine).Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Assert.Equal(cheap, Assert.Single(result.Plates).StockId);
Assert.Equal(factor, NestJobCost.Evaluate(job, result));
}
}
[Theory]
[InlineData("Rectangles")]
[InlineData("Irregular")]
[InlineData("StockLadder")]
[InlineData("Default")]
[InlineData("Fill")]
public void SameSizeStockRetainsIndependentFiniteInventory(string engine)
{
var stocks = new[] { Stock("expensive", 10, 2, 5), Stock("cheap", 1, 1, 5) };
var job = new NestJob(new[] { new NestJobPart("part",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 3) }, stocks);
var result = NestingEngineRegistry.Create(engine).Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Assert.Equal("cheap", result.Plates[0].StockId);
Assert.Single(result.Plates.Where(p => p.StockId == "cheap"));
Assert.Equal(21, NestJobCost.Evaluate(job, result));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void ExplicitCostsMustBeFiniteAndPositive(double cost) =>
Assert.Throws<ArgumentOutOfRangeException>(() => Stock("bad", cost));
[Fact]
public void MixedModeRejectsOnlyAvailableStock()
{
Assert.Throws<ArgumentException>(() => Job(Stock("priced", 2), new NestPlateStock("missing", new Size(10, 10))));
var job = Job(Stock("priced", 2), new NestPlateStock("unavailable", new Size(10, 10), 0));
NestJobValidator.Validate(job);
Assert.Equal(2, NestJobCost.UnplacedPartPenalty(job));
}
[Theory]
[InlineData(0, 5, 20)]
[InlineData(0.5, 0, 20)]
[InlineData(0.5, 7, 20)]
[InlineData(0.5, 6, 14)]
public void PricesCreditSalvageProportionally(double rate, double minimum, double expected)
{
var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20);
var job = new NestJob(Job(stock).Parts, new[] { stock }, new NestJobOptions(salvageRate: rate, minimumSalvageDimension: minimum));
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) });
Assert.Equal(expected, NestJobCost.NetSheetCost(job, sheet), 10);
Assert.Equal(expected, NestJobCost.NetSheetCost(job.Options, stock, new Box(0, 0, 4, 4)), 10);
Assert.Equal(expected * 5, NestJobCost.NetSheetArea(job, sheet), 10);
}
[Fact]
public void AggregateOverflowFailsClearly()
{
var stock = Stock("huge", double.MaxValue);
var job = new NestJob(new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 2) }, new[] { stock });
var builder = new NestJobResultBuilder(job);
builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) });
builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) });
Assert.Throws<OverflowException>(() => NestJobCost.Evaluate(job, builder.Build(NestJobStopReason.Completed)));
}
[Fact]
public void BuilderMapsLegacyZeroAndSnapshotsPositiveCosts()
{
var option = new PlateOption { Width = 10, Length = 10 };
Assert.Null(Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option })).Cost);
option.Cost = 12;
var stock = Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option }));
option.Cost = 50;
Assert.Equal(12, stock.Cost);
Assert.Throws<ArgumentException>(() => NestStockBuilder.FromTemplate(new Plate(10, 10),
new[] { option, new PlateOption { Width = 12, Length = 12 } }));
}
}
@@ -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,9 +98,24 @@ 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)) {
edges[host].Add(inner); try
{
if (InsideCutout(nodes, inner, host, token))
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)
@@ -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)
throw new NotSupportedException(material.Reason); {
if (!bestEffort)
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,34 +191,44 @@ 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));
while (stack.Count != 0) try
{ {
token.ThrowIfCancellationRequested(); while (stack.Count != 0)
var frame = stack.Peek();
var node = frame.Node;
if (node.Order.Length == snapshot.Placements.Count)
{ {
attempt.Order = node.Order; token.ThrowIfCancellationRequested();
return attempt; var frame = stack.Peek();
var node = frame.Node;
if (node.Order.Length == snapshot.Placements.Count)
{
attempt.Order = node.Order;
return attempt;
}
if (attempt.Advance(node))
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
return attempt;
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly).GetEnumerator();
if (!frame.Children.MoveNext())
{
stack.Pop().Children.Dispose();
continue;
}
stack.Push(new(frame.Children.Current.Node));
} }
if (attempt.Advance(node)) return attempt;
progressExpansions = Expansions; }
else if (stall is int limit && Expansions - progressExpansions > limit) finally
return attempt; {
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly); // Ready, stalled, cancelled and budget-exhausted searches can all leave
if (frame.Next == frame.Children.Length) // suspended siblings. Release their captured prefixes on every exit.
{ foreach (var frame in stack)
stack.Pop(); frame.Children?.Dispose();
continue;
}
stack.Push(new(frame.Children[frame.Next++].Node));
} }
return attempt;
} }
/// <summary> /// <summary>
@@ -259,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; }
} }
} }
+57
View File
@@ -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;
}
+39
View File
@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
public sealed record NestStockCost(string StockId, int Used, double? Cost, double GrossSubtotal);
/// <summary>Costs of validated physical sheets, excluding unplaced-demand penalties.</summary>
public sealed record NestCostSummary(string Basis, IReadOnlyList<NestStockCost> Stock,
double GrossTotal, double SalvageCredit, double NetScore)
{
public static NestCostSummary FromAccepted(NestPipelineResult result)
{
if (!result.CanKeep || !result.IsValid)
throw new ArgumentException("Cost reporting requires a validated proposal.", nameof(result));
var stock = result.Job.Plates.Select(s =>
{
var used = result.Plates.Count(p => p.Stock.Id == s.Id);
return new NestStockCost(s.Id, used, s.Cost,
used == 0 ? 0 : NestJobCost.RequireFinite(used * NestJobCost.GrossSheetCost(s)));
}).ToArray();
var gross = NestJobCost.RequireFinite(stock.Sum(s => s.GrossSubtotal));
var net = NestJobCost.RequireFinite(result.Plates.Sum(sheet =>
{
if (sheet.Parts.Count == 0 || result.Job.Options.SalvageRate == 0
|| result.Job.Options.MinimumSalvageDimension <= 0)
return NestJobCost.GrossSheetCost(sheet.Stock);
var bounds = sheet.Parts.Select(NestLayoutCheck.MaterialBounds).ToArray();
var left = bounds.Min(b => b.Left);
var bottom = bounds.Min(b => b.Bottom);
return NestJobCost.NetSheetCost(result.Job.Options, sheet.Stock,
new Box(left, bottom, bounds.Max(b => b.Right) - left, bounds.Max(b => b.Top) - bottom));
}));
return new NestCostSummary(NestJobCost.UsesExplicitCosts(result.Job)
? "supplied-cost" : "area", stock, gross, NestJobCost.RequireFinite(gross - net), net);
}
}
+1
View File
@@ -16,6 +16,7 @@ public sealed class NestJob
Parts = Own(parts); Parts = Own(parts);
Plates = Own(plates); Plates = Own(plates);
Options = options ?? new NestJobOptions(); Options = options ?? new NestJobOptions();
NestJobValidator.ValidateStockCosts(Plates);
if ( if (
Parts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Parts.Count Parts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Parts.Count
|| Plates.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Plates.Count || Plates.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Plates.Count
@@ -4,10 +4,13 @@ using System.Linq;
namespace OpenNest.Engine.Jobs; namespace OpenNest.Engine.Jobs;
/// <summary>Ranks independent plate trials: priority fulfillment, sheet area, placement envelope, then input order.</summary> /// <summary>Ranks independent plate trials: priority fulfillment, sheet cost, placement envelope, then input order.</summary>
public sealed class NestJobCandidateComparer public sealed class NestJobCandidateComparer
{ {
private readonly IReadOnlyList<NestJobPart> parts; private readonly IReadOnlyList<NestJobPart> parts;
private readonly NestJob job;
public NestJobCandidateComparer(NestJob job) : this(job.Parts) => this.job = job;
public NestJobCandidateComparer(IReadOnlyList<NestJobPart> parts) public NestJobCandidateComparer(IReadOnlyList<NestJobPart> parts)
{ {
@@ -36,7 +39,7 @@ public sealed class NestJobCandidateComparer
return leftCount.CompareTo(rightCount); return leftCount.CompareTo(rightCount);
} }
var area = Area(rightStock).CompareTo(Area(leftStock)); var area = Score(right, rightStock).CompareTo(Score(left, leftStock));
if (area != 0) if (area != 0)
return area; return area;
@@ -52,7 +55,10 @@ public sealed class NestJobCandidateComparer
parts.First(part => part.Id == placement.PartId).Priority == priority parts.First(part => part.Id == placement.PartId).Priority == priority
); );
private static double Area(NestPlateStock stock) => stock.Size.Width * stock.Size.Length; private double Score(PlateCandidate candidate, NestPlateStock stock) =>
job != null && stock.Cost.HasValue
? NestJobCost.NetSheetCost(job, new NestJobPlateResult(0, stock, candidate.Placements))
: NestJobCost.GrossSheetCost(stock);
private static double Envelope(PlateCandidate candidate) private static double Envelope(PlateCandidate candidate)
{ {
+26 -6
View File
@@ -1,10 +1,11 @@
using System;
using System.Linq; using System.Linq;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs; namespace OpenNest.Engine.Jobs;
/// <summary>Benchmark scoring primitives. Lower costs represent less sheet consumption.</summary> /// <summary>Generic per-sheet scoring; unspecified costs retain legacy area scores.</summary>
public static class NestJobCost public static class NestJobCost
{ {
/// <summary> /// <summary>
@@ -66,16 +67,35 @@ public static class NestJobCost
return area - options.SalvageRate * salvage; return area - options.SalvageRate * salvage;
} }
/// <summary>Largest offered sheet area, charged by the benchmark per unplaced part.</summary> public static bool UsesExplicitCosts(NestJob job) =>
job.Plates.Any(s => s.Quantity != 0 && s.Cost.HasValue);
public static double RequireFinite(double value) => double.IsFinite(value) ? value
: throw new OverflowException("Stock cost arithmetic exceeded the finite scoring range.");
public static double GrossSheetCost(NestPlateStock stock) => RequireFinite(stock.Cost ?? stock.Area);
public static double NetSheetCost(NestJob job, NestJobPlateResult sheet) =>
PriceArea(sheet.Stock, NetSheetArea(job, sheet));
public static double NetSheetCost(NestJobOptions options, NestPlateStock stock, Box envelope) =>
PriceArea(stock, NetSheetArea(options, stock, envelope));
private static double PriceArea(NestPlateStock stock, double area) =>
RequireFinite(stock.Cost is double cost ? cost * (area / stock.Area) : area);
/// <summary>Largest offered sheet score, charged per unplaced part.</summary>
public static double UnplacedPartPenalty(NestJob job) => public static double UnplacedPartPenalty(NestJob job) =>
job.Plates.Count == 0 ? 0 : job.Plates.Max(stock => stock.Size.Width * stock.Size.Length); job.Plates.Count == 0 ? 0 : UsesExplicitCosts(job)
? job.Plates.Where(s => s.Quantity != 0).Max(GrossSheetCost)
: job.Plates.Max(s => RequireFinite(s.Area));
/// <summary> /// <summary>
/// Sum of net sheet areas plus unplaced quantity times the largest offered sheet area. /// Sum of net sheet costs plus unplaced quantity times the largest offered sheet score.
/// Matches the benchmark for a valid run; this method does not validate the result. /// Matches the benchmark for a valid run; this method does not validate the result.
/// </summary> /// </summary>
public static double Evaluate(NestJob job, NestJobResult result) => public static double Evaluate(NestJob job, NestJobResult result) =>
result.Plates.Sum(sheet => NetSheetArea(job, sheet)) RequireFinite(result.Plates.Sum(sheet => NetSheetCost(job, sheet))
+ System.Math.Max(0, job.Parts.Sum(part => part.Quantity) + System.Math.Max(0, job.Parts.Sum(part => part.Quantity)
- result.Plates.Sum(sheet => sheet.Placements.Count)) * UnplacedPartPenalty(job); - result.Plates.Sum(sheet => sheet.Placements.Count)) * UnplacedPartPenalty(job));
} }
+1 -1
View File
@@ -38,7 +38,7 @@ public sealed class NestJobRunner : INestingEngine
var placed = job.Parts.ToDictionary(part => part.Id, _ => 0, StringComparer.Ordinal); var placed = job.Parts.ToDictionary(part => part.Id, _ => 0, StringComparer.Ordinal);
var parts = job.Parts.ToDictionary(part => part.Id, StringComparer.Ordinal); var parts = job.Parts.ToDictionary(part => part.Id, StringComparer.Ordinal);
var used = job.Plates.ToDictionary(stock => stock.Id, _ => 0, StringComparer.Ordinal); var used = job.Plates.ToDictionary(stock => stock.Id, _ => 0, StringComparer.Ordinal);
var comparer = new NestJobCandidateComparer(job.Parts); var comparer = new NestJobCandidateComparer(job);
var nester = var nester =
job.Parts.Count == 0 job.Parts.Count == 0
? null ? null
+11 -1
View File
@@ -10,11 +10,13 @@ public static class NestJobValidator
public static void Validate(NestJob job) public static void Validate(NestJob job)
{ {
ArgumentNullException.ThrowIfNull(job); ArgumentNullException.ThrowIfNull(job);
ValidateStockCosts(job.Plates);
foreach (var stock in job.Plates) foreach (var stock in job.Plates)
{ {
var edges = stock.EdgeSpacing; var edges = stock.EdgeSpacing;
if ( if (
!Positive(stock.Size.Width) !Positive(stock.Area)
|| !Positive(stock.Size.Width)
|| !Positive(stock.Size.Length) || !Positive(stock.Size.Length)
|| !Nonnegative(stock.PartSpacing) || !Nonnegative(stock.PartSpacing)
|| !Nonnegative(edges.Left) || !Nonnegative(edges.Left)
@@ -61,6 +63,14 @@ public static class NestJobValidator
} }
} }
public static void ValidateStockCosts(IEnumerable<NestPlateStock> stocks)
{
var available = stocks.Where(s => s.Quantity != 0).ToArray();
if (available.Any(s => s.Cost.HasValue) && available.Any(s => !s.Cost.HasValue))
throw new ArgumentException("Specify a positive cost for every available stock row or omit all costs. Missing: "
+ string.Join(", ", available.Where(s => !s.Cost.HasValue).Select(s => s.Id)));
}
internal static void ValidateCandidate( internal static void ValidateCandidate(
PlateCandidate candidate, PlateCandidate candidate,
NestPlateStock stock, NestPlateStock stock,
+2 -1
View File
@@ -231,7 +231,8 @@ public static class NestLayoutCheck
private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) => private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) =>
ReferenceEquals(expected, actual) ReferenceEquals(expected, actual)
|| ( || (
expected.Size.Equals(actual.Size) expected.Cost == actual.Cost
&& expected.Size.Equals(actual.Size)
&& expected.PartSpacing.IsEqualTo(actual.PartSpacing) && expected.PartSpacing.IsEqualTo(actual.PartSpacing)
&& expected.EdgeSpacing.Left.IsEqualTo(actual.EdgeSpacing.Left) && expected.EdgeSpacing.Left.IsEqualTo(actual.EdgeSpacing.Left)
&& expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right) && expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right)
+102 -4
View File
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Cutouts;
namespace OpenNest.Engine.Jobs; namespace OpenNest.Engine.Jobs;
@@ -36,7 +37,8 @@ public sealed class NestPipelineResult
IReadOnlyList<string> violations, IReadOnlyList<string> violations,
bool canKeep, bool canKeep,
TimeSpan solveTime, TimeSpan solveTime,
TimeSpan validationTime TimeSpan validationTime,
IReadOnlyDictionary<string, Drawing> drawingsByPartId
) )
{ {
EngineName = engineName; EngineName = engineName;
@@ -47,6 +49,7 @@ public sealed class NestPipelineResult
CanKeep = canKeep; CanKeep = canKeep;
SolveTime = solveTime; SolveTime = solveTime;
ValidationTime = validationTime; ValidationTime = validationTime;
DrawingsByPartId = drawingsByPartId;
} }
public string EngineName { get; } public string EngineName { get; }
@@ -62,6 +65,7 @@ public sealed class NestPipelineResult
public NestJobStopReason StopReason => Raw.StopReason; public NestJobStopReason StopReason => Raw.StopReason;
public TimeSpan SolveTime { get; } public TimeSpan SolveTime { get; }
public TimeSpan ValidationTime { get; } public TimeSpan ValidationTime { get; }
internal IReadOnlyDictionary<string, Drawing> DrawingsByPartId { get; }
} }
/// <summary> /// <summary>
@@ -72,6 +76,15 @@ public sealed class NestPipelineResult
/// </summary> /// </summary>
public static class NestPipeline public static class NestPipeline
{ {
private sealed class PreviewProgress(IProgress<NestJobProgress> inner) : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value)
{
if (value != null && value.Stage == NestJobStage.EvaluatingCandidate)
inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 });
}
}
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through /// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>. /// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
/// Engine cancellation propagates unchanged.</summary> /// Engine cancellation propagates unchanged.</summary>
@@ -93,6 +106,32 @@ public static class NestPipeline
NestPipelineRequest request, NestPipelineRequest request,
IProgress<NestJobProgress> progress = null, IProgress<NestJobProgress> progress = null,
CancellationToken token = default CancellationToken token = default
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false);
/// <summary>Internal test-only integration trial. Normal Run never prepares cutouts;
/// this entry point must not be called by production front ends before cutting/post gates.</summary>
internal static NestPipelineResult RunCutoutPreview(
INestingEngine engine,
string engineName,
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: true);
internal static NestPipelineResult RunCutoutPreview(
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
) => RunCutoutPreview(NestingEngineRegistry.Create(request.EngineName),
request.EngineName, request, progress, token);
private static NestPipelineResult RunCore(
INestingEngine engine,
string engineName,
NestPipelineRequest request,
IProgress<NestJobProgress> progress,
CancellationToken token,
bool cutoutPreview
) )
{ {
ArgumentNullException.ThrowIfNull(engine); ArgumentNullException.ThrowIfNull(engine);
@@ -115,10 +154,15 @@ public static class NestPipeline
var job = new NestJob(parts, request.Stock, request.Options); var job = new NestJob(parts, request.Stock, request.Options);
NestJobValidator.Validate(job); NestJobValidator.Validate(job);
var prepass = cutoutPreview ? CutoutPipelinePrepass.Prepare(job, token) : null;
var engineJob = prepass?.EngineJob ?? job;
if (prepass != null)
NestJobValidator.Validate(engineJob);
var clock = Stopwatch.StartNew(); var clock = Stopwatch.StartNew();
var raw = var raw =
engine.Solve(job, progress, token) engine.Solve(engineJob, prepass == null || progress == null
? progress : new PreviewProgress(progress), token)
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result."); ?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
var solveTime = clock.Elapsed; var solveTime = clock.Elapsed;
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -129,8 +173,36 @@ public static class NestPipeline
kv => kv.Value.Name ?? kv.Key, kv => kv.Value.Name ?? kv.Key,
StringComparer.Ordinal StringComparer.Ordinal
); );
var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep); var checkedNames = prepass == null ? names : engineJob.Parts.ToDictionary(p => p.Id,
p => names.GetValueOrDefault(p.Id, p.Id), StringComparer.Ordinal);
var violations = NestLayoutCheck.Violations(engineJob, raw, checkedNames, out var canKeep).ToList();
if (prepass != null)
{
// A compound may be physically invalid even when its envelope is legal.
// Refuse every unverified trial, including with explicit invalid-result consent.
var failure = "Cutout engine output failed reconciliation";
if (violations.Count == 0 && prepass.TryExpand(job, raw, token, out var expanded, out failure))
{
raw = expanded;
violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep));
if (raw.Status != NestJobStatus.Complete)
violations.Add("Cutout engine did not complete the transformed job; unplaced frames' reserved inserts remain unplaced in the original job");
canKeep = physicalKeep && violations.Count == 0;
}
else
{
if (violations.Count == 0)
violations.Add(failure);
canKeep = false;
}
}
var freshness = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId);
violations.AddRange(freshness);
if (freshness.Count > 0)
canKeep = false;
var validationTime = clock.Elapsed; var validationTime = clock.Elapsed;
if (canKeep && violations.Count == 0)
_ = NestJobCost.Evaluate(job, raw);
var plates = canKeep var plates = canKeep
? raw.Plates.Select(sheet => new ProposedPlate( ? raw.Plates.Select(sheet => new ProposedPlate(
@@ -141,6 +213,31 @@ public static class NestPipeline
.ToList() .ToList()
: new List<ProposedPlate>(); : new List<ProposedPlate>();
// Binding clones the caller's current Program; a concurrent edit during binding
// must not return parts whose bytes differ from the already validated snapshot.
if (canKeep)
{
var changes = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId).ToList();
changes.AddRange(NestPipelineDrawingFreshness.BoundChanges(job, raw, plates, drawingsByPartId));
if (changes.Count > 0)
{
violations.AddRange(changes);
plates.Clear();
canKeep = false;
}
}
token.ThrowIfCancellationRequested();
if (prepass != null && canKeep)
{
var count = 0;
for (var i = 0; i < raw.Plates.Count; i++)
{
count += raw.Plates[i].Placements.Count;
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
raw.Plates[i].StockId, i, i + 1, count));
}
}
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
return new NestPipelineResult( return new NestPipelineResult(
engineName, engineName,
@@ -150,7 +247,8 @@ public static class NestPipeline
violations, violations,
canKeep, canKeep,
solveTime, solveTime,
validationTime validationTime,
drawingsByPartId
); );
} }
} }
@@ -20,6 +20,11 @@ public static class NestPipelineCommit
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (!result.CanKeep || (!result.IsValid && !allowInvalid)) if (!result.CanKeep || (!result.IsValid && !allowInvalid))
throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations."); throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations.");
if (NestPipelineDrawingFreshness.Changes(result.Job, result.DrawingsByPartId).Count > 0
|| NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
result.Plates, result.DrawingsByPartId).Count > 0)
throw new InvalidOperationException("A drawing or proposed part changed after the nesting proposal was validated; run Auto Nest again.");
token.ThrowIfCancellationRequested();
var applied = new List<Plate>(); var applied = new List<Plate>();
// Commit is synchronous on the caller's owning thread. Cancellation is checked // Commit is synchronous on the caller's owning thread. Cancellation is checked
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
/// <summary>Refuse a proposal when a caller drawing no longer matches its checked snapshot.</summary>
internal static class NestPipelineDrawingFreshness
{
internal static IReadOnlyList<string> Changes(NestJob job,
IReadOnlyDictionary<string, Drawing> drawings)
{
var changed = new List<string>();
foreach (var requirement in job.Parts)
{
if (!drawings.TryGetValue(requirement.Id, out var drawing)
|| drawing?.Program == null)
{
changed.Add($"Drawing for '{requirement.Id}' changed after the nesting snapshot");
continue;
}
try
{
var current = PartGeometrySnapshot.FromProgram(drawing.Program);
if (Same(requirement.Geometry, current))
continue;
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
or InvalidOperationException)
{
// A now-unsupported program is not the geometry that was checked.
}
changed.Add($"Drawing '{drawing.Name ?? requirement.Id}' changed after the nesting snapshot");
}
return changed;
}
/// <summary>Check the actual cloned programs, not only the drawing before/after
/// binding. A transient edit during Clone may leave the drawing unchanged.</summary>
internal static IReadOnlyList<string> BoundChanges(NestJob job, NestJobResult raw,
IReadOnlyList<ProposedPlate> plates, IReadOnlyDictionary<string, Drawing> drawings)
{
var changed = new List<string>();
var requirements = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
if (plates.Count != raw.Plates.Count)
return new[] { "Bound plate count changed after validation" };
for (var sheetIndex = 0; sheetIndex < plates.Count; sheetIndex++)
{
var poses = raw.Plates[sheetIndex].Placements;
var bound = plates[sheetIndex].Parts;
if (bound.Count != poses.Count)
return new[] { "Bound part count changed after validation" };
for (var index = 0; index < poses.Count; index++)
{
var pose = poses[index];
var actual = bound[index];
if (pose.PartId == null || !requirements.TryGetValue(pose.PartId, out var requirement)
|| !drawings.TryGetValue(pose.PartId, out var drawing)
|| !ReferenceEquals(actual.BaseDrawing, drawing))
return new[] { "A bound part no longer matches its requirement" };
try
{
var expected = new Part(DrawingJobMapper.CreateDrawing(requirement));
expected.Rotate(pose.Rotation);
expected.Location = new Vector(pose.X, pose.Y);
if (Same(PartGeometrySnapshot.FromProgram(expected.Program),
PartGeometrySnapshot.FromProgram(actual.Program))
&& Bits(expected.Location.X) == Bits(actual.Location.X)
&& Bits(expected.Location.Y) == Bits(actual.Location.Y)
&& Bits(expected.Rotation) == Bits(actual.Rotation))
continue;
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
or InvalidOperationException)
{
// A changed/unsupported program cannot represent the checked pose.
}
changed.Add($"Bound part for '{drawing.Name ?? requirement.Id}' changed after validation");
}
}
return changed;
}
private static bool Same(PartGeometrySnapshot a, PartGeometrySnapshot b) =>
a.Mode == b.Mode && a.Motions.Count == b.Motions.Count
&& a.Motions.Zip(b.Motions).All(pair =>
pair.First.Type == pair.Second.Type
&& Bits(pair.First.X) == Bits(pair.Second.X)
&& Bits(pair.First.Y) == Bits(pair.Second.Y)
&& Bits(pair.First.CenterX) == Bits(pair.Second.CenterX)
&& Bits(pair.First.CenterY) == Bits(pair.Second.CenterY)
&& pair.First.Rotation == pair.Second.Rotation
&& pair.First.Layer == pair.Second.Layer
&& pair.First.Suppressed == pair.Second.Suppressed);
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
}
+9
View File
@@ -16,7 +16,14 @@ public sealed class NestPlateStock
Spacing edgeSpacing = default, Spacing edgeSpacing = default,
int quadrant = 1 int quadrant = 1
) )
: this(id, size, quantity, partSpacing, edgeSpacing, quadrant, null) { }
public NestPlateStock(string id, Size size, int? quantity, double partSpacing,
Spacing edgeSpacing, int quadrant, double? cost)
{ {
if (cost.HasValue && (!double.IsFinite(cost.Value) || cost.Value <= 0))
throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null.");
Cost = cost;
ArgumentException.ThrowIfNullOrWhiteSpace(id); ArgumentException.ThrowIfNullOrWhiteSpace(id);
if (quantity < 0) if (quantity < 0)
throw new ArgumentOutOfRangeException(nameof(quantity)); throw new ArgumentOutOfRangeException(nameof(quantity));
@@ -36,6 +43,8 @@ public sealed class NestPlateStock
); );
} }
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
public double? Cost { get; }
public string Id { get; } public string Id { get; }
public Size Size { get; } public Size Size { get; }
+3 -1
View File
@@ -29,9 +29,11 @@ public static class NestStockBuilder
quantityPerOption, quantityPerOption,
template.PartSpacing, template.PartSpacing,
template.EdgeSpacing, template.EdgeSpacing,
template.Quadrant template.Quadrant,
option.Cost == 0 ? null : option.Cost
)); ));
} }
NestJobValidator.ValidateStockCosts(stock);
return stock.AsReadOnly(); return stock.AsReadOnly();
} }
@@ -9,7 +9,7 @@ namespace OpenNest.Engine.Jobs;
/// <summary> /// <summary>
/// Caller-stock-only allocation followed by bounded adjacent-sheet repacking. All replacements /// Caller-stock-only allocation followed by bounded adjacent-sheet repacking. All replacements
/// must reproduce exactly the removed demand and reduce net sheet area; inventory is transactional. /// must reproduce exactly the removed demand and reduce net sheet cost; inventory is transactional.
/// This is a deterministic heuristic, not an optimality or geometric impossibility proof. /// This is a deterministic heuristic, not an optimality or geometric impossibility proof.
/// </summary> /// </summary>
public sealed class StockLadderNestingEngine : INestingEngine public sealed class StockLadderNestingEngine : INestingEngine
@@ -41,12 +41,12 @@ public sealed class StockLadderNestingEngine : INestingEngine
// Probe actual validated single-part placements, not bounding-box fit assertions. // Probe actual validated single-part placements, not bounding-box fit assertions.
foreach (var part in job.Parts) foreach (var part in job.Parts)
foreach (var stock in job.Plates.Where(s => s.Quantity != 0)) foreach (var stock in job.Plates.Where(s => s.Quantity != 0))
{ {
var probe = Trial(stock, new[] { WithQuantity(part, 1) }); var probe = Trial(stock, new[] { WithQuantity(part, 1) });
if (probe.Placements.Count != 0) if (probe.Placements.Count != 0)
feasible[part.Id].Add(stock.Id); feasible[part.Id].Add(stock.Id);
} }
var ordered = job var ordered = job
.Parts.OrderBy(p => p.Priority) .Parts.OrderBy(p => p.Priority)
.ThenBy(p => feasible[p.Id].Count) .ThenBy(p => feasible[p.Id].Count)
@@ -97,8 +97,8 @@ public sealed class StockLadderNestingEngine : INestingEngine
// Initial construction only: material area, never raw part counts. Repacking below // Initial construction only: material area, never raw part counts. Repacking below
// compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill. // compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill.
var value = var value =
NestJobCost.NetSheetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]); NestJobCost.RequireFinite(NestJobCost.NetSheetCost(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]));
if (value < score - 1e-9) if (value < score - (stock.Cost.HasValue ? 0 : 1e-9))
{ {
winner = sheet; winner = sheet;
score = value; score = value;
@@ -182,55 +182,55 @@ public sealed class StockLadderNestingEngine : INestingEngine
var changed = false; var changed = false;
// Single downgrade and adjacent pair merge only: bounded local search, no combinatorial tree. // Single downgrade and adjacent pair merge only: bounded local search, no combinatorial tree.
for (var index = 0; index < sheets.Count; index++) for (var index = 0; index < sheets.Count; index++)
for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--) for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--)
{
var old = sheets.Skip(index).Take(count).ToList();
var demand = old.SelectMany(s => s.Placements)
.GroupBy(p => p.PartId)
.ToDictionary(g => g.Key, g => g.Count());
var baseline = old.Sum(s => NestJobCost.NetSheetArea(job, s));
NestJobPlateResult replacement = null;
foreach (var stock in job.Plates)
{ {
token.ThrowIfCancellationRequested(); var old = sheets.Skip(index).Take(count).ToList();
var returned = old.Count(s => s.StockId == stock.Id); var demand = old.SelectMany(s => s.Placements)
if (stock.Quantity is int limit && used[stock.Id] - returned >= limit) .GroupBy(p => p.PartId)
continue;
// Even the maximum possible salvage credit cannot beat the incumbent.
var lowerBound =
stock.Size.Width * stock.Size.Length * (1 - job.Options.SalvageRate);
if (lowerBound >= baseline - 1e-9)
continue;
if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id)))
continue;
var candidate = Trial(
stock,
ordered
.Where(p => demand.ContainsKey(p.Id))
.Select(p => WithQuantity(p, demand[p.Id]))
);
var actual = candidate
.Placements.GroupBy(p => p.PartId)
.ToDictionary(g => g.Key, g => g.Count()); .ToDictionary(g => g.Key, g => g.Count());
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value)) var baseline = NestJobCost.RequireFinite(old.Sum(s => NestJobCost.NetSheetCost(job, s)));
NestJobPlateResult replacement = null;
foreach (var stock in job.Plates)
{
token.ThrowIfCancellationRequested();
var returned = old.Count(s => s.StockId == stock.Id);
if (stock.Quantity is int limit && used[stock.Id] - returned >= limit)
continue;
// Even the maximum possible salvage credit cannot beat the incumbent.
var lowerBound =
NestJobCost.GrossSheetCost(stock) * (1 - job.Options.SalvageRate);
if (lowerBound >= baseline - (stock.Cost.HasValue ? 0 : 1e-9))
continue;
if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id)))
continue;
var candidate = Trial(
stock,
ordered
.Where(p => demand.ContainsKey(p.Id))
.Select(p => WithQuantity(p, demand[p.Id]))
);
var actual = candidate
.Placements.GroupBy(p => p.PartId)
.ToDictionary(g => g.Key, g => g.Count());
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value))
continue;
var trial = new NestJobPlateResult(index, stock, candidate.Placements);
var cost = NestJobCost.NetSheetCost(job, trial);
if (cost >= baseline - (stock.Cost.HasValue ? 0 : 1e-9))
continue;
baseline = cost;
replacement = trial;
}
if (replacement == null)
continue; continue;
var trial = new NestJobPlateResult(index, stock, candidate.Placements); // No accounting changes until the entire equivalent-demand candidate is valid.
var cost = NestJobCost.NetSheetArea(job, trial); foreach (var sheet in old)
if (cost >= baseline - 1e-9) used[sheet.StockId]--;
continue; used[replacement.StockId]++;
baseline = cost; sheets.RemoveRange(index, count);
replacement = trial; sheets.Insert(index, replacement);
changed = true;
} }
if (replacement == null)
continue;
// No accounting changes until the entire equivalent-demand candidate is valid.
foreach (var sheet in old)
used[sheet.StockId]--;
used[replacement.StockId]++;
sheets.RemoveRange(index, count);
sheets.Insert(index, replacement);
changed = true;
}
return changed; return changed;
} }
} }
@@ -100,7 +100,7 @@ public sealed class DefaultNestingEngine : INestingEngine
} }
} }
private sealed record Scored(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order) private sealed record Scored(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order, bool Priced)
{ {
public static Scored Of(NestJob job, NestJobResult result, int order) public static Scored Of(NestJob job, NestJobResult result, int order)
{ {
@@ -109,7 +109,7 @@ public sealed class DefaultNestingEngine : INestingEngine
var unplaced = System.Math.Max(0, job.Parts.Sum(p => p.Quantity) - placed); var unplaced = System.Math.Max(0, job.Parts.Sum(p => p.Quantity) - placed);
// Cost needs every placement to name a known part, which only a valid result guarantees. // Cost needs every placement to name a known part, which only a valid result guarantees.
var cost = valid ? NestJobCost.Evaluate(job, result) : double.PositiveInfinity; var cost = valid ? NestJobCost.Evaluate(job, result) : double.PositiveInfinity;
return new Scored(result, valid, unplaced, cost, order); return new Scored(result, valid, unplaced, cost, order, NestJobCost.UsesExplicitCosts(job));
} }
public bool IsBetterThan(Scored other) public bool IsBetterThan(Scored other)
@@ -118,6 +118,8 @@ public sealed class DefaultNestingEngine : INestingEngine
return Valid; return Valid;
if (Unplaced != other.Unplaced) if (Unplaced != other.Unplaced)
return Unplaced < other.Unplaced; return Unplaced < other.Unplaced;
if (Priced && Cost != other.Cost)
return Cost < other.Cost;
var scale = System.Math.Max(1, System.Math.Max(System.Math.Abs(Cost), System.Math.Abs(other.Cost))); var scale = System.Math.Max(1, System.Math.Max(System.Math.Abs(Cost), System.Math.Abs(other.Cost)));
if (double.IsFinite(Cost) && double.IsFinite(other.Cost) && System.Math.Abs(Cost - other.Cost) > 1e-9 * scale) if (double.IsFinite(Cost) && double.IsFinite(other.Cost) && System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
return Cost < other.Cost; return Cost < other.Cost;
@@ -14,7 +14,7 @@ namespace OpenNest.Engine.NestingEngines.Irregular;
/// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places /// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places
/// either the largest part that fills a gap behind the packing front, or the part that advances /// either the largest part that fills a gap behind the packing front, or the part that advances
/// the front least per unit of area covered. Across sheets, every available stock size is /// the front least per unit of area covered. Across sheets, every available stock size is
/// trial-packed and the one with the lowest estimated whole-job cost (its own net area plus the /// trial-packed and the one with the lowest estimated whole-job cost (its own net cost plus the
/// remaining demand at the best efficiency seen) is committed. A handful of deterministic /// remaining demand at the best efficiency seen) is committed. A handful of deterministic
/// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins. /// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins.
/// ///
@@ -120,7 +120,7 @@ public sealed class IrregularNestingEngine : INestingEngine
var reason = run.Reason; var reason = run.Reason;
if (unplaced > 0 && reason == NestJobStopReason.Completed) if (unplaced > 0 && reason == NestJobStopReason.Completed)
reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold. reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold.
return new Plan(run.Sheets, run.Net + unplaced * Penalty, unplaced, reason); return new Plan(run.Sheets, NestJobCost.RequireFinite(run.Net + unplaced * Penalty), unplaced, reason, NestJobCost.UsesExplicitCosts(job));
} }
/// <summary> /// <summary>
@@ -138,7 +138,7 @@ public sealed class IrregularNestingEngine : INestingEngine
var prefix = sheets.Take(sheets.Count - k).ToList(); var prefix = sheets.Take(sheets.Count - k).ToList();
var tail = sheets.Skip(sheets.Count - k).ToList(); var tail = sheets.Skip(sheets.Count - k).ToList();
var tailParts = tail.Sum(s => s.Parts.Count); var tailParts = tail.Sum(s => s.Parts.Count);
var tailNet = tail.Sum(s => NetArea(job.Options, s)); var tailNet = tail.Sum(s => NetCost(job.Options, s));
var tailDemand = new int[types.Count]; var tailDemand = new int[types.Count];
foreach (var part in tail.SelectMany(s => s.Parts)) foreach (var part in tail.SelectMany(s => s.Parts))
tailDemand[part.Orientation.TypeIndex]++; tailDemand[part.Orientation.TypeIndex]++;
@@ -148,7 +148,7 @@ public sealed class IrregularNestingEngine : INestingEngine
int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null; int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null;
Run? bestRun = null; Run? bestRun = null;
var bestNet = tailNet - 1e-9 * System.Math.Max(1, tailNet); var bestNet = tailNet - (NestJobCost.UsesExplicitCosts(job) ? 0 : 1e-9 * System.Math.Max(1, tailNet));
foreach (var (axis, beta) in Variants) foreach (var (axis, beta) in Variants)
foreach (var first in job.Plates) foreach (var first in job.Plates)
{ {
@@ -245,7 +245,7 @@ public sealed class IrregularNestingEngine : INestingEngine
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock)); var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
var fill = packer.Fill(remaining, token); var fill = packer.Fill(remaining, token);
if (fill.Parts.Count > 0) if (fill.Parts.Count > 0)
trials.Add((fill, NetArea(job.Options, fill))); trials.Add((fill, NetCost(job.Options, fill)));
} }
if (trials.Count == 0) if (trials.Count == 0)
@@ -260,14 +260,14 @@ public sealed class IrregularNestingEngine : INestingEngine
var remainingArea = types.Sum(t => remaining[t.Index] * t.Area); var remainingArea = types.Sum(t => remaining[t.Index] * t.Area);
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12)); var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12));
var chosen = trials var chosen = trials
.Select((t, order) => (t.Fill, t.Net, order, Estimate: t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio)) .Select((t, order) => (t.Fill, t.Net, order, Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio)))
.OrderBy(t => t.Estimate) .OrderBy(t => t.Estimate)
.ThenByDescending(t => t.Fill.Parts.Count) .ThenByDescending(t => t.Fill.Parts.Count)
.ThenBy(t => t.order) .ThenBy(t => t.order)
.First(); .First();
sheets.Add(chosen.Fill); sheets.Add(chosen.Fill);
net += chosen.Net; net = NestJobCost.RequireFinite(net + chosen.Net);
used[chosen.Fill.Stock.Id]++; used[chosen.Fill.Stock.Id]++;
foreach (var part in chosen.Fill.Parts) foreach (var part in chosen.Fill.Parts)
remaining[part.Orientation.TypeIndex]--; remaining[part.Orientation.TypeIndex]--;
@@ -289,21 +289,23 @@ public sealed class IrregularNestingEngine : INestingEngine
return builder.Build(plan.Reason); return builder.Build(plan.Reason);
} }
private static double NetArea(NestJobOptions options, SheetFill fill) private static double NetCost(NestJobOptions options, SheetFill fill)
{ {
if (fill.Parts.Count == 0) return fill.Stock.Area; if (fill.Parts.Count == 0) return NestJobCost.GrossSheetCost(fill.Stock);
var left = fill.Parts.Min(p => p.Left); var left = fill.Parts.Min(p => p.Left);
var bottom = fill.Parts.Min(p => p.Bottom); var bottom = fill.Parts.Min(p => p.Bottom);
return NestJobCost.NetSheetArea(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom, return NestJobCost.NetSheetCost(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom,
fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom)); fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom));
} }
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason) private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason, bool Priced)
{ {
public bool IsBetterThan(Plan other) public bool IsBetterThan(Plan other)
{ {
if (Unplaced != other.Unplaced) if (Unplaced != other.Unplaced)
return Unplaced < other.Unplaced; return Unplaced < other.Unplaced;
if (Priced && Cost != other.Cost)
return Cost < other.Cost;
var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost)); var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost));
if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale) if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
return Cost < other.Cost; return Cost < other.Cost;
@@ -18,8 +18,8 @@ namespace OpenNest.Engine.NestingEngines.Rectangles;
/// ///
/// Sheet by sheet, each available stock is packed under several free-space scoring rules and /// Sheet by sheet, each available stock is packed under several free-space scoring rules and
/// two pick modes (best-fitting box anywhere, or largest type first). The candidate sheet with /// two pick modes (best-fitting box anywhere, or largest type first). The candidate sheet with
/// the lowest estimated whole-job cost wins: its salvage-credited net area (NestJobCost) plus the /// the lowest estimated whole-job cost wins: its salvage-credited net cost (NestJobCost) plus the
/// remaining demand priced at the best net-area-per-part-area ratio seen among the candidates. /// remaining demand priced at the best net-cost-per-part-area ratio seen among the candidates.
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token. /// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
/// </summary> /// </summary>
public sealed class RectanglesNestingEngine : INestingEngine public sealed class RectanglesNestingEngine : INestingEngine
@@ -78,7 +78,7 @@ public sealed class RectanglesNestingEngine : INestingEngine
{ {
var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token); var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token);
if (plan.Parts.Count > 0) if (plan.Parts.Count > 0)
trials.Add((plan, NetArea(job, plan))); trials.Add((plan, NetCost(job, plan)));
} }
} }
@@ -101,7 +101,7 @@ public sealed class RectanglesNestingEngine : INestingEngine
/// <summary> /// <summary>
/// Picks the sheet with the lowest estimated whole-job cost. Remaining demand is priced at the /// Picks the sheet with the lowest estimated whole-job cost. Remaining demand is priced at the
/// best net-area-per-material ratio any candidate achieved, so a sheet that finishes the job /// best net-cost-per-material ratio any candidate achieved, so a sheet that finishes the job
/// competes fairly with a denser partial one. Ties: more material placed, then enumeration order. /// competes fairly with a denser partial one. Ties: more material placed, then enumeration order.
/// </summary> /// </summary>
private static SheetPlan Choose( private static SheetPlan Choose(
@@ -111,7 +111,7 @@ public sealed class RectanglesNestingEngine : INestingEngine
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Plan.MaterialArea, 1e-12)); var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Plan.MaterialArea, 1e-12));
return trials return trials
.Select((t, order) => (t.Plan, order, .Select((t, order) => (t.Plan, order,
Estimate: t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio)) Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio)))
.OrderBy(t => PriorityDebt(types, remaining, t.Plan)) .OrderBy(t => PriorityDebt(types, remaining, t.Plan))
.ThenBy(t => t.Estimate) .ThenBy(t => t.Estimate)
.ThenByDescending(t => t.Plan.MaterialArea) .ThenByDescending(t => t.Plan.MaterialArea)
@@ -135,10 +135,10 @@ public sealed class RectanglesNestingEngine : INestingEngine
return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed; return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed;
} }
private static double NetArea(NestJob job, SheetPlan plan) => private static double NetCost(NestJob job, SheetPlan plan) =>
plan.Envelope is { } envelope plan.Envelope is { } envelope
? NestJobCost.NetSheetArea(job.Options, plan.Stock, envelope) ? NestJobCost.NetSheetCost(job.Options, plan.Stock, envelope)
: plan.Stock.Area; : NestJobCost.GrossSheetCost(plan.Stock);
private static bool FitsStock(NestPlateStock stock, BoxOrientation o) private static bool FitsStock(NestPlateStock stock, BoxOrientation o)
{ {
@@ -0,0 +1,105 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.Mcp;
using OpenNest.Mcp.Tools;
namespace OpenNest.FrontEnd.Tests;
[Collection("FrontEndRegistry")]
public class McpMixReportingTests
{
private static Drawing Rectangle(string name, double width, double length)
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(width, 0)));
shape.Entities.Add(new Line(new Vector(width, 0), new Vector(width, length)));
shape.Entities.Add(new Line(new Vector(width, length), new Vector(0, length)));
shape.Entities.Add(new Line(new Vector(0, length), new Vector(0, 0)));
return new Drawing(name, OpenNest.Converters.ConvertGeometry.ToProgram(shape));
}
private static NestSession Session()
{
var session = new NestSession();
session.Drawings.Add(Rectangle("A", 2, 2));
session.Drawings.Add(Rectangle("B", 20, 20));
session.Plates.Add(new Plate(10, 10));
return session;
}
[Fact]
public void PackReportsZeroForUnfitDrawingAndCountsOnlyNewParts()
{
var session = Session();
var plate = session.GetPlate(0);
plate.Parts.Add(new Part(session.Drawings[0]));
var text = new NestingTools(session).PackPlate(0, "A,B", "10,10");
Assert.Contains("A: requested=10", text);
Assert.Contains("B: requested=10, placed=0, remaining=10", text);
Assert.Contains("remaining demand", text);
Assert.DoesNotContain("ratio not met", text);
Assert.Contains($"A: requested=10, placed={plate.Parts.Count - 1}, remaining={11 - plate.Parts.Count}", text);
Assert.True(plate.Parts.Count > 1);
}
[Fact]
public void AutoNestReportsSkewedAndZeroProgressByRequirement()
{
var session = Session();
var engine = "MixStub-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "test", () => new Stub());
var tool = new NestingTools(session);
var text = tool.AutoNestPlate(0, "A,B", "2,2", engine);
Assert.Contains("A: requested=2, placed=1, remaining=1", text);
Assert.Contains("B: requested=2, placed=0, remaining=2", text);
Assert.Contains("remaining demand", text);
Assert.DoesNotContain("ratio not met", text);
// Duplicate tokens must be refused before the engine can mutate a fresh target.
var fresh = Session();
Assert.Contains("duplicate", new NestingTools(fresh).AutoNestPlate(0, "A,A", "2,2", engine), StringComparison.OrdinalIgnoreCase);
Assert.Empty(fresh.GetPlate(0).Parts);
Assert.Single(session.GetPlate(0).Parts);
}
[Fact]
public void PackZeroProgressAndAmbiguousNamesAreExplicit()
{
var session = Session();
session.GetPlate(0).Size = new Size(1, 1);
var text = new NestingTools(session).PackPlate(0, "A,B", "2,2");
Assert.Contains("A: requested=2, placed=0, remaining=2", text);
Assert.Contains("B: requested=2, placed=0, remaining=2", text);
Assert.Contains("zero progress", text);
Assert.Empty(session.GetPlate(0).Parts);
session.Drawings.Add(Rectangle("A", 1, 1));
Assert.Contains("ambiguous drawing name", new NestingTools(session).PackPlate(0, "A,B", "2,2"));
Assert.Empty(session.GetPlate(0).Parts);
}
[Fact]
public void ZeroDemandHasZeroRemainingAndNegativeDemandIsRejected()
{
var session = Session();
var tools = new NestingTools(session);
var text = tools.PackPlate(0, "A", "0");
Assert.Contains("A: requested=0, placed=0, remaining=0", text);
Assert.Contains("zero progress", text);
Assert.Contains("negative", tools.PackPlate(0, "A", "-1"), StringComparison.OrdinalIgnoreCase);
Assert.Empty(session.GetPlate(0).Parts);
}
private sealed class Stub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var placed = new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) };
return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, job.Plates[0], placed)],
[], []);
}
}
}
@@ -0,0 +1,112 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Mcp;
using OpenNest.Mcp.Tools;
namespace OpenNest.FrontEnd.Tests;
public class McpPlateToolsTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-mcp-plates-" + Guid.NewGuid());
public McpPlateToolsTests() => Directory.CreateDirectory(directory);
public void Dispose() => Directory.Delete(directory, true);
[Fact]
public void NegativeIndexOnLoadedNestIsNotAPlate()
{
var session = new NestSession { Nest = new Nest() };
var loaded = new Plate(10, 20);
session.Nest.Plates.Add(loaded);
var appended = new Plate(30, 40);
session.Plates.Add(appended);
Assert.Null(session.GetPlate(-1));
Assert.Equal("Error: plate -1 not found", new SetupTools(session).ClearPlate(-1));
Assert.Same(loaded, session.GetPlate(0));
Assert.Same(appended, session.GetPlate(1));
Assert.Null(session.GetPlate(2));
Assert.Single(session.Nest.Plates);
Assert.Single(session.Plates);
var sessionOnly = new NestSession();
sessionOnly.Plates.Add(appended);
Assert.Null(sessionOnly.GetPlate(-1));
Assert.Same(appended, sessionOnly.GetPlate(0));
Assert.Null(sessionOnly.GetPlate(1));
}
private static string Delete(SetupTools tools, int index) => tools.DeletePlate(index);
[Fact]
public void DeleteEmptyLoadedMiddlePlateRenumbersAppendedAndPersists()
{
var session = new NestSession { Nest = new Nest("fixture") };
var drawing = new Drawing("square", new Program());
session.Nest.Drawings.Add(drawing);
var occupied = new Plate(20, 30) { Quantity = 2 };
occupied.Parts.Add(new Part(drawing));
var unused = new Plate(40, 50);
var appended = new Plate(60, 70) { PartSpacing = 1.25, Quantity = 3 };
session.Nest.Plates.Add(occupied);
session.Nest.Plates.Add(unused);
session.Plates.Add(appended);
var tools = new SetupTools(session);
var result = Delete(tools, 1);
Assert.Contains("Deleted plate 1", result);
Assert.Contains("2 -> 1", result);
Assert.Single(session.Nest.Plates);
Assert.Same(appended, session.GetPlate(1));
Assert.Null(session.GetPlate(2));
Assert.Contains("60.0 x 70.0", new InspectionTools(session).GetPlateInfo(1));
Assert.Equal(2, drawing.Quantity.Nested);
var path = Path.Combine(directory, "cleaned.nest");
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
var reloaded = new NestReader(path).Read();
// NestWriter already omits empty plates; the deletion is session cleanup,
// and the saved file must still retain the occupied plate and its counts.
var saved = Assert.Single(reloaded.Plates);
Assert.Single(saved.Parts);
Assert.Equal(20, saved.Size.Width);
Assert.Equal(2, saved.Quantity);
Assert.Equal(2, reloaded.Drawings.Single().Quantity.Nested);
}
[Fact]
public void DeleteRefusesOccupiedCutoffAndInvalidIndicesWithoutMutation()
{
var session = new NestSession { Nest = new Nest("fixture") };
var drawing = new Drawing("square", new Program());
session.Nest.Drawings.Add(drawing);
var occupied = new Plate(20, 30) { Quantity = 4 };
var part = new Part(drawing);
occupied.Parts.Add(part);
var cutoff = new Plate(40, 50);
var definition = new CutOff(new Vector(10, 10), CutOffAxis.Vertical);
cutoff.CutOffs.Add(definition);
session.Nest.Plates.Add(occupied);
session.Plates.Add(cutoff);
var tools = new SetupTools(session);
Assert.Contains("occupied", Delete(tools, 0));
Assert.Contains("cutoff", Delete(tools, 1), StringComparison.OrdinalIgnoreCase);
Assert.Contains("not found", Delete(tools, -1));
Assert.Contains("not found", Delete(tools, 2));
Assert.Same(occupied, session.GetPlate(0));
Assert.Same(cutoff, session.GetPlate(1));
Assert.Same(part, Assert.Single(occupied.Parts));
Assert.Same(definition, Assert.Single(cutoff.CutOffs));
Assert.Equal(4, drawing.Quantity.Nested);
Assert.Single(session.Nest.Plates);
Assert.Single(session.Plates);
// The session-only collection must also delete without requiring a loaded nest.
var standalone = new NestSession();
standalone.Plates.Add(new Plate(12, 13));
Assert.Contains("Deleted plate 0", Delete(new SetupTools(standalone), 0));
Assert.Empty(standalone.Plates);
}
}
+506
View File
@@ -0,0 +1,506 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Mcp;
using OpenNest.Mcp.Tools;
namespace OpenNest.FrontEnd.Tests;
[Collection("FrontEndRegistry")]
public class McpWholeJobTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-mcp-job-" + Guid.NewGuid());
public McpWholeJobTests() => Directory.CreateDirectory(directory);
public void Dispose() => Directory.Delete(directory, true);
private static Drawing Square(string name, double size = 2)
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(size, 0)));
shape.Entities.Add(new Line(new Vector(size, 0), new Vector(size, size)));
shape.Entities.Add(new Line(new Vector(size, size), new Vector(0, size)));
shape.Entities.Add(new Line(new Vector(0, size), new Vector(0, 0)));
return new Drawing(name, OpenNest.Converters.ConvertGeometry.ToProgram(shape));
}
private static NestSession Session()
{
var session = new NestSession { Nest = new Nest("job") };
session.Nest.Drawings.Add(Square("A"));
session.Nest.Drawings.Add(Square("B"));
session.Nest.Plates.Add(new Plate(10, 10) { PartSpacing = 0.25, Quantity = 1 });
return session;
}
private static string Call(NestingTools tools, int plateIndex, string names, string quantities,
string engine, bool noNew = false, CancellationToken token = default) =>
tools.AutoNestJob(plateIndex, names, quantities, engine, noNew, token);
[Fact]
public void AcceptedPricesAndRequirementsSurviveSaveWithoutLosingMetadata()
{
var session = Session();
session.Nest.Customer = "Cost customer";
session.Nest.Notes = "Keep these notes";
session.Nest.Units = Units.Inches;
session.Nest.SalvageRate = 0.25;
var output = new NestingTools(session).AutoNestJob(0, "A,B", "1,1", "Rectangles", sheets:
[new SheetStockInput { Width = 10, Length = 10, Quantity = 2, Cost = 50 },
new SheetStockInput { Width = 12, Length = 12, Quantity = 2, Cost = 7 }]);
Assert.Contains("Whole job complete", output);
Assert.Contains("gross total=7", output);
Assert.Contains("net score=7", output);
Assert.Contains("supplied-cost", output);
Assert.All(session.AllDrawings(), d => Assert.Equal(1, d.Quantity.Required));
var path = Path.Combine(directory, "priced.nest");
new InputTools(session).SaveNest(path);
var reopened = new NestReader(path).Read();
Assert.Equal("Cost customer", reopened.Customer);
Assert.Equal("Keep these notes", reopened.Notes);
Assert.Equal(Units.Inches, reopened.Units);
Assert.Equal(0.25, reopened.SalvageRate);
Assert.Equal(new[] { 50d, 7d }, reopened.PlateOptions.Select(o => o.Cost));
Assert.All(reopened.Drawings, d => Assert.Equal(1, d.Quantity.Required));
Assert.Equal(2, reopened.Plates.Sum(p => p.Parts.Count));
}
[Theory]
[InlineData(null)]
[InlineData(0d)]
[InlineData(-1d)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void InvalidCostsDoNotMutateSession(double? cost)
{
var session = Session();
var quantities = session.AllDrawings().Select(d => d.Quantity.Required).ToArray();
var output = new NestingTools(session).AutoNestJob(0, "A,B", "1,1", "Rectangles", sheets:
[new SheetStockInput { Width = 10, Length = 10, Quantity = 1, Cost = 5 },
new SheetStockInput { Width = 12, Length = 12, Quantity = 1, Cost = cost }]);
Assert.Contains("Error:", output);
Assert.Contains("sheets[1]", output);
Assert.Empty(session.GetPlate(0).Parts);
Assert.Empty(session.Nest.PlateOptions);
Assert.Equal(quantities, session.AllDrawings().Select(d => d.Quantity.Required));
}
[Fact]
public void FiniteSheetInputsReachEngineWithTemplateSettings()
{
var session = Session();
var template = session.GetPlate(0);
template.Size = new Size(60, 120);
template.EdgeSpacing = new Spacing(1, 2, 3, 4);
template.Quadrant = 3;
var engine = "StockMap-" + Guid.NewGuid();
var called = false;
NestingEngineRegistry.Register(engine, "inspect finite stock", () => new InspectStub(job =>
{
called = true;
Assert.Equal(2, job.Plates.Count);
Assert.Equal(["stock-0", "stock-1"], job.Plates.Select(s => s.Id));
Assert.Equal([new Size(60, 120), new Size(48, 96)], job.Plates.Select(s => s.Size));
Assert.Equal([1, 2], job.Plates.Select(s => s.Quantity));
Assert.All(job.Plates, stock =>
{
Assert.Equal(0.25, stock.PartSpacing);
Assert.Equal(template.EdgeSpacing, stock.EdgeSpacing);
Assert.Equal(3, stock.Quadrant);
});
Assert.Equal(2, job.Options.MaxPlates); // capped by demand, not plate.Quantity
return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.StockExhausted, [], [], []);
}));
var rows = new[] { new SheetStockInput { Width = 60, Length = 120, Quantity = 1 },
new SheetStockInput { Width = 48, Length = 96, Quantity = 2 } };
var tool = new NestingTools(session);
Assert.Contains("incomplete", tool.AutoNestJob(0, "A,B", "1,1", engine, sheets: rows));
Assert.True(called);
called = false;
rows[1].Width = double.NaN;
Assert.Contains("sheets[1]", tool.AutoNestJob(0, "A,B", "1,1", engine, sheets: rows));
Assert.False(called);
Assert.Single(session.AllPlates());
Assert.Empty(template.Parts);
Assert.Equal(1, template.Quantity); // no inventory drawn from the template quantity
}
private sealed class InspectStub(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => solve(job);
}
[Fact]
public void FiniteMixedSheetsCommitByStockIdAndRoundTrip()
{
var session = Session();
var template = session.GetPlate(0);
template.Size = new Size(6, 6);
template.Quantity = 9; // repeat count is not inventory
var small = session.Nest.Drawings.Single(d => d.Name == "B");
var large = Square("large", 4); // four-by-four cannot fit a three-by-three sheet
session.Nest.Drawings.Add(large);
var rows = new[] { new SheetStockInput { Width = 6, Length = 6, Quantity = 1 },
new SheetStockInput { Width = 3, Length = 3, Quantity = 2 } };
var engine = "MixedStock-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "two different sheets", () => new InspectStub(job =>
{
Assert.Equal(2, job.Options.MaxPlates);
Assert.Equal([1, 2], job.Plates.Select(s => s.Quantity));
// Small stock first: result ordinal must not resize/reuse the large template.
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, job.Plates[1], [new NestJobPlacement(job.Parts[1].Id, 0, 0.5, 0.5, 0)]),
new NestJobPlateResult(1, job.Plates[0], [new NestJobPlacement(job.Parts[0].Id, 1, 0.5, 0.5, 0)])], [], []);
}));
var text = new NestingTools(session).AutoNestJob(0, "large,B", "1,1", engine, sheets: rows);
Assert.Contains("Whole job complete", text);
Assert.Contains("stock-0 (6x6): used=1, remaining=0, reused=1, created=0", text);
Assert.Contains("stock-1 (3x3): used=1, remaining=1, reused=0, created=1", text);
Assert.Contains("plate 0 -> stock-0", text);
Assert.Contains("plate 1 -> stock-1", text);
Assert.Same(template, session.GetPlate(0));
Assert.Equal(new Size(6, 6), template.Size);
Assert.Same(large, Assert.Single(template.Parts).BaseDrawing);
Assert.Same(small, Assert.Single(session.GetPlate(1).Parts).BaseDrawing);
Assert.Equal(1, template.Quantity);
var path = Path.Combine(directory, "mixed.nest");
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
var reloaded = new NestReader(path).Read();
Assert.Equal([new Size(6, 6), new Size(3, 3)], reloaded.Plates.Select(p => p.Size));
Assert.All(reloaded.Plates, p => Assert.Equal(1, p.Quantity));
Assert.Equal(1, reloaded.Drawings.Single(d => d.Name == "large").Quantity.Nested);
Assert.Equal(1, reloaded.Drawings.Single(d => d.Name == "B").Quantity.Nested);
}
[Fact]
public void FiniteStockOverdrawIsRejectedBeforeAnySessionMutation()
{
var session = Session();
var plate = session.GetPlate(0);
plate.Size = new Size(3, 3);
var engine = "Overdraw-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "overdraw stock", () => new InspectStub(job =>
new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, job.Plates[0], [new NestJobPlacement(job.Parts[0].Id, 0, 0.5, 0.5, 0)]),
new NestJobPlateResult(1, job.Plates[0], [new NestJobPlacement(job.Parts[1].Id, 1, 0.5, 0.5, 0)])], [], [])));
var text = new NestingTools(session).AutoNestJob(0, "A,B", "1,1", engine,
sheets: [new SheetStockInput { Width = 3, Length = 3, Quantity = 1 },
new SheetStockInput { Width = 6, Length = 6, Quantity = 2 }]);
Assert.Contains("stock-0", text);
Assert.Contains("invalid", text, StringComparison.OrdinalIgnoreCase);
Assert.Single(session.AllPlates());
Assert.Empty(plate.Parts);
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
}
[Fact]
public void FiniteStockRejectsInvalidInventoryBeforeSolving()
{
var session = Session();
var plate = session.GetPlate(0);
var engine = "NeverSolve-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "should not be called", () => new InspectStub(_ =>
throw new Xunit.Sdk.XunitException("invalid input reached solver")));
var tools = new NestingTools(session);
var valid = new SheetStockInput { Width = 10, Length = 10, Quantity = 1 };
Assert.Contains("at least one", tools.AutoNestJob(0, "A", "1", engine, sheets: []));
Assert.Contains("sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
sheets: [new SheetStockInput { Width = 10, Length = 10, Quantity = 0 }]));
Assert.Contains("sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
sheets: [new SheetStockInput { Width = double.PositiveInfinity, Length = 10, Quantity = 1 }]));
Assert.Contains("sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
sheets: [new SheetStockInput { Width = 0, Length = 10, Quantity = 1 }]));
Assert.Contains("sheets[1] duplicates sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
sheets: [valid, new SheetStockInput { Width = 10, Length = 10, Quantity = 2 }]));
Assert.Contains("sheet inventory exceeds", tools.AutoNestJob(0, "A", "1", engine,
sheets: [new SheetStockInput { Width = 10, Length = 10, Quantity = int.MaxValue },
new SheetStockInput { Width = 11, Length = 11, Quantity = 1 }]));
Assert.Contains("cannot be combined", tools.AutoNestJob(0, "A", "1", engine,
no_new_plates: true, sheets: [valid]));
Assert.Single(session.AllPlates());
Assert.Empty(plate.Parts);
Assert.Equal(1, plate.Quantity);
}
[Fact]
public void FiniteInventoryUsesOnlyOneOfTwoMatchingEmptySheets()
{
var session = Session();
var original = session.GetPlate(0);
session.Plates.Add(new Plate(10, 10) { PartSpacing = 0.25, Quantity = 5 });
var text = new NestingTools(session).AutoNestJob(0, "A", "1", "Rectangles",
sheets: [new SheetStockInput { Width = 10, Length = 10, Quantity = 1 }]);
Assert.Contains("Whole job complete", text);
Assert.Contains("stock-0 (10x10): used=1, remaining=0", text);
Assert.Single(original.Parts);
Assert.Empty(session.GetPlate(1).Parts);
Assert.Equal(5, session.GetPlate(1).Quantity);
}
[Fact]
public void LaterSelectedTemplateIsReusedBeforeOtherCompatibleEmptySheet()
{
var session = Session();
var earlier = session.GetPlate(0);
var selected = new Plate(10, 10) { PartSpacing = 0.25, Quantity = 3 };
session.Plates.Add(selected);
var text = new NestingTools(session).AutoNestJob(1, "A", "1", "Rectangles");
Assert.Contains("Whole job complete", text);
Assert.Empty(earlier.Parts);
Assert.Single(selected.Parts);
Assert.Equal(1, selected.Quantity);
}
[Fact]
public void CreatesSheetsForCompleteMixedJobThenSavesAndReloads()
{
var session = Session();
var original = session.GetPlate(0);
var engine = "JobTwoSheets-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "two safe sheets", () => new TwoSheetStub());
var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine);
Assert.Contains("complete", text, StringComparison.OrdinalIgnoreCase);
Assert.Equal(2, session.AllPlates().Count);
Assert.Same(original, session.GetPlate(0));
Assert.Single(session.Plates);
Assert.All(session.AllPlates(), plate =>
{
Assert.Equal(2, plate.Parts.Count);
Assert.Equal(0.25, plate.PartSpacing);
Assert.Equal(1, plate.Quantity);
});
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
var path = Path.Combine(directory, "complete.nest");
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
var loaded = new NestReader(path).Read();
Assert.Equal(2, loaded.Plates.Count);
Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "B").Quantity.Nested);
}
[Fact]
public void NoNewPlateFlagRefusesIncompleteProposalWithoutMutation()
{
var session = Session();
var original = session.GetPlate(0);
var engine = "JobLimited-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "one sheet when capped", () => new TwoSheetStub());
var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine, noNew: true);
Assert.Contains("incomplete", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Nothing committed", text);
Assert.Single(session.AllPlates());
Assert.Same(original, session.GetPlate(0));
Assert.Empty(original.Parts);
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
}
[Fact]
public void ExistingQuantityAndPrecreatedSheetsCompleteWithRealEngine()
{
var session = Session();
var occupied = session.GetPlate(0);
occupied.Quantity = 2;
occupied.Parts.Add(new Part(session.Nest.Drawings.Single(d => d.Name == "A")));
var first = new Plate(3, 3) { PartSpacing = 0.25, Quantity = 1 };
var second = new Plate(3, 3) { PartSpacing = 0.25, Quantity = 1 };
session.Plates.Add(first);
session.Plates.Add(second);
var text = Call(new NestingTools(session), 1, "A,B", "3,1", "Rectangles", noNew: true);
Assert.Contains("Whole job complete", text);
Assert.Contains("0 new sheet(s)", text);
Assert.Same(first, session.GetPlate(1));
Assert.Same(second, session.GetPlate(2));
Assert.Equal(3, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(1, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
Assert.Equal(3, session.AllPlates().Count);
var path = Path.Combine(directory, "existing.nest");
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
var loaded = new NestReader(path).Read();
Assert.Equal(3, loaded.Plates.Count);
Assert.Equal(3, loaded.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(1, loaded.Drawings.Single(d => d.Name == "B").Quantity.Nested);
}
[Fact]
public void RealEngineAddsSheetsWhenOnlyTemplateExists()
{
var session = Session();
session.GetPlate(0).Size = new Size(3, 3);
var text = Call(new NestingTools(session), 0, "A,B", "2,2", "Rectangles");
Assert.Contains("Whole job complete", text);
Assert.Equal(4, session.AllPlates().Count);
Assert.All(session.AllPlates(), p => Assert.Single(p.Parts));
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
}
[Fact]
public void InvalidPluginLayoutCannotCreateOrOccupySheets()
{
var session = Session();
var original = session.GetPlate(0);
var engine = "JobOverlap-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "invalid layout", () => new TwoSheetStub(overlap: true));
var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine);
Assert.Contains("invalid", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Nothing committed", text);
Assert.Single(session.AllPlates());
Assert.Same(original, session.GetPlate(0));
Assert.Empty(original.Parts);
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
}
[Fact]
public void RestrictedDrawingRejectsPluginRotationWithoutMutationAfterReload()
{
var session = Session();
var drawing = session.Nest.Drawings.Single(d => d.Name == "A");
drawing.Constraints = new NestConstraints
{
StepAngle = OpenNest.Math.Angle.TwoPI,
StartAngle = 0,
EndAngle = 0,
};
session.GetPlate(0).Parts.Add(new Part(session.Nest.Drawings.Single(d => d.Name == "B")));
var path = Path.Combine(directory, "rotation.nest");
Assert.True(new NestWriter(session.Nest).Write(path));
session = new NestSession { Nest = new NestReader(path).Read() };
Assert.Equal(OpenNest.Math.Angle.TwoPI, session.Nest.Drawings.Single(d => d.Name == "A").Constraints.StepAngle);
session.Plates.Add(new Plate(10, 10) { PartSpacing = 0.25, Quantity = 1 });
var engine = "JobForbiddenRotation-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "rotated proposal", () => new RotatedStub());
var plateTool = new NestingTools(session).AutoNestPlate(1, "A", "1", engine);
Assert.Contains("Violation", plateTool);
Assert.Contains("rotation", plateTool, StringComparison.OrdinalIgnoreCase);
Assert.Empty(session.GetPlate(1).Parts);
var text = Call(new NestingTools(session), 1, "A", "1", engine);
Assert.True(text.Contains("rotation", StringComparison.OrdinalIgnoreCase), text);
Assert.Contains("Nothing committed", text);
Assert.Equal(2, session.AllPlates().Count);
Assert.Empty(session.GetPlate(1).Parts);
Assert.Single(session.GetPlate(0).Parts);
Assert.Equal(0, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
}
[Fact]
public void EquivalentDeserializedCuttingSettingsReuseExistingSheets()
{
var session = Session();
var first = session.GetPlate(0);
first.Size = new Size(3, 3);
first.CuttingParameters = new CuttingParameters { MachineName = "Same" };
var second = new Plate(3, 3)
{
Quantity = 1,
PartSpacing = 0.25,
CuttingParameters = new CuttingParameters { MachineName = "Same" }
};
session.Plates.Add(second);
Assert.NotSame(first.CuttingParameters, second.CuttingParameters);
var text = Call(new NestingTools(session), 0, "A", "2", "Rectangles", noNew: true);
Assert.Contains("Whole job complete", text);
Assert.Equal(2, session.AllPlates().Count);
Assert.Single(first.Parts);
Assert.Single(second.Parts);
}
[Fact]
public void DifferentCuttingSettingsAreNotReusedAsStock()
{
var session = Session();
var first = session.GetPlate(0);
first.Size = new Size(3, 3);
first.CuttingParameters = new CuttingParameters { MachineName = "First" };
var second = new Plate(3, 3)
{
Quantity = 1,
PartSpacing = 0.25,
CuttingParameters = new CuttingParameters { MachineName = "Other" }
};
session.Plates.Add(second);
var text = Call(new NestingTools(session), 0, "A", "2", "Rectangles", noNew: true);
Assert.Contains("incomplete", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Nothing committed", text);
Assert.Empty(first.Parts);
Assert.Empty(second.Parts);
Assert.Equal(2, session.AllPlates().Count);
}
[Fact]
public void SolverNotSupportedErrorNamesActualCause()
{
var session = Session();
var engine = "JobUnsupported-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "unsupported solver", () => new UnsupportedStub());
var text = Call(new NestingTools(session), 0, "A", "1", engine);
Assert.Contains("unsupported fixture", text);
Assert.DoesNotContain("Use autonest_plate", text);
Assert.Contains("Nothing committed", text);
Assert.Empty(session.GetPlate(0).Parts);
}
[Fact]
public void CancellationAfterPluginIgnoresTokenDoesNotCommit()
{
var session = Session();
var required = session.AllDrawings().Select(d => d.Quantity.Required).ToArray();
using var cts = new CancellationTokenSource();
var engine = "JobCancel-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "cancels after solving", () => new CancelStub(cts));
Assert.ThrowsAny<OperationCanceledException>(() =>
Call(new NestingTools(session), 0, "A,B", "2,2", engine, token: cts.Token));
Assert.Single(session.AllPlates());
Assert.Empty(session.GetPlate(0).Parts);
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
Assert.Equal(required, session.AllDrawings().Select(d => d.Quantity.Required));
}
private sealed class CancelStub(CancellationTokenSource source) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var result = new TwoSheetStub().Solve(job, progress, token);
source.Cancel();
return result;
}
}
private sealed class RotatedStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var stock = Assert.Single(job.Plates);
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, stock,
[new NestJobPlacement(job.Parts[0].Id, 0, 4, 4, System.Math.PI / 2)])], [], []);
}
}
private sealed class UnsupportedStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => throw new NotSupportedException("unsupported fixture");
}
private sealed class TwoSheetStub(bool overlap = false) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var stock = Assert.Single(job.Plates);
var sheets = new List<NestJobPlateResult>();
for (var i = 0; i < System.Math.Min(2, stock.Quantity ?? 2); i++)
sheets.Add(new NestJobPlateResult(i, stock,
[new NestJobPlacement(job.Parts[0].Id, i, 1, 1, 0),
new NestJobPlacement(job.Parts[1].Id, i, overlap ? 1 : 5, 1, 0)]));
return new NestJobResult(sheets.Count == 2 ? NestJobStatus.Complete : NestJobStatus.Incomplete,
sheets.Count == 2 ? NestJobStopReason.Completed : NestJobStopReason.StockExhausted,
sheets, [], []);
}
}
}
@@ -0,0 +1,32 @@
using OpenNest.Engine.Jobs;
using OpenNest.IO;
namespace OpenNest.IO.Tests;
public class StockCostPersistenceTests
{
[Theory]
[InlineData(0)]
[InlineData(12.5)]
public void ExistingOptionsRoundTripWithoutChangingMetadata(double cost)
{
var nest = new Nest("priced options")
{
Customer = "customer",
Notes = "notes",
Units = Units.Inches,
SalvageRate = 0.25,
PlateOptions = [new PlateOption { Width = 48, Length = 96, Cost = cost }],
};
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
var reopened = new NestReader(stream).Read();
Assert.Equal(cost, Assert.Single(reopened.PlateOptions).Cost);
Assert.Equal(nest.Customer, reopened.Customer);
Assert.Equal(nest.Notes, reopened.Notes);
Assert.Equal(nest.SalvageRate, reopened.SalvageRate);
var stock = Assert.Single(NestStockBuilder.FromTemplate(new Plate(48, 96), reopened.PlateOptions));
Assert.Equal(cost == 0 ? (double?)null : cost, stock.Cost);
}
}
+35
View File
@@ -17,6 +17,9 @@ namespace OpenNest.Mcp
public Plate GetPlate(int index) public Plate GetPlate(int index)
{ {
if (index < 0)
return null;
if (Nest != null && index < Nest.Plates.Count) if (Nest != null && index < Nest.Plates.Count)
return Nest.Plates[index]; return Nest.Plates[index];
@@ -27,6 +30,38 @@ namespace OpenNest.Mcp
return null; return null;
} }
/// <summary>Remove only a genuinely unused sheet; loaded sheets precede appended sheets.</summary>
public bool TryDeleteEmptyPlate(int index, out string reason)
{
var plate = GetPlate(index);
if (plate == null)
{
reason = $"plate {index} not found";
return false;
}
if (plate.Parts.Count != 0)
{
reason = $"plate {index} is occupied";
return false;
}
if (plate.CutOffs.Count != 0)
{
reason = $"plate {index} has cutoff definitions";
return false;
}
var loadedCount = Nest?.Plates.Count ?? 0;
if (index < loadedCount)
Nest.Plates.RemoveAt(index);
else
Plates.RemoveAt(index - loadedCount);
reason = null;
return true;
}
public Drawing GetDrawing(string name) public Drawing GetDrawing(string name)
{ {
if (Nest != null) if (Nest != null)
+12
View File
@@ -0,0 +1,12 @@
namespace OpenNest.Mcp;
/// <summary>Request-local physical inventory for one whole-job solve.</summary>
public sealed class SheetStockInput
{
/// <summary>Positive per-sheet cost in common units; omit on every row for area scoring.</summary>
public double? Cost { get; set; }
public double Width { get; set; }
public double Length { get; set; }
public int Quantity { get; set; }
}
+18 -2
View File
@@ -69,8 +69,24 @@ namespace OpenNest.Mcp.Tools
[Description("Name for the nest (optional)")] string name = null [Description("Name for the nest (optional)")] string name = null
) )
{ {
var nest = new Nest(); var source = _session.Nest;
nest.Name = name ?? Path.GetFileNameWithoutExtension(path); var nest = new Nest
{
Name = name ?? (string.IsNullOrWhiteSpace(source?.Name) ? Path.GetFileNameWithoutExtension(path) : source.Name),
Customer = source?.Customer ?? "",
Notes = source?.Notes ?? "",
AssistGas = source?.AssistGas ?? "",
Status = source?.Status ?? NestStatus.Quote,
MadeBy = source?.MadeBy ?? "",
Thickness = source?.Thickness ?? 0,
Material = source?.Material ?? new Material(),
Units = source?.Units ?? default,
DateCreated = source?.DateCreated ?? default,
DateLastModified = source?.DateLastModified ?? default,
PlateDefaults = source?.PlateDefaults ?? new Nest.PlateSettings(),
PlateOptions = source?.PlateOptions ?? new(),
SalvageRate = source?.SalvageRate ?? 0.5,
};
foreach (var drawing in _session.AllDrawings()) foreach (var drawing in _session.AllDrawings())
nest.Drawings.Add(drawing); nest.Drawings.Add(drawing);
+247 -9
View File
@@ -10,6 +10,7 @@ using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement; using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Mcp.Tools namespace OpenNest.Mcp.Tools
{ {
@@ -257,10 +258,7 @@ namespace OpenNest.Mcp.Tools
sb.AppendLine($" Total parts: {countAfter}"); sb.AppendLine($" Total parts: {countAfter}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}"); sb.AppendLine($" Utilization: {plate.Utilization():P1}");
// Breakdown by drawing AppendMixSummary(sb, items, parts);
var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name);
foreach (var group in groups)
sb.AppendLine($" {group.Key}: {group.Count()}");
return sb.ToString(); return sb.ToString();
} }
@@ -333,11 +331,235 @@ namespace OpenNest.Mcp.Tools
sb.AppendLine($" Parts placed: {totalPlaced}"); sb.AppendLine($" Parts placed: {totalPlaced}");
sb.AppendLine($" Total parts: {plate.Parts.Count}"); sb.AppendLine($" Total parts: {plate.Parts.Count}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}"); sb.AppendLine($" Utilization: {plate.Utilization():P1}");
foreach (var group in plate.Parts.GroupBy(p => p.BaseDrawing.Name)) AppendMixSummary(sb, parsed.items, proposed == null ? Enumerable.Empty<Part>() : proposed.Parts);
sb.AppendLine($" {group.Key}: {group.Count()}");
return sb.ToString(); return sb.ToString();
} }
[McpServerTool(Name = "autonest_job")]
[Description("Complete requested drawing quantities across physical sheets. The selected empty plate supplies common settings; optional sheets offer finite sizes and quantities. An incomplete or invalid proposal changes nothing.")]
public string AutoNestJob(
[Description("Index of an empty plate to use as the stock template")] int plateIndex,
[Description("Comma-separated drawing names")] string drawingNames,
[Description("Comma-separated total requested quantities for the entire session")] string quantities,
[Description(JobEngines)] string engine = null,
[Description("Use only already-created empty plates with matching dimensions and spacing; do not add sheets")] bool no_new_plates = false,
CancellationToken cancellationToken = default,
[Description("Optional finite physical sheet stock: width, length, available quantity and optional positive per-sheet cost in common units. Specify costs for every row or omit all. Incompatible with no_new_plates.")] SheetStockInput[] sheets = null
)
{
var template = _session.GetPlate(plateIndex);
if (template == null)
return $"Error: plate {plateIndex} not found";
if (template.Parts.Count != 0 || template.CutOffs.Count != 0)
return "Error: stock template must be empty and have no cutoff definitions. Nothing committed.";
if (sheets != null && no_new_plates)
return "Error: sheets cannot be combined with no_new_plates. Nothing committed.";
var offeredStock = BuildSheetStock(template, sheets);
if (offeredStock.error != null)
return offeredStock.error;
if (string.IsNullOrWhiteSpace(drawingNames) || string.IsNullOrWhiteSpace(quantities))
return "Error: drawingNames and quantities are required";
var parsed = ParseItems(drawingNames, quantities);
if (parsed.error != null)
return parsed.error;
if (parsed.items.Any(item => item.Quantity <= 0))
return "Error: whole-job quantities must be positive";
var existing = _session.AllPlates();
if (existing.Any(p => p.Parts.Count > 0 && p.Quantity <= 0))
return "Error: an occupied plate has a nonpositive quantity. Nothing committed.";
var remaining = new List<NestItem>(parsed.items.Count);
var counts = new Dictionary<Drawing, long>(ReferenceEqualityComparer.Instance);
foreach (var item in parsed.items)
{
var nested = existing.Sum(p => (long)p.Parts.Count(part => ReferenceEquals(part.BaseDrawing, item.Drawing)) * p.Quantity);
if (nested > item.Quantity)
return $"Error: drawing '{item.Drawing.Name}' already exceeds requested quantity ({nested} > {item.Quantity}). Nothing committed.";
counts[item.Drawing] = nested;
if (nested < item.Quantity)
remaining.Add(new NestItem
{
Drawing = item.Drawing,
Quantity = (int)(item.Quantity - nested),
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
});
}
if (remaining.Count == 0)
return "Job already complete: all requested quantities are present. Nothing committed.";
var available = new List<Plate> { template };
available.AddRange(existing.Where(p => !ReferenceEquals(p, template)
&& p.Parts.Count == 0 && p.CutOffs.Count == 0 && SameStock(p, template)));
var demand = remaining.Sum(item => (long)item.Quantity);
if (demand > int.MaxValue)
return "Error: total remaining demand exceeds the supported sheet limit. Nothing committed.";
var limit = no_new_plates ? System.Math.Min(available.Count, (int)demand)
: sheets != null ? (int)System.Math.Min(offeredStock.total, demand) : (int)demand;
var engineName = string.IsNullOrWhiteSpace(engine) ? _session.DefaultEngineName : engine.Trim();
if (NestingEngineRegistry.ResolveName(engineName) == null)
return $"Error: unknown whole-job engine '{engineName}'. Nothing committed. Available: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}";
NestPipelineResult result;
try
{
result = NestPipeline.Run(new NestPipelineRequest(engineName, remaining,
sheets == null ? NestStockBuilder.FromTemplate(template, null, no_new_plates ? available.Count : null) : offeredStock.stock,
new NestJobOptions(maxPlates: limit)), token: cancellationToken);
}
catch (NotSupportedException ex)
{
return $"Error: whole-job request unsupported: {ex.Message}. Nothing committed.";
}
cancellationToken.ThrowIfCancellationRequested();
if (!result.CanKeep || !result.IsValid)
return "Error: invalid whole-job proposal. Nothing committed. " + string.Join("; ", result.Violations);
var placed = new Dictionary<Drawing, long>(ReferenceEqualityComparer.Instance);
foreach (var sheet in result.Plates)
foreach (var part in sheet.Parts)
placed[part.BaseDrawing] = placed.GetValueOrDefault(part.BaseDrawing) + 1;
if (remaining.Any(item => placed.GetValueOrDefault(item.Drawing) != item.Quantity))
{
var summary = string.Join(", ", remaining.Select(item =>
$"{item.Drawing.Name}: {placed.GetValueOrDefault(item.Drawing)}/{item.Quantity} newly placed"));
return $"Error: incomplete whole-job proposal ({result.StopReason}); {summary}. Nothing committed. A solver's no-placement result does not prove geometric impossibility.";
}
if (result.Plates.Count > limit)
return "Error: proposal exceeds the allowed sheet count. Nothing committed.";
var costSummary = NestCostSummary.FromAccepted(result);
// Build the entire target map before attaching a single part. Indexes in a mixed
// stock proposal are solver ordinals, not indexes into the session's empty plates.
var usedTargets = new HashSet<Plate>(ReferenceEqualityComparer.Instance);
var targets = new List<(Plate plate, bool created, string stockId)>();
foreach (var proposed in result.Plates)
{
var target = new[] { template }.Concat(existing.Where(p => !ReferenceEquals(p, template)))
.FirstOrDefault(p => !usedTargets.Contains(p)
&& p.Parts.Count == 0 && p.CutOffs.Count == 0
&& SameStock(p, template, proposed.Stock.Size));
var created = target == null;
target ??= new Plate(proposed.Stock.Size)
{
GrainAngle = template.GrainAngle,
CuttingParameters = template.CuttingParameters,
};
usedTargets.Add(target);
targets.Add((target, created, proposed.Stock.Id));
}
cancellationToken.ThrowIfCancellationRequested();
for (var i = 0; i < result.Plates.Count; i++)
{
var proposed = result.Plates[i];
var (target, created, _) = targets[i];
target.Size = proposed.Stock.Size;
target.PartSpacing = proposed.Stock.PartSpacing;
target.EdgeSpacing = proposed.Stock.EdgeSpacing;
target.Quadrant = proposed.Stock.Quadrant;
target.Quantity = 1;
if (created)
_session.Plates.Add(target);
target.Parts.AddRange(proposed.Parts);
}
foreach (var item in parsed.items)
item.Drawing.Quantity.Required = item.Quantity;
if (sheets != null)
{
_session.Nest ??= new Nest();
_session.Nest.PlateOptions = offeredStock.stock.Select(s => new PlateOption
{ Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
}
var lines = new StringBuilder();
lines.AppendLine($"Cost basis={costSummary.Basis}, gross total={costSummary.GrossTotal}, estimated salvage credit={costSummary.SalvageCredit}, net score={costSummary.NetScore}");
foreach (var usage in costSummary.Stock)
lines.AppendLine($" {usage.StockId}: used={usage.Used}, supplied cost={usage.Cost?.ToString() ?? "unspecified"}, gross subtotal={usage.GrossSubtotal}");
lines.AppendLine($"Whole job complete: {result.Plates.Count} sheet(s) placed, {targets.Count(t => t.created)} new sheet(s) created.");
if (sheets != null)
{
foreach (var stock in offeredStock.stock)
{
var used = targets.Count(t => t.stockId == stock.Id);
lines.AppendLine($" {stock.Id} ({stock.Size.Width}x{stock.Size.Length}): used={used}, remaining={stock.Quantity - used}, reused={targets.Count(t => !t.created && t.stockId == stock.Id)}, created={targets.Count(t => t.created && t.stockId == stock.Id)}");
}
foreach (var (target, _, stockId) in targets)
lines.AppendLine($" plate {_session.AllPlates().FindIndex(p => ReferenceEquals(p, target))} -> {stockId}");
}
foreach (var item in parsed.items)
lines.AppendLine($" {item.Drawing.Name}: requested={item.Quantity}, already={counts[item.Drawing]}, newly placed={placed.GetValueOrDefault(item.Drawing)}, remaining=0");
return lines.ToString();
}
private static (List<NestPlateStock> stock, long total, string error) BuildSheetStock(Plate template, SheetStockInput[] sheets)
{
if (sheets == null)
return (null, 0, null);
if (sheets.Length == 0)
return (null, 0, "Error: sheets must contain at least one stock size. Nothing committed.");
var stock = new List<NestPlateStock>(sheets.Length);
var priced = sheets.Any(s => s?.Cost != null);
long total = 0;
for (var i = 0; i < sheets.Length; i++)
{
var row = sheets[i];
if (row == null || !double.IsFinite(row.Width) || !double.IsFinite(row.Length)
|| !double.IsFinite(row.Width * row.Length)
|| row.Width <= 0 || row.Length <= 0 || row.Quantity <= 0
|| row.Length <= template.EdgeSpacing.Left + template.EdgeSpacing.Right
|| row.Width <= template.EdgeSpacing.Top + template.EdgeSpacing.Bottom)
return (null, 0, $"Error: sheets[{i}] requires finite positive usable dimensions and a positive physical quantity. Nothing committed.");
if (row.Cost is double cost && (!double.IsFinite(cost) || cost <= 0)
|| priced && row.Cost == null)
return (null, 0, $"Error: sheets[{i}] requires a finite positive cost; specify costs on every row or omit all. Nothing committed.");
var duplicate = stock.FindIndex(s => s.Size.Width == row.Width && s.Size.Length == row.Length);
if (duplicate >= 0)
return (null, 0, $"Error: sheets[{i}] duplicates sheets[{duplicate}] size. Nothing committed.");
total += row.Quantity;
if (total > int.MaxValue)
return (null, 0, "Error: total sheet inventory exceeds the supported sheet limit. Nothing committed.");
stock.Add(new NestPlateStock($"stock-{i}", new Size(row.Width, row.Length), row.Quantity,
template.PartSpacing, template.EdgeSpacing, template.Quadrant, row.Cost));
}
return (stock, total, null);
}
private static bool SameStock(Plate a, Plate b) => SameStock(a, b, b.Size);
private static bool SameStock(Plate a, Plate b, Size size) =>
a.Size.Width == size.Width && a.Size.Length == size.Length
&& a.PartSpacing == b.PartSpacing && a.EdgeSpacing.Equals(b.EdgeSpacing)
&& a.Quadrant == b.Quadrant && a.GrainAngle == b.GrainAngle
&& (a.CuttingParameters == null && b.CuttingParameters == null
|| a.CuttingParameters != null && b.CuttingParameters != null
&& string.Equals(CuttingParametersSerializer.Serialize(a.CuttingParameters),
CuttingParametersSerializer.Serialize(b.CuttingParameters), StringComparison.Ordinal));
private static void AppendMixSummary(StringBuilder sb, IReadOnlyList<NestItem> items, IEnumerable<Part> newlyPlaced)
{
var counts = new Dictionary<Drawing, int>(ReferenceEqualityComparer.Instance);
foreach (var part in newlyPlaced)
counts[part.BaseDrawing] = counts.TryGetValue(part.BaseDrawing, out var n) ? n + 1 : 1;
var placed = new int[items.Count];
sb.AppendLine(" Requested mix (new placements only):");
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
placed[i] = counts.GetValueOrDefault(item.Drawing);
var remaining = item.Quantity - placed[i];
sb.AppendLine($" [{i}] {item.Drawing.Name}: requested={item.Quantity}, placed={placed[i]}, remaining={remaining}");
}
if (placed.All(count => count == 0))
sb.AppendLine(" Mix: zero progress for all requested drawings.");
else if (items.Where((item, i) => item.Quantity > placed[i]).Any())
sb.AppendLine(" Warning: remaining demand in this call; place the outstanding quantities on another sheet before treating the job as complete.");
else
sb.AppendLine(" Mix: requested quantities placed in this call.");
}
/// <summary> /// <summary>
/// Resolves the requested fill strategy; an omitted name means Fill. Returns null when the /// Resolves the requested fill strategy; an omitted name means Fill. Returns null when the
/// name is not a single-plate placement strategy. /// name is not a single-plate placement strategy.
@@ -376,6 +598,8 @@ namespace OpenNest.Mcp.Tools
{ {
if (!int.TryParse(qtyStrings[i], out qtys[i])) if (!int.TryParse(qtyStrings[i], out qtys[i]))
return (null, $"Error: '{qtyStrings[i]}' is not a valid quantity"); return (null, $"Error: '{qtyStrings[i]}' is not a valid quantity");
if (qtys[i] < 0)
return (null, $"Error: negative quantity '{qtys[i]}' is not supported");
} }
if (names.Length != qtys.Length) if (names.Length != qtys.Length)
@@ -385,14 +609,28 @@ namespace OpenNest.Mcp.Tools
); );
var items = new List<NestItem>(); var items = new List<NestItem>();
var seen = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
for (var i = 0; i < names.Length; i++) for (var i = 0; i < names.Length; i++)
{ {
var drawing = _session.GetDrawing(names[i]); var matches = _session.AllDrawings().Where(d => d.Name == names[i]).ToArray();
if (drawing == null) if (matches.Length == 0)
return (null, $"Error: drawing '{names[i]}' not found"); return (null, $"Error: drawing '{names[i]}' not found");
if (matches.Length > 1)
return (null, $"Error: ambiguous drawing name '{names[i]}' matches multiple drawings");
var drawing = matches[0];
if (!seen.Add(drawing))
return (null, $"Error: duplicate drawing '{names[i]}' in request");
items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] }); items.Add(new NestItem
{
Drawing = drawing,
Quantity = qtys[i],
Priority = drawing.Priority,
StepAngle = drawing.Constraints?.StepAngle ?? 0,
RotationStart = drawing.Constraints?.StartAngle ?? 0,
RotationEnd = drawing.Constraints?.EndAngle ?? 0,
});
} }
return (items, null); return (items, null);
+16
View File
@@ -58,6 +58,22 @@ namespace OpenNest.Mcp.Tools
return sb.ToString(); return sb.ToString();
} }
[McpServerTool(Name = "delete_plate")]
[Description("Delete an empty plate without parts or cutoff definitions. Later plate indices shift down by one.")]
public string DeletePlate([Description("Index of the empty plate to delete")] int plateIndex)
{
var oldCount = _session.AllPlates().Count;
if (!_session.TryDeleteEmptyPlate(plateIndex, out var reason))
return $"Error: {reason}; no plates changed";
var sb = new StringBuilder();
sb.AppendLine($"Deleted plate {plateIndex}. Plates: {oldCount} -> {oldCount - 1}.");
for (var oldIndex = plateIndex + 1; oldIndex < oldCount; oldIndex++)
sb.AppendLine($" {oldIndex} -> {oldIndex - 1}");
sb.AppendLine("Use get_plate_info with the new indices before further operations.");
return sb.ToString();
}
[McpServerTool(Name = "clear_plate")] [McpServerTool(Name = "clear_plate")]
[Description("Remove all parts from a plate. Returns how many parts were removed.")] [Description("Remove all parts from a plate. Returns how many parts were removed.")]
public string ClearPlate([Description("Index of the plate to clear")] int plateIndex) public string ClearPlate([Description("Index of the plate to clear")] int plateIndex)
+17 -85
View File
@@ -25,7 +25,6 @@ public static class NestPdfWriter
private const double PortraitWidth = 540; private const double PortraitWidth = 540;
private const double LandscapeWidth = 720; private const double LandscapeWidth = 720;
private const double OverviewHeight = 320; private const double OverviewHeight = 320;
private const double DetailHeight = 400;
private static readonly XColor Fill = XColor.FromArgb(222, 222, 222); private static readonly XColor Fill = XColor.FromArgb(222, 222, 222);
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture; private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
@@ -54,7 +53,7 @@ public static class NestPdfWriter
// Plan every diagram before layout; nothing below touches the destination until Save. // Plan every diagram before layout; nothing below touches the destination until Save.
var plans = snapshot.Plates.Select(plate => var plans = snapshot.Plates.Select(plate =>
NestReportDiagram.Plan(plate, LandscapeWidth, OverviewHeight, DetailHeight)).ToList(); NestReportDiagram.Plan(plate, LandscapeWidth, OverviewHeight)).ToList();
var document = new Document(); var document = new Document();
document.Info.Title = snapshot.Name; document.Info.Title = snapshot.Name;
@@ -131,7 +130,7 @@ public static class NestPdfWriter
else else
{ {
var plates = AddTable(section, 60, 200, 80, 100, 100); var plates = AddTable(section, 60, 200, 80, 100, 100);
AddHeader(plates, "Plate", "Stock size", "Copies", "Parts/sheet", "Utilization"); AddHeader(plates, "Plate", "Stock size", "Quantity", "Parts per plate", "Utilization");
foreach (var plate in snapshot.Plates) foreach (var plate in snapshot.Plates)
AddRow(plates, $"Plate {plate.Number}", false, plate.Number.ToString(Invariant), SheetSize(snapshot, plate), AddRow(plates, $"Plate {plate.Number}", false, plate.Number.ToString(Invariant), SheetSize(snapshot, plate),
plate.Copies.ToString(Invariant), plate.Parts.Length.ToString(Invariant), Percent(plate.Utilization)); plate.Copies.ToString(Invariant), plate.Parts.Length.ToString(Invariant), Percent(plate.Utilization));
@@ -158,7 +157,7 @@ public static class NestPdfWriter
} }
private static void AddPlate(Document document, NestReportSnapshot snapshot, ReportPlate plate, private static void AddPlate(Document document, NestReportSnapshot snapshot, ReportPlate plate,
PlateDiagramPlan plan, List<(Table, Action<XGraphics, XRect>)> areas) DiagramView plan, List<(Table, Action<XGraphics, XRect>)> areas)
{ {
var heading = $"Plate {plate.Number} of {snapshot.Plates.Length}"; var heading = $"Plate {plate.Number} of {snapshot.Plates.Length}";
var header = new List<string> { $"Nest report: {snapshot.Name}", heading, SheetSize(snapshot, plate) }; var header = new List<string> { $"Nest report: {snapshot.Name}", heading, SheetSize(snapshot, plate) };
@@ -172,30 +171,23 @@ public static class NestPdfWriter
title.Format.Font.Size = 13; title.Format.Font.Size = 13;
title.Format.Font.Bold = true; title.Format.Font.Bold = true;
var info = AddTable(section, 80, 280, 90, 270); var info = AddTable(section, 100, 260, 90, 270);
info.Borders.Visible = false; info.Borders.Visible = false;
AddInfoRow(info, "Nest:", snapshot.Name, "Sheet size:", SheetSize(snapshot, plate)); AddInfoRow(info, "Nest:", snapshot.Name, "Sheet size:", SheetSize(snapshot, plate));
AddInfoRow(info, "Material:", Material(snapshot), "Copies:", plate.Copies.ToString(Invariant)); AddInfoRow(info, "Material:", Material(snapshot), "Quantity:", plate.Copies.ToString(Invariant));
AddInfoRow(info, "Thickness:", Thickness(snapshot), "Part spacing:", AddInfoRow(info, "Thickness:", Thickness(snapshot), "Part spacing:",
$"{Number(plate.PartSpacing)} {snapshot.Units}"); $"{Number(plate.PartSpacing)} {snapshot.Units}");
AddInfoRow(info, "Parts/sheet:", plate.Parts.Length.ToString(Invariant), "Utilization:", AddInfoRow(info, "Parts per plate:", plate.Parts.Length.ToString(Invariant), "Utilization:",
Percent(plate.Utilization) + " (net part area / full sheet)"); Percent(plate.Utilization) + " (net part area / full sheet)");
var overview = AddReservedArea(section, null, plan.Overview.Size.Height); var overview = AddReservedArea(section, plan.Size.Height);
areas.Add((overview, (gfx, _) => DrawView(gfx, plate, plan.Overview))); areas.Add((overview, (gfx, _) => DrawView(gfx, plate, plan)));
var legend = section.AddParagraph("Filled outlines are closed parts; open material paths such as tab gaps are shown without fill. Dashed lines are scrap cutoffs."); var legend = section.AddParagraph("Filled outlines are closed parts; open material paths such as tab gaps are shown without fill. Dashed lines are scrap cutoffs.");
legend.Format.Font.Size = 7.5; legend.Format.Font.Size = 7.5;
legend.Format.SpaceBefore = Unit.FromPoint(4); legend.Format.SpaceBefore = Unit.FromPoint(4);
if (plan.Overview.Grid is { } grid) var labelsNote = section.AddParagraph("Part IDs are shown where space permits. A dotted outline groups like parts under one ID.");
{ labelsNote.Format.Font.Size = 7.5;
var names = string.Join(", ", plan.Details.Select(detail => detail.Cell));
var fallback = section.AddParagraph();
fallback.Format.Font.Size = 7.5;
AddLines(fallback, ReportText.Wrap(
$"Some part IDs are too small to label at this scale. Grid rows are lettered from the top and columns numbered from the left; cells outlined dash-dot have detail views on the following pages: {names}.",
new XFont(ReportFonts.Family, 7.5), LandscapeWidth));
}
var approval = section.AddParagraph("This report is not a geometry or CNC approval."); var approval = section.AddParagraph("This report is not a geometry or CNC approval.");
approval.Format.Font.Size = 7.5; approval.Format.Font.Size = 7.5;
approval.Format.SpaceAfter = Unit.FromPoint(4); approval.Format.SpaceAfter = Unit.FromPoint(4);
@@ -204,36 +196,23 @@ public static class NestPdfWriter
var perSheet = plate.Parts.GroupBy(part => part.ReportId, StringComparer.Ordinal) var perSheet = plate.Parts.GroupBy(part => part.ReportId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => (long)group.Count(), StringComparer.Ordinal); .ToDictionary(group => group.Key, group => (long)group.Count(), StringComparer.Ordinal);
var parts = AddTable(section, 60, 400, 110, 150); var parts = AddTable(section, 60, 400, 110, 150);
AddHeader(parts, "ID", "Drawing", "Qty/sheet", $"Total ({plate.Copies} copies)"); AddHeader(parts, "ID", "Drawing", "Parts per plate", $"Total ({plate.Copies} plates)");
foreach (var drawing in snapshot.Drawings.Where(drawing => perSheet.ContainsKey(drawing.Id))) foreach (var drawing in snapshot.Drawings.Where(drawing => perSheet.ContainsKey(drawing.Id)))
{ {
var count = perSheet[drawing.Id]; var count = perSheet[drawing.Id];
AddRow(parts, $"Plate {plate.Number}, drawing {drawing.Id}", false, drawing.Id, names2[drawing.Id], AddRow(parts, $"Plate {plate.Number}, drawing {drawing.Id}", false, drawing.Id, names2[drawing.Id],
count.ToString(Invariant), checked(count * plate.Copies).ToString(Invariant)); count.ToString(Invariant), checked(count * plate.Copies).ToString(Invariant));
} }
foreach (var detail in plan.Details)
{
var bounds = detail.CellBounds!;
var caption = $"Plate {plate.Number} detail {detail.Cell}: X {Number(bounds.Left)} to {Number(bounds.Right)}, Y {Number(bounds.Bottom)} to {Number(bounds.Top)} {snapshot.Units}. Parts centered in the dash-dot cell are labeled.";
var table = AddReservedArea(section, caption, detail.Size.Height);
areas.Add((table, (gfx, _) => DrawView(gfx, plate, detail)));
}
} }
/// <summary>A fixed-height, never-split table row reserves a vector drawing area in MigraDoc's flow.</summary> /// <summary>A fixed-height, never-split table row reserves a vector drawing area in MigraDoc's flow.</summary>
private static Table AddReservedArea(Section section, string? caption, double height) private static Table AddReservedArea(Section section, double height)
{ {
var table = AddTable(section, LandscapeWidth); var table = AddTable(section, LandscapeWidth);
table.Borders.Visible = false; table.Borders.Visible = false;
table.LeftPadding = table.RightPadding = Unit.Zero; table.LeftPadding = table.RightPadding = Unit.Zero;
table.TopPadding = table.BottomPadding = Unit.Zero; table.TopPadding = table.BottomPadding = Unit.Zero;
table.Format.SpaceBefore = Unit.FromPoint(4); table.Format.SpaceBefore = Unit.FromPoint(4);
if (caption != null)
{
var row = AddRow(table, "Detail caption", true, caption);
row.KeepWith = 1;
}
var reserved = table.AddRow(); var reserved = table.AddRow();
reserved.HeightRule = RowHeightRule.Exactly; reserved.HeightRule = RowHeightRule.Exactly;
reserved.Height = Unit.FromPoint(height); reserved.Height = Unit.FromPoint(height);
@@ -276,14 +255,6 @@ public static class NestPdfWriter
private static void DrawView(XGraphics gfx, ReportPlate plate, DiagramView view) private static void DrawView(XGraphics gfx, ReportPlate plate, DiagramView view)
{ {
var fit = view.Fit; var fit = view.Fit;
// Detail views clip exactly at their frame line so cropped neighbours stop there.
var frame = new XRect(0.5, 0.5, view.Size.Width - 1, view.Size.Height - 1);
var state = gfx.Save();
if (view.CellBounds != null)
gfx.IntersectClip(frame);
// The map grid lies beneath the sheet and parts so it never hides an outline.
if (view.Grid is { } grid)
DrawGrid(gfx, grid, fit);
gfx.DrawRectangle(new XPen(XColors.Black, 1), fit.Rect(plate.Bounds)); gfx.DrawRectangle(new XPen(XColors.Black, 1), fit.Rect(plate.Bounds));
var outline = new XPen(XColors.Black, 0.6); var outline = new XPen(XColors.Black, 0.6);
foreach (var part in plate.Parts) foreach (var part in plate.Parts)
@@ -293,54 +264,15 @@ public static class NestPdfWriter
var cutoff = new XPen(XColors.Black, 0.75) { DashPattern = [9, 3] }; var cutoff = new XPen(XColors.Black, 0.75) { DashPattern = [9, 3] };
foreach (var geometry in plate.Cutoffs) foreach (var geometry in plate.Cutoffs)
StrokeContours(gfx, geometry, fit, cutoff); StrokeContours(gfx, geometry, fit, cutoff);
// Long dash-dot, so detail cells are never confused with dashed scrap cutoffs. var groupPen = new XPen(XColors.Black, 0.6) { DashPattern = [1, 3] };
var detail = new XPen(XColors.Black, 1.6) { DashPattern = [9, 3, 1, 3] }; foreach (var group in view.Groups)
if (view.Grid is { } overviewGrid) gfx.DrawRectangle(groupPen, group.Boundary);
{
foreach (var (row, column) in overviewGrid.DetailCells)
gfx.DrawRectangle(detail, fit.Rect(overviewGrid.Cell(row, column)));
}
if (view.CellBounds is { } cell)
gfx.DrawRectangle(detail, fit.Rect(cell));
var font = NestReportDiagram.LabelFont(); var font = NestReportDiagram.LabelFont();
foreach (var label in view.Labels) foreach (var label in view.Labels.Concat(view.Groups.Select(group => group.Label)))
{ {
gfx.DrawRectangle(XBrushes.White, label.Box); gfx.DrawRectangle(XBrushes.White, label.Box);
gfx.DrawString(label.Id, font, XBrushes.Black, label.Box, XStringFormats.Center); gfx.DrawString(label.Id, font, XBrushes.Black, label.Box, XStringFormats.Center);
} }
gfx.Restore(state);
// Frame the viewport so cropped neighbouring parts read as intentional.
if (view.CellBounds != null)
gfx.DrawRectangle(new XPen(XColors.Gray, 0.75), frame);
}
private static void DrawGrid(XGraphics gfx, DiagramGrid grid, Fit fit)
{
var line = new XPen(XColors.Gray, 0.3) { DashPattern = [3, 6] };
var area = fit.Rect(grid.Union);
for (var column = 0; column <= grid.Columns; column++)
{
var x = area.X + column * grid.CellLength * fit.Scale;
gfx.DrawLine(line, x, area.Y, x, area.Y + grid.Rows * grid.CellWidth * fit.Scale);
}
for (var row = 0; row <= grid.Rows; row++)
{
var y = area.Y + row * grid.CellWidth * fit.Scale;
gfx.DrawLine(line, area.X, y, area.X + grid.Columns * grid.CellLength * fit.Scale, y);
}
var font = NestReportDiagram.LabelFont();
for (var column = 0; column < grid.Columns; column++)
{
var x = area.X + (column + 0.5) * grid.CellLength * fit.Scale;
gfx.DrawString((column + 1).ToString(Invariant), font, XBrushes.Black,
new XRect(x - 20, area.Y - 11, 40, 10), XStringFormats.BottomCenter);
}
for (var row = 0; row < grid.Rows; row++)
{
var y = area.Y + (row + 0.5) * grid.CellWidth * fit.Scale;
gfx.DrawString(ReportText.RowName(row), font, XBrushes.Black,
new XRect(area.X - 16, y - 5, 14, 10), XStringFormats.CenterRight);
}
} }
private static string Thumbnail(ReportGeometry geometry) private static string Thumbnail(ReportGeometry geometry)
@@ -532,7 +464,7 @@ public static class NestPdfWriter
snapshot.Thickness > 0 ? $"{Number(snapshot.Thickness)} {snapshot.Units}" : "Not specified"; snapshot.Thickness > 0 ? $"{Number(snapshot.Thickness)} {snapshot.Units}" : "Not specified";
private static string SheetSize(NestReportSnapshot snapshot, ReportPlate plate) => private static string SheetSize(NestReportSnapshot snapshot, ReportPlate plate) =>
$"{Number(plate.Bounds.Length)} x {Number(plate.Bounds.Width)} {snapshot.Units}"; $"{Number(plate.Bounds.Width)} x {Number(plate.Bounds.Length)} {snapshot.Units}";
private static string Number(double value) => value.ToString("0.####", Invariant); private static string Number(double value) => value.ToString("0.####", Invariant);
+8 -2
View File
@@ -21,13 +21,19 @@ public static class NestReportBuilder
{ {
if (byReference.TryGetValue(drawing, out var existing)) if (byReference.TryGetValue(drawing, out var existing))
return existing; return existing;
var item = new DrawingCount(drawing, "R" + (ordered.Count + 1).ToString("D3", CultureInfo.InvariantCulture)); var item = new DrawingCount(drawing, (ordered.Count + 1).ToString(CultureInfo.InvariantCulture));
byReference.Add(drawing, item); byReference.Add(drawing, item);
ordered.Add(item); ordered.Add(item);
return item; return item;
} }
for (var index = 0; index < nest.Plates.Count; index++) // The editor can leave unused stock at the end of a job. Exclude those placeholders
// from report pages and totals without changing plate numbers or the live nest.
var plateCount = nest.Plates.Count;
while (plateCount > 0 && nest.Plates[plateCount - 1]?.Parts.Count == 0)
plateCount--;
for (var index = 0; index < plateCount; index++)
{ {
var number = index + 1; var number = index + 1;
var plate = nest.Plates[index]; var plate = nest.Plates[index];
+81 -118
View File
@@ -6,43 +6,20 @@ namespace OpenNest.Reporting;
/// <summary>A part ID label in canvas-local page coordinates (Y down).</summary> /// <summary>A part ID label in canvas-local page coordinates (Y down).</summary>
internal sealed record DiagramLabel(int Part, string Id, XRect Box); internal sealed record DiagramLabel(int Part, string Id, XRect Box);
/// <summary>Map grid over the overview; rows are lettered from the top, columns numbered from the left.</summary> /// <summary>A dotted group boundary and its shared ID, in canvas coordinates.</summary>
internal sealed record DiagramGrid(ReportBounds Union, double CellLength, double CellWidth, int Columns, int Rows, internal sealed record DiagramGroup(IReadOnlyList<int> Parts, XRect Boundary, DiagramLabel Label);
IReadOnlyList<(int Row, int Column)> DetailCells)
{
public string Name(int row, int column) => ReportText.RowName(row) + (column + 1).ToString(System.Globalization.CultureInfo.InvariantCulture);
public ReportBounds Cell(int row, int column)
{
var left = Union.Left + column * CellLength;
var top = Union.Top - row * CellWidth;
return new ReportBounds(left, top - CellWidth, left + CellLength, top);
}
}
/// <summary>One drawn view of a plate: the fitted overview or a zoomed grid-cell detail.</summary>
internal sealed record DiagramView(XSize Size, Fit Fit, IReadOnlyList<DiagramLabel> Labels, internal sealed record DiagramView(XSize Size, Fit Fit, IReadOnlyList<DiagramLabel> Labels,
DiagramGrid? Grid, string? Cell, ReportBounds? CellBounds); IReadOnlyList<DiagramGroup> Groups);
internal sealed record PlateDiagramPlan(DiagramView Overview, IReadOnlyList<DiagramView> Details); /// <summary>Plans best-effort labels on a single plate view; small repeated parts may share a label.</summary>
/// <summary>
/// Plans labels before any PDF output. IDs are never shrunk below <see cref="LabelFontSize"/> or
/// dropped: a label that cannot sit legibly inside its part's material at overview scale is placed
/// in a zoomed map-grid detail view instead, or the report fails with the part identified.
/// </summary>
internal static class NestReportDiagram internal static class NestReportDiagram
{ {
internal const double LabelFontSize = 7; internal const double LabelFontSize = 7;
internal const int MaxDetailViews = 24;
private const double LabelPadding = 1; private const double LabelPadding = 1;
// Minimum white space between a label and any edge, hole, cutoff or other label. // Minimum white space between a label and any edge, hole, cutoff or other label.
private const double Clearance = 1.5; private const double Clearance = 1.5;
private const double Inset = 5; private const double Inset = 5;
private const double GutterLeft = 16;
private const double GutterTop = 12;
private const double DetailMargin = 0.08;
private static readonly double[] Zooms = [1, 2, 4];
internal static XFont LabelFont() internal static XFont LabelFont()
{ {
@@ -58,100 +35,100 @@ internal static class NestReportDiagram
internal static double PolePrecision(ReportBounds bounds) => internal static double PolePrecision(ReportBounds bounds) =>
System.Math.Max(System.Math.Max(bounds.Length, bounds.Width) * 0.005, 1e-9); System.Math.Max(System.Math.Max(bounds.Length, bounds.Width) * 0.005, 1e-9);
internal static PlateDiagramPlan Plan(ReportPlate plate, double width, double overviewHeight, double detailHeight) internal static DiagramView Plan(ReportPlate plate, double width, double height)
{ {
var shapes = plate.Parts.Select(part => new Shape(part.Geometry)).ToList(); var shapes = plate.Parts.Select(part => new Shape(part.Geometry)).ToList();
var obstacles = shapes.Concat(plate.Cutoffs.Select(cutoff => new Shape(cutoff))).ToList(); var obstacles = shapes.Concat(plate.Cutoffs.Select(cutoff => new Shape(cutoff))).ToList();
var union = Union(plate, obstacles); var union = Union(plate, obstacles);
var font = LabelFont(); var font = LabelFont();
var all = Enumerable.Range(0, plate.Parts.Length).ToList(); var all = Enumerable.Range(0, plate.Parts.Length).ToList();
var canvas = new XSize(width, overviewHeight); var canvas = new XSize(width, height);
var overviewView = new XRect(0, 0, width, overviewHeight); var view = new XRect(0, 0, width, height);
var fit = Fit.Create(union, new XRect(Inset, Inset, width - 2 * Inset, height - 2 * Inset));
var labels = PlaceLabels(plate, shapes, obstacles, all, fit, view, font, out var failed);
var groups = PlaceGroups(plate, failed, labels, fit, view, font);
return new DiagramView(canvas, fit, labels, groups);
}
var fit = Fit.Create(union, new XRect(Inset, Inset, width - 2 * Inset, overviewHeight - 2 * Inset)); private static List<DiagramGroup> PlaceGroups(ReportPlate plate, List<int> failed,
var labels = PlaceLabels(plate, shapes, obstacles, all, fit, overviewView, font, out var failed); List<DiagramLabel> labels, Fit fit, XRect view, XFont font)
if (failed.Count == 0) {
return new PlateDiagramPlan(new DiagramView(canvas, fit, labels, null, null, null), []); var boxes = plate.Parts.Select(part => fit.Rect(part.Geometry.Bounds)).ToList();
var cutoffs = plate.Cutoffs.Select(cutoff => fit.Rect(cutoff.Bounds)).ToList();
// Reserve map-grid gutters, then re-place what still fits at overview scale. var groups = new List<DiagramGroup>();
fit = Fit.Create(union, new XRect(GutterLeft, GutterTop, width - GutterLeft - Inset, overviewHeight - GutterTop - Inset)); // A small screen-space gap also accommodates tightly packed parts with unset spacing.
labels = PlaceLabels(plate, shapes, obstacles, all, fit, overviewView, font, out failed); var gap = System.Math.Max(4, plate.PartSpacing * fit.Scale * 2);
var failedSet = failed.ToHashSet(); var remaining = failed.ToHashSet();
var required = failed.Max(index => RequiredScale(plate, index, font)); foreach (var seed in failed)
var inner = new XRect(Inset, Inset, width - 2 * Inset, detailHeight - 2 * Inset);
var legibleGrid = false;
foreach (var zoom in Zooms)
{ {
var scale = required * zoom; if (!remaining.Remove(seed))
// Cells evenly divide the sheet (no overrun); each is at most the size a detail view
// shows at the required scale, so the zoom only increases.
var columns = System.Math.Max(1, (int)System.Math.Ceiling(union.Length * scale * (1 + 2 * DetailMargin) / inner.Width));
var rows = System.Math.Max(1, (int)System.Math.Ceiling(union.Width * scale * (1 + 2 * DetailMargin) / inner.Height));
var cellLength = union.Length / columns;
var cellWidth = union.Width / rows;
var grid = new DiagramGrid(union, cellLength, cellWidth, columns, rows, []);
// Every grid line and gutter name must stay readable on the overview.
if (!GridIsLegible(grid, fit, font))
continue; continue;
legibleGrid = true; var id = plate.Parts[seed].ReportId;
(int Row, int Column) CellOf(int index) => CellIndex(plate, index, union, columns, rows); var members = new List<int> { seed };
for (var cursor = 0; cursor < members.Count; cursor++)
var cells = failed.Select(CellOf).Distinct().OrderBy(cell => cell.Row).ThenBy(cell => cell.Column).ToList();
if (cells.Count > MaxDetailViews)
throw new NotSupportedException($"Plate {plate.Number}: legible part ID labels would need {cells.Count} detail views; this report supports at most {MaxDetailViews} per plate.");
grid = grid with { DetailCells = cells };
var details = new List<DiagramView>();
var unplaced = new List<int>();
foreach (var (row, column) in cells)
{ {
var cell = grid.Cell(row, column); var nearby = boxes[members[cursor]];
var marginX = cellLength * DetailMargin; nearby.Inflate(gap, gap);
var marginY = cellWidth * DetailMargin; foreach (var candidate in failed)
var view = new ReportBounds(cell.Left - marginX, cell.Bottom - marginY, cell.Right + marginX, cell.Top + marginY); {
var detailFit = Fit.Create(view, inner); if (remaining.Contains(candidate) && plate.Parts[candidate].ReportId == id
// Parts missing from the overview claim label space first. && nearby.IntersectsWith(boxes[candidate]))
var members = all.Where(index => CellOf(index) == (row, column)) {
.OrderBy(index => failedSet.Contains(index) ? 0 : 1).ToList(); remaining.Remove(candidate);
var detailLabels = PlaceLabels(plate, shapes, obstacles, members, detailFit, members.Add(candidate);
new XRect(0, 0, width, detailHeight), font, out var missed); }
unplaced.AddRange(missed.Where(failedSet.Contains)); }
details.Add(new DiagramView(new XSize(width, detailHeight), detailFit, detailLabels, null, }
grid.Name(row, column), cell)); if (members.Count < 2)
continue;
var boundary = boxes[seed];
foreach (var member in members.Skip(1))
boundary.Union(boxes[member]);
boundary.Inflate(1, 1);
var memberSet = members.ToHashSet();
// A rectangular group must not imply that unrelated or ungrouped parts belong to it.
if (!view.Contains(boundary)
|| boxes.Where((_, index) => !memberSet.Contains(index)).Any(box => box.IntersectsWith(boundary))
|| cutoffs.Any(box => box.IntersectsWith(boundary))
|| labels.Any(label => label.Box.IntersectsWith(boundary))
|| groups.Any(group => group.Boundary.IntersectsWith(boundary) || group.Label.Box.IntersectsWith(boundary)))
continue;
var size = ReportText.Size(id, font);
var labelWidth = size.Width + 2 * LabelPadding;
var labelHeight = size.Height + 2 * LabelPadding;
var centerX = boundary.X + (boundary.Width - labelWidth) / 2;
var centerY = boundary.Y + (boundary.Height - labelHeight) / 2;
// Put the ID beside the dotted boundary, never over a small part or its hole.
var candidates = new[]
{
new XRect(centerX, boundary.Y - labelHeight - Clearance, labelWidth, labelHeight),
new XRect(centerX, boundary.Bottom + Clearance, labelWidth, labelHeight),
new XRect(boundary.Right + Clearance, centerY, labelWidth, labelHeight),
new XRect(boundary.X - labelWidth - Clearance, centerY, labelWidth, labelHeight),
};
foreach (var box in candidates)
{
var padded = box;
padded.Inflate(Clearance, Clearance);
if (!view.Contains(padded) || boxes.Any(other => other.IntersectsWith(padded))
|| cutoffs.Any(other => other.IntersectsWith(padded))
|| labels.Any(label => label.Box.IntersectsWith(padded))
|| groups.Any(group => group.Boundary.IntersectsWith(padded) || group.Label.Box.IntersectsWith(padded)))
continue;
groups.Add(new DiagramGroup(members, boundary, new DiagramLabel(seed, id, box)));
break;
} }
if (unplaced.Count == 0)
return new PlateDiagramPlan(new DiagramView(canvas, fit, labels, grid, null, null), details);
} }
return groups;
var worst = failed.First();
if (!legibleGrid)
throw new NotSupportedException($"Plate {plate.Number}, part {worst + 1} ({plate.Parts[worst].ReportId}): the part is too small relative to the sheet for a legible detail-view grid; this is not supported by the report.");
throw new NotSupportedException($"Plate {plate.Number}, part {worst + 1} ({plate.Parts[worst].ReportId}): its ID label cannot be placed legibly inside the part's material, even in a zoomed detail view; this is not supported by the report.");
}
/// <summary>Grid cell holding a part's bounds center; rows count down from the top.</summary>
private static (int Row, int Column) CellIndex(ReportPlate plate, int index, ReportBounds union, int columns, int rows)
{
var bounds = plate.Parts[index].Geometry.Bounds;
var x = (bounds.Left + bounds.Right) / 2;
var y = (bounds.Bottom + bounds.Top) / 2;
return (System.Math.Clamp((int)((union.Top - y) / (union.Width / rows)), 0, rows - 1),
System.Math.Clamp((int)((x - union.Left) / (union.Length / columns)), 0, columns - 1));
}
/// <summary>Grid spacing on the overview must exceed the gutter names it carries.</summary>
internal static bool GridIsLegible(DiagramGrid grid, Fit fit, XFont font)
{
var column = ReportText.Size(grid.Columns.ToString(System.Globalization.CultureInfo.InvariantCulture), font);
var row = ReportText.Size(ReportText.RowName(grid.Rows - 1), font);
return grid.CellLength * fit.Scale >= column.Width + 2 && grid.CellWidth * fit.Scale >= row.Height + 1
&& row.Width + 2 <= GutterLeft && column.Height + 2 <= GutterTop;
} }
/// <summary> /// <summary>
/// Centers each ID on its part's pole of inaccessibility (<see cref="PolyLabel"/>, as PlateView /// Centers each ID on its part's pole of inaccessibility (<see cref="PolyLabel"/>, as PlateView
/// does), so it sits deepest inside the material and clear of holes. A label box that would /// does), so it sits deepest inside the material and clear of holes. A label box that would
/// touch an edge, hole, cutoff or another label is not moved elsewhere: the part fails here /// touch an edge, hole, cutoff or another label is not moved elsewhere: the individual label is skipped here
/// and gets a zoomed detail view instead. /// and may share a group label or remain unlabeled.
/// </summary> /// </summary>
private static List<DiagramLabel> PlaceLabels(ReportPlate plate, List<Shape> shapes, List<Shape> obstacles, private static List<DiagramLabel> PlaceLabels(ReportPlate plate, List<Shape> shapes, List<Shape> obstacles,
List<int> targets, Fit fit, XRect view, XFont font, out List<int> failed) List<int> targets, Fit fit, XRect view, XFont font, out List<int> failed)
@@ -178,20 +155,6 @@ internal static class NestReportDiagram
return placed; return placed;
} }
/// <summary>Scale at which the label would fit the part's bounds, with room to avoid holes and strokes.</summary>
private static double RequiredScale(ReportPlate plate, int index, XFont font)
{
var bounds = plate.Parts[index].Geometry.Bounds;
if (!(bounds.Length > 0) || !(bounds.Width > 0))
throw new NotSupportedException($"Plate {plate.Number}, part {index + 1} ({plate.Parts[index].ReportId}): the part has no area to hold its ID label; this is not supported by the report.");
var size = ReportText.Size(plate.Parts[index].ReportId, font);
var scale = System.Math.Max((size.Width + 2 * (LabelPadding + Clearance)) / bounds.Length,
(size.Height + 2 * (LabelPadding + Clearance)) / bounds.Width) * 1.5;
if (!double.IsFinite(scale))
throw new NotSupportedException($"Plate {plate.Number}, part {index + 1} ({plate.Parts[index].ReportId}): the part is too small to hold a legible ID label.");
return scale;
}
private static ReportBounds Union(ReportPlate plate, IEnumerable<Shape> shapes) private static ReportBounds Union(ReportPlate plate, IEnumerable<Shape> shapes)
{ {
var left = plate.Bounds.Left; var left = plate.Bounds.Left;
@@ -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(() =>
{ {
+4 -4
View File
@@ -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);
} }
+7 -2
View File
@@ -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;
+46 -1
View File
@@ -4,14 +4,59 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using OpenNest.Api; using OpenNest.Api;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO; using OpenNest.IO;
using OpenNest.Engine.Jobs;
namespace OpenNest.Tests.Api; namespace OpenNest.Tests.Api;
public class NestRunnerTests public class NestRunnerTests
{ {
[Fact]
public async Task ExplicitPricesSelectCheaperStockAndSurviveArchive()
{
var path = CreateTempSquareDxf(4, 4);
var archive = Path.ChangeExtension(path, ".nestquote");
try
{
var response = await NestRunner.RunAsync(new NestRequest
{
Engine = "Rectangles",
Parts = [new NestRequestPart { DxfPath = path, Quantity = 1 }],
Plates = [new NestRequestPlate { Id = "small", Size = new Size(10, 10), Quantity = 1, Cost = 50 },
new NestRequestPlate { Id = "large", Size = new Size(12, 12), Quantity = 1, Cost = 7 }],
});
Assert.Equal(NestValidationStatus.Valid, response.ValidationStatus);
Assert.Equal("large", Assert.Single(response.PlateStockMappings).StockId);
Assert.Equal("supplied-cost", response.Costs.Basis);
Assert.Equal(7, response.Costs.GrossTotal);
Assert.Equal(7, response.Costs.NetScore);
Assert.Equal(0, response.Costs.SalvageCredit);
await response.SaveAsync(archive);
var reopened = await NestResponse.LoadAsync(archive);
Assert.Equal(7, reopened.Costs.GrossTotal);
Assert.Equal(new[] { 50d, 7d }, reopened.Nest.PlateOptions.Select(p => p.Cost));
Assert.Equal(7, reopened.Request.Plates[1].Cost);
}
finally
{
File.Delete(path);
File.Delete(archive);
}
}
[Fact]
public async Task MixedCostsFailBeforeImport()
{
var error = await Assert.ThrowsAsync<ArgumentException>(() => NestRunner.RunAsync(new NestRequest
{
Parts = [new NestRequestPart { DxfPath = "missing.dxf", Quantity = 1 }],
Plates = [new NestRequestPlate { Id = "priced", Size = new Size(10, 10), Cost = 2 },
new NestRequestPlate { Id = "missing", Size = new Size(12, 12) }],
}));
Assert.Contains("Missing: missing", error.Message);
}
[Fact] [Fact]
public async Task RunAsync_LegacySheetSize_UsesUnlimitedLegacyStockAndDerivedPartId() public async Task RunAsync_LegacySheetSize_UsesUnlimitedLegacyStockAndDerivedPartId()
{ {
@@ -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();
Assert.False(proposal.CanApply); if (zeroLength)
var result = Assert.Single(proposal.Plates).Result; {
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status); Assert.False(proposal.CanApply);
Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.MissingLeadIn); Assert.Contains("Lead motion is missing, degenerate or inconsistent", string.Join("\n", proposal.Describe("in")));
var text = string.Join("\n", proposal.Describe("in")); Assert.True(ProgramContent.Equal(original, part.Program));
Assert.Contains("Plate 1: blocked: missing or zero-length lead-in.", text); return;
Assert.Contains("Open Cutting Settings...", text); }
Assert.Contains("other than None", text);
Assert.Contains("nonzero length", text);
Assert.Contains("Part 1 (sample):", text);
Assert.Contains("Cutting contour 1", text);
Assert.DoesNotContain("search limit", text);
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
Assert.Same(program, part.Program);
Assert.True(ProgramContent.Equal(original, part.Program));
Assert.Null(plate.CuttingParameters);
Assert.False(part.HasManualLeadIns);
// Choosing valid settings fixes the refusal; reporting never changes settings or bypasses checks. Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
var ready = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), keepOrder).Plan(); var result = Assert.Single(proposal.Plates).Result;
Assert.True(ready.CanApply, string.Join("\n", ready.Describe("in"))); Assert.True(result.IndependentlyReplayed);
Assert.True(Assert.Single(ready.Plates).Result.IndependentlyReplayed); Assert.DoesNotContain(result.Findings, f => f.Kind == PostVerificationKind.MissingLeadIn);
Assert.DoesNotContain("missing or zero-length", string.Join("\n", ready.Describe("in"))); Assert.Contains("Plate 1: ready.", string.Join("\n", proposal.Describe("in")));
Assert.Same(program, part.Program); var output = Assert.Single(result.ProposedOrder).CopyProgram();
Assert.DoesNotContain(ExecutionMotionReader.ReadSupported(output, part.Location, null).Motions,
m => m.Layer == OpenNest.CNC.LayerType.Leadin && m.Length > 0);
Assert.True(ProgramContent.Equal(original, part.Program)); Assert.True(ProgramContent.Equal(original, part.Program));
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status);
} }
[Fact] [Fact]
public void Describe_LockedProgramWithoutLead_ExplainsThatSettingsCannotRegenerateIt() public void Describe_LockedProgramWithoutLead_IsReadyAndKeepsItsProgram()
{ {
var part = new Part(new Drawing("locked sample", ExplicitContourTests.Square(false)), new Vector(1, 1)) var part = new Part(new Drawing("locked sample", ExplicitContourTests.Square(false)), new Vector(1, 1))
{ {
LeadInsLocked = true, LeadInsLocked = true,
}; };
var plate = new Nest().CreatePlate(); var plate = BestEffortCuttingPlanTests.Plate(part);
plate.Size = new Size(100, 100);
plate.Parts.Add(part);
var program = part.Program; var program = part.Program;
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan(); var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
Assert.Equal(CuttingPlanStatus.ConstraintConflict, Assert.Single(proposal.Plates).Result.Status); Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
Assert.False(proposal.CanApply); Assert.False(Assert.Single(proposal.Plates[0].Result.ProposedOrder).IsRegenerated);
var text = string.Join("\n", proposal.Describe("in")); Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status);
Assert.Contains("missing or zero-length lead-in", text);
Assert.Contains("If the affected part is locked, edit its lead-ins or unlock it before replanning.", text);
Assert.Contains("Part 1 (locked sample):", text);
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
Assert.True(part.LeadInsLocked); Assert.True(part.LeadInsLocked);
Assert.Same(program, part.Program); Assert.Same(program, part.Program);
} }
[Fact] [Fact]
public void Describe_LeadHitsNeighbour_SuggestsSpacingOrShorterLeadWithoutAllowingApply() public void Describe_LeadHitsNeighbour_SuggestsSpacingOrShorterLeadWithoutAllowingApply()
{ {
@@ -126,9 +126,24 @@ public class CuttingPlanServiceTests
Assert.Equal(beforeCodes, program.Codes?.ToArray()); Assert.Equal(beforeCodes, program.Codes?.ToArray());
} }
[Fact]
public void FixedProgramWithoutLead_IsReadyAndReplayed()
{
var part = Fixture()[0];
part.Program.Codes.RemoveAt(1);
((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
var unchanged = Unchanged([part]);
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
Assert.False(Assert.Single(result.ProposedOrder).IsRegenerated);
unchanged();
}
[Theory] [Theory]
[InlineData("suppressed")] [InlineData("suppressed")]
[InlineData("missing-lead")]
[InlineData("retention-unknown")] [InlineData("retention-unknown")]
[InlineData("cutoff")] [InlineData("cutoff")]
public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault) public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault)
@@ -138,10 +153,6 @@ public class CuttingPlanServiceTests
switch (fault) switch (fault)
{ {
case "suppressed": ((Motion)part.Program.Codes[2]).Suppressed = true; break; case "suppressed": ((Motion)part.Program.Codes[2]).Suppressed = true; break;
case "missing-lead":
part.Program.Codes.RemoveAt(1);
((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
expected = CuttingPlanStatus.ConstraintConflict; break;
case "retention-unknown": ((LinearMove)part.Program.Codes[^2]).EndPoint = new Vector(4, 2.25); break; case "retention-unknown": ((LinearMove)part.Program.Codes[^2]).EndPoint = new Vector(4, 2.25); break;
case "cutoff": part.BaseDrawing.IsCutOff = true; break; case "cutoff": part.BaseDrawing.IsCutOff = true; break;
} }
@@ -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
{ {
+25
View File
@@ -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);
}
}
+66
View File
@@ -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()
{ {
+49
View File
@@ -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));
}
+96 -83
View File
@@ -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(new PdfSharp.Drawing.XRect(0, 0, 720, 320).Contains(label.Box));
Assert.True(label.Box.Width >= ReportText.Size(label.Id, font).Width); Assert.DoesNotContain(labels, other => other != label && other.Box.IntersectsWith(label.Box));
Assert.True(label.Box.X >= 0 && label.Box.Right <= view.Size.Width && label.Box.Y >= 0 && label.Box.Bottom <= view.Size.Height);
Assert.DoesNotContain(view.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,25 +282,22 @@ 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 = plan.Fit.Rect(snapshot.Plates[0].Parts[label.Part].Geometry.Bounds);
{ return (X: label.Box.X - bounds.X, Y: label.Box.Y - bounds.Y);
var bounds = view.Fit.Rect(snapshot.Plates[0].Parts[label.Part].Geometry.Bounds); }).ToList();
return (X: label.Box.X - bounds.X, Y: label.Box.Y - bounds.Y); return relative.Select(offset => (offset.X - relative[0].X, offset.Y - relative[0].Y));
}).ToList(); });
return relative.Select(offset => (offset.X - relative[0].X, offset.Y - relative[0].Y)); Assert.All(offsets, delta =>
}); {
Assert.All(offsets, delta => Assert.InRange(delta.Item1, -0.01, 0.01);
{ Assert.InRange(delta.Item2, -0.01, 0.01);
Assert.InRange(delta.Item1, -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);
+44 -18
View File
@@ -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()
{ {
+22 -8
View File
@@ -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);
}
}
+262
View File
@@ -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