41 Commits
Author SHA1 Message Date
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
114 changed files with 6561 additions and 824 deletions

No files matched your search

+3
View File
@@ -5,6 +5,9 @@ namespace OpenNest.Api;
/// <summary>One explicit physical-stock type for a whole nesting job.</summary>
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 Size Size { get; init; }
+4
View File
@@ -44,6 +44,7 @@ public class NestResponse
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
public NestCostSummary Costs { get; init; }
public Nest Nest { get; init; }
public NestRequest Request { get; init; }
@@ -86,6 +87,7 @@ public class NestResponse
Fulfillment = Fulfillment is null
? []
: new List<NestPartFulfillment>(Fulfillment),
Costs = Costs,
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
PlateStockMappings = PlateStockMappings is null
? []
@@ -171,6 +173,7 @@ public class NestResponse
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
Costs = archive.Costs,
Nest = nest,
Request = request,
};
@@ -178,6 +181,7 @@ public class NestResponse
private sealed class NestResponseArchiveDto
{
public NestCostSummary Costs { get; init; }
public int SchemaVersion { get; init; }
public int SheetCount { get; init; }
public double Utilization { get; init; }
+8 -2
View File
@@ -28,6 +28,7 @@ public static class NestRunner
if (requestParts.Count == 0)
throw new ArgumentException("Request must contain at least one part.", nameof(request));
var stock = CreateStock(request);
var sw = Stopwatch.StartNew();
var parts = IdentifyParts(requestParts);
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 jobProgress = progress == null ? null : new JobProgressBridge(progress);
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.
// Invalid but representable proposals retain every pose and carry explicit validation status.
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)
nest.Drawings.Add(item.Drawing);
foreach (var proposed in result.Plates)
@@ -144,6 +147,7 @@ public static class NestRunner
Violations = result.Violations,
Fulfillment = fulfillment,
StockUsage = usage,
Costs = result.CanKeep && result.IsValid ? NestCostSummary.FromAccepted(result) : null,
PlateStockMappings = result.Plates.Select((value, index) =>
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
Nest = nest,
@@ -209,11 +213,13 @@ public static class NestRunner
plate.Quantity,
plate.PartSpacing,
plate.EdgeSpacing,
plate.Quadrant
plate.Quadrant,
plate.Cost
)
);
}
NestJobValidator.ValidateStockCosts(stock);
return stock;
}
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingPlanning;
@@ -46,7 +47,7 @@ public sealed class LeadMaterialSnapshot
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Material contour is discontinuous.");
chain.Add(curve);
if (PostVerificationGeometry.Closed(chain))
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
Finish();
}
Finish();
@@ -66,11 +67,23 @@ public sealed class LeadMaterialSnapshot
var b = rings[s][j];
var contacts = a.Contacts(b, out var overlap);
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
|| !(p.DistanceTo(a.End) <= PostVerificationGeometry.Epsilon
&& p.DistanceTo(b.Start) <= PostVerificationGeometry.Epsilon)
&& !(p.DistanceTo(a.Start) <= PostVerificationGeometry.Epsilon
&& p.DistanceTo(b.End) <= PostVerificationGeometry.Epsilon)))
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
}
var outer = -1;
@@ -97,8 +110,13 @@ public sealed class LeadMaterialSnapshot
{
if (chain.Count == 0)
return;
if (!PostVerificationGeometry.Closed(chain))
throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly.");
if (!NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
{
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());
chain.Clear();
}
@@ -113,7 +113,8 @@ public static class LeadPathValidator
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);
}
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)
throw new ArgumentException("Discontinuous native contour.");
current.Entities.Add(entity);
if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon)
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
FinishContour();
}
if (current.Entities.Count != 0 || contours.Count == 0)
@@ -11,10 +11,20 @@ namespace OpenNest.CNC.CuttingPlanning;
public sealed class ReleasedContourState
{
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()
{
var copy = new ReleasedContourState();
var copy = new ReleasedContourState(reportMissingLeadIns);
copy.obstacles.AddRange(obstacles);
return copy;
}
@@ -68,7 +78,13 @@ public sealed class ReleasedContourState
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"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)
@@ -92,9 +108,10 @@ public sealed class ReleasedContourState
if (contour.Count == 0)
{
contourNumber++;
if (!cutoff && !hasLead)
if (reportMissingLeadIns && !cutoff && !hasLead)
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;
// A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles
@@ -113,7 +113,8 @@ public static class PostVerificationAnalyzer
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
foreach (var pair in overlap.Pairs)
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)
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
@@ -89,6 +89,30 @@ internal static class PostVerificationGeometry
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 Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
private static double Normalize(double angle)
@@ -106,8 +130,13 @@ internal static class PostVerificationGeometry
Center = center;
Radius = radius;
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 End { get; }
private Vector? Center { get; }
@@ -170,12 +199,21 @@ internal static class PostVerificationGeometry
: 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)
{
var entity = ToEntity();
var candidate = other.ToEntity();
var entity = nativeEntity.Value;
var candidate = other.nativeEntity.Value;
// Native arc filters discard NaN supporting-circle intersections. Inspect
// both unfiltered queries first: roundoff can differ by operand direction.
// 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.
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)
{
@@ -294,10 +333,13 @@ internal static class PostVerificationGeometry
if (square < -ContactSlack)
return false;
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
&& 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 relativeStart = Start - origin;
@@ -310,13 +352,20 @@ internal static class PostVerificationGeometry
var b = Dot(End - origin, direction);
var low = System.Math.Max(0, System.Math.Min(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 fractionAlongEdge = Cross(relativeStart, direction) / denominator;
return distanceAlongRapid > Epsilon && distanceAlongRapid <= 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)
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using OpenNest.Geometry;
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>
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>
public sealed class PostVerificationReport
@@ -29,10 +38,12 @@ public sealed class PostVerificationReport
public bool HasWarnings => Findings.Count != 0;
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();
text.AppendLine("Pre-post verification");
text.AppendLine(title);
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
Summary(PostVerificationKind.Overlap, "Overlap");
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
@@ -0,0 +1,29 @@
using System;
using System.Linq;
namespace OpenNest.Sequencing
{
/// <summary>Moves one existing part to a one-based cutting sequence position.</summary>
public static class ManualPartSequencing
{
public static bool Move(Plate plate, Part part, int sequenceNumber)
{
ArgumentNullException.ThrowIfNull(plate);
ArgumentNullException.ThrowIfNull(part);
var order = plate.Parts.ToList();
if (sequenceNumber < 1 || sequenceNumber > order.Count)
return false;
var oldIndex = order.FindIndex(p => ReferenceEquals(p, part));
if (oldIndex < 0)
return false;
if (oldIndex == sequenceNumber - 1)
return true;
order.RemoveAt(oldIndex);
order.Insert(sequenceNumber - 1, part);
plate.Parts.Reorder(order);
return true;
}
}
}
+4 -1
View File
@@ -15,7 +15,8 @@ public sealed class NestBrowseSession : IDisposable
private int _generation;
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);
if (pageSize < 1 || pageSize > NestQuery.MaxLimit)
@@ -23,6 +24,8 @@ public sealed class NestBrowseSession : IDisposable
_repository = repository;
PageSize = pageSize;
Sort = Enum.IsDefined(sort) ? sort : NestSortField.SavedAt;
Descending = descending;
}
public int PageSize { get; }
+12
View File
@@ -32,6 +32,18 @@ public sealed class NestStorageSettings
public bool IsDatabaseMode =>
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>
public static string DefaultPath => Path.Combine(
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()
{
foreach (var rate in new[] { 0.0, 0.5, 1.0 })
foreach (var edge in new[] { "bottom", "top", "left", "right" })
foreach (var quadrant in new[] { 1, 2, 3, 4 })
yield return new object[] { rate, edge, quadrant };
foreach (var edge in new[] { "bottom", "top", "left", "right" })
foreach (var quadrant in new[] { 1, 2, 3, 4 })
yield return new object[] { rate, edge, quadrant };
}
[Theory]
@@ -84,7 +84,14 @@ public class NestJobCostTests
TestDrawingFactory.Rectangle(4, 3)), 5);
var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options);
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(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000,
NestJobCost.Evaluate(job, result));
@@ -75,6 +75,73 @@ public class NestPipelineCommitTests
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]
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]
public void CancellationPropagatesWithoutAResult()
{
@@ -0,0 +1,165 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class StockCostTests
{
public static IEnumerable<object[]> Engines()
{
foreach (var engine in new[] { "Rectangles", "Irregular", "StockLadder", "Default", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" })
foreach (var factor in new[] { 1e-12, 1.0, 100.0 })
yield return new object[] { engine, factor };
}
private static NestPlateStock Stock(string id, double cost, int quantity = 2, double size = 10) =>
new(id, new Size(size, size), quantity, 0.1, default, 1, cost);
private static NestJob Job(params NestPlateStock[] stocks) => new(
new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 1) }, stocks);
[Fact]
public void ValidationRejectsForgedStockPrice()
{
var job = Job(Stock("offered", 10));
var forged = Stock("offered", 1);
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, forged, new[] { new NestJobPlacement("part", 0, 0, 0, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.NotEmpty(NestLayoutCheck.Violations(job, result));
}
[Fact]
public void UnavailablePriceDoesNotChangeAreaModeOrLegacyPenalty()
{
var job = Job(new NestPlateStock("available", new Size(10, 10)), Stock("unavailable", 10000, 0));
Assert.False(NestJobCost.UsesExplicitCosts(job));
Assert.Equal(100, NestJobCost.UnplacedPartPenalty(job));
}
[Fact]
public void FixedStrategyUsesSalvageOnlyForPricedStock()
{
var poses = new[] { new NestJobPlacement("part", 0, 0, 0, 0) };
var candidate = new PlateCandidate(poses);
var priced = Job(Stock("small", 10, size: 5), Stock("large", 12, size: 10));
var options = new NestJobOptions(salvageRate: 1, minimumSalvageDimension: 1);
var pricedJob = new NestJob(priced.Parts, priced.Plates, options);
Assert.True(new NestJobCandidateComparer(pricedJob).Compare(candidate, priced.Plates[1], 1,
candidate, priced.Plates[0], 0) > 0);
var unpriced = new NestJob(priced.Parts, new[] { new NestPlateStock("small", new Size(5, 5)),
new NestPlateStock("large", new Size(10, 10)) }, options);
Assert.True(new NestJobCandidateComparer(unpriced).Compare(candidate, unpriced.Plates[0], 0,
candidate, unpriced.Plates[1], 1) > 0);
}
[Theory]
[InlineData(0, 20, 0)]
[InlineData(0.5, 14, 6)]
public void SummarySeparatesGrossCreditAndNet(double rate, double net, double credit)
{
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(4, 4));
var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20);
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, new[] { stock },
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 6)));
Assert.Empty(result.Violations);
var summary = NestCostSummary.FromAccepted(result);
Assert.Equal("supplied-cost", summary.Basis);
Assert.Equal(20, summary.GrossTotal);
Assert.Equal(net, summary.NetScore, 10);
Assert.Equal(credit, summary.SalvageCredit, 10);
Assert.Equal(1, Assert.Single(summary.Stock).Used);
}
[Theory]
[MemberData(nameof(Engines))]
public void PricesReverseSelectionWithoutChangingFulfillment(string engine, double factor)
{
foreach (var cheap in new[] { "small", "large" })
{
var job = Job(Stock("small", (cheap == "small" ? 1 : 10) * factor),
Stock("large", (cheap == "large" ? 1 : 10) * factor, size: 12));
var result = NestingEngineRegistry.Create(engine).Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Assert.Equal(cheap, Assert.Single(result.Plates).StockId);
Assert.Equal(factor, NestJobCost.Evaluate(job, result));
}
}
[Theory]
[InlineData("Rectangles")]
[InlineData("Irregular")]
[InlineData("StockLadder")]
[InlineData("Default")]
[InlineData("Fill")]
public void SameSizeStockRetainsIndependentFiniteInventory(string engine)
{
var stocks = new[] { Stock("expensive", 10, 2, 5), Stock("cheap", 1, 1, 5) };
var job = new NestJob(new[] { new NestJobPart("part",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 3) }, stocks);
var result = NestingEngineRegistry.Create(engine).Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Assert.Equal("cheap", result.Plates[0].StockId);
Assert.Single(result.Plates.Where(p => p.StockId == "cheap"));
Assert.Equal(21, NestJobCost.Evaluate(job, result));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void ExplicitCostsMustBeFiniteAndPositive(double cost) =>
Assert.Throws<ArgumentOutOfRangeException>(() => Stock("bad", cost));
[Fact]
public void MixedModeRejectsOnlyAvailableStock()
{
Assert.Throws<ArgumentException>(() => Job(Stock("priced", 2), new NestPlateStock("missing", new Size(10, 10))));
var job = Job(Stock("priced", 2), new NestPlateStock("unavailable", new Size(10, 10), 0));
NestJobValidator.Validate(job);
Assert.Equal(2, NestJobCost.UnplacedPartPenalty(job));
}
[Theory]
[InlineData(0, 5, 20)]
[InlineData(0.5, 0, 20)]
[InlineData(0.5, 7, 20)]
[InlineData(0.5, 6, 14)]
public void PricesCreditSalvageProportionally(double rate, double minimum, double expected)
{
var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20);
var job = new NestJob(Job(stock).Parts, new[] { stock }, new NestJobOptions(salvageRate: rate, minimumSalvageDimension: minimum));
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) });
Assert.Equal(expected, NestJobCost.NetSheetCost(job, sheet), 10);
Assert.Equal(expected, NestJobCost.NetSheetCost(job.Options, stock, new Box(0, 0, 4, 4)), 10);
Assert.Equal(expected * 5, NestJobCost.NetSheetArea(job, sheet), 10);
}
[Fact]
public void AggregateOverflowFailsClearly()
{
var stock = Stock("huge", double.MaxValue);
var job = new NestJob(new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 2) }, new[] { stock });
var builder = new NestJobResultBuilder(job);
builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) });
builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) });
Assert.Throws<OverflowException>(() => NestJobCost.Evaluate(job, builder.Build(NestJobStopReason.Completed)));
}
[Fact]
public void BuilderMapsLegacyZeroAndSnapshotsPositiveCosts()
{
var option = new PlateOption { Width = 10, Length = 10 };
Assert.Null(Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option })).Cost);
option.Cost = 12;
var stock = Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option }));
option.Cost = 50;
Assert.Equal(12, stock.Cost);
Assert.Throws<ArgumentException>(() => NestStockBuilder.FromTemplate(new Plate(10, 10),
new[] { option, new PlateOption { Width = 12, Length = 12 } }));
}
}
@@ -28,7 +28,7 @@ internal static class BestEffortCuttingPlan
var done = new HashSet<int>();
var position = snapshot.StartPoint;
var distance = 0.0;
var checker = new ReleasedContourState();
var checker = new ReleasedContourState(reportMissingLeadIns: false);
try
{
while (order.Count < snapshot.Placements.Count)
@@ -31,8 +31,8 @@ public sealed record ContourFeasibilityVerdict(ContourFeasibilityStatus Status,
/// candidate contour through the S06 diagnostic seam, reads it at the placement position,
/// 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
/// contour's emitted leads only — the missing-lead (NoLeadIn) check still runs later on the
/// complete plan, and rapids/pierce clearance belong to their existing checkers. A
/// contour's emitted leads only — NoLeadIn is supported, and the complete plan must
/// still pass rapid travel and contour-accounting checks. A
/// candidate rejected here is not proven infeasible by anything else: this adapter only
/// reports what the validator reported.
/// One instance is one captured planning attempt: verdicts cache per exact choice and node
@@ -43,6 +43,10 @@ public sealed class CuttingPlanBatch
/// </summary>
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 CuttingParameters ownedParameters;
@@ -142,7 +146,7 @@ public sealed class CuttingPlanBatch
if (entry.Reorder != null)
{
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)
{
plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap);
@@ -156,6 +160,22 @@ public sealed class CuttingPlanBatch
return new(plans, ownedParameters);
}
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)
@@ -216,6 +236,10 @@ public sealed class CuttingPlanPlateResult
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>
/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance.
/// </summary>
@@ -243,6 +267,35 @@ public sealed class CuttingPlanProposal
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>
/// 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
@@ -296,7 +349,7 @@ public sealed class CuttingPlanProposal
public IReadOnlyList<string> Describe(string unit)
{
var lines = new List<string>();
var ready = Plates.Count(p => p.IsReady);
var blocked = Plates.Count(p => !p.CanApplyWithWarnings);
if (IsCancelled)
lines.Add("Planning was cancelled. Nothing has changed.");
else if (CanApply)
@@ -307,7 +360,7 @@ public sealed class CuttingPlanProposal
lines.Add("Best-effort plan available. Review the warnings and accept the unverified plan to apply. "
+ "This is not approval to cut or post CNC output.");
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.");
foreach (var plate in Plates)
@@ -93,6 +93,13 @@ public sealed class CuttingPlanSnapshot
public int ExpansionBudget { get; }
internal CuttingPlanStatus? Failure { 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
@@ -241,7 +241,7 @@ public static class CuttingPlanService
if (placement.Prepared != null)
continue; // An eligible old crossing is precisely what regeneration may repair.
// 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);
fixedFindings.AddRange(Map(snapshot, findings));
}
@@ -279,7 +279,7 @@ public static class CuttingPlanService
if (snapshot.Dependencies.FirstViolation(order) is { } violation)
return new(CuttingPlanStatus.InvalidInput,
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
var checker = new ReleasedContourState();
var checker = new ReleasedContourState(reportMissingLeadIns: false);
var position = snapshot.StartPoint;
var distance = 0.0;
var findings = new List<PostVerificationFinding>();
@@ -313,7 +313,7 @@ public static class CuttingPlanService
if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation)
return new(CuttingPlanStatus.InvalidInput,
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
var checker = new ReleasedContourState();
var checker = new ReleasedContourState(reportMissingLeadIns: false);
var position = snapshot.StartPoint;
var distance = 0.0;
var findings = new List<CuttingPlanFinding>();
@@ -16,7 +16,7 @@ internal static class FixedProgramSearch
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
{
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 expansions = 0;
while (stack.Count != 0)
@@ -191,34 +191,44 @@ internal static class JointCuttingPlanSearch
/// </summary>
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 progressExpansions = Expansions;
var stack = new Stack<Frame>();
stack.Push(new(root));
while (stack.Count != 0)
try
{
token.ThrowIfCancellationRequested();
var frame = stack.Peek();
var node = frame.Node;
if (node.Order.Length == snapshot.Placements.Count)
while (stack.Count != 0)
{
attempt.Order = node.Order;
return attempt;
token.ThrowIfCancellationRequested();
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))
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
return attempt;
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly);
if (frame.Next == frame.Children.Length)
{
stack.Pop();
continue;
}
stack.Push(new(frame.Children[frame.Next++].Node));
return attempt;
}
finally
{
// Ready, stalled, cancelled and budget-exhausted searches can all leave
// suspended siblings. Release their captured prefixes on every exit.
foreach (var frame in stack)
frame.Children?.Dispose();
}
return attempt;
}
/// <summary>
@@ -259,8 +269,14 @@ internal static class JointCuttingPlanSearch
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
/// </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();
if (edges.Count <= 1)
return edges.ToArray();
@@ -334,8 +350,10 @@ internal static class JointCuttingPlanSearch
// First try one ranked hole chain per endpoint. A later-part failure then
// changes the endpoint before replaying all earlier hole combinations.
// 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)
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1);
contours = contours.Take(1);
foreach (var contour in contours)
{
token.ThrowIfCancellationRequested();
@@ -593,7 +611,6 @@ internal static class JointCuttingPlanSearch
private sealed class Frame(Node node)
{
internal Node Node { get; } = node;
internal Edge[] Children { get; set; }
internal int Next { get; set; }
internal IEnumerator<Edge> Children { get; set; }
}
}
@@ -22,20 +22,24 @@ namespace OpenNest.Engine.Jobs.Cutouts;
/// </summary>
/// <remarks>
/// 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
/// any engine or pipeline yet: placing parts in cutouts waits on containment-aware cutting order.
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Used by the
/// internal pipeline cutout preview, but not enabled for normal whole-job calls;
/// production placement waits on cutting/post safety.
/// </remarks>
internal static class CutoutLatticeFill
{
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
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>
private const double Margin = FlattenTolerance + 0.001;
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,
/// or none when no copy fits.</summary>
@@ -45,9 +49,17 @@ internal static class CutoutLatticeFill
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
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(insert);
ArgumentNullException.ThrowIfNull(occupied);
token.ThrowIfCancellationRequested();
ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps);
if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0)
return Array.Empty<NestJobPlacement>();
@@ -69,6 +81,10 @@ internal static class CutoutLatticeFill
var bounds = cutout.BoundingBox;
var insertBounds = insertGeometry.Bounds;
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);
if (lattice.Count == 0)
@@ -83,7 +99,8 @@ internal static class CutoutLatticeFill
{
token.ThrowIfCancellationRequested();
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))
best = (count, i, j);
}
@@ -91,13 +108,33 @@ internal static class CutoutLatticeFill
return Array.Empty<NestJobPlacement>();
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),
System.Math.Round(p.Y + sy, 8), p.Rotation))
.OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation)
.ToList();
return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity)
.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
@@ -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
/// never meets the cutout boundary (t outside boundary + reflected outline).
/// </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();
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>
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 inside = false;
@@ -218,7 +255,7 @@ internal static class CutoutLatticeFill
return inside;
}
private static PathD Positive(PathD path)
internal static PathD Positive(PathD path)
{
if (path.Count >= 3 && !Clipper.IsPositive(path))
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);
Plates = Own(plates);
Options = options ?? new NestJobOptions();
NestJobValidator.ValidateStockCosts(Plates);
if (
Parts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Parts.Count
|| Plates.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Plates.Count
@@ -4,10 +4,13 @@ using System.Linq;
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
{
private readonly IReadOnlyList<NestJobPart> parts;
private readonly NestJob job;
public NestJobCandidateComparer(NestJob job) : this(job.Parts) => this.job = job;
public NestJobCandidateComparer(IReadOnlyList<NestJobPart> parts)
{
@@ -36,7 +39,7 @@ public sealed class NestJobCandidateComparer
return leftCount.CompareTo(rightCount);
}
var area = Area(rightStock).CompareTo(Area(leftStock));
var area = Score(right, rightStock).CompareTo(Score(left, leftStock));
if (area != 0)
return area;
@@ -52,7 +55,10 @@ public sealed class NestJobCandidateComparer
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)
{
+26 -6
View File
@@ -1,10 +1,11 @@
using System;
using System.Linq;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
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
{
/// <summary>
@@ -66,16 +67,35 @@ public static class NestJobCost
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) =>
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>
/// 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.
/// </summary>
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)
- 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 parts = job.Parts.ToDictionary(part => part.Id, 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 =
job.Parts.Count == 0
? null
+11 -1
View File
@@ -10,11 +10,13 @@ public static class NestJobValidator
public static void Validate(NestJob job)
{
ArgumentNullException.ThrowIfNull(job);
ValidateStockCosts(job.Plates);
foreach (var stock in job.Plates)
{
var edges = stock.EdgeSpacing;
if (
!Positive(stock.Size.Width)
!Positive(stock.Area)
|| !Positive(stock.Size.Width)
|| !Positive(stock.Size.Length)
|| !Nonnegative(stock.PartSpacing)
|| !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(
PlateCandidate candidate,
NestPlateStock stock,
+2 -1
View File
@@ -231,7 +231,8 @@ public static class NestLayoutCheck
private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) =>
ReferenceEquals(expected, actual)
|| (
expected.Size.Equals(actual.Size)
expected.Cost == actual.Cost
&& expected.Size.Equals(actual.Size)
&& expected.PartSpacing.IsEqualTo(actual.PartSpacing)
&& expected.EdgeSpacing.Left.IsEqualTo(actual.EdgeSpacing.Left)
&& expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right)
+102 -4
View File
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Cutouts;
namespace OpenNest.Engine.Jobs;
@@ -36,7 +37,8 @@ public sealed class NestPipelineResult
IReadOnlyList<string> violations,
bool canKeep,
TimeSpan solveTime,
TimeSpan validationTime
TimeSpan validationTime,
IReadOnlyDictionary<string, Drawing> drawingsByPartId
)
{
EngineName = engineName;
@@ -47,6 +49,7 @@ public sealed class NestPipelineResult
CanKeep = canKeep;
SolveTime = solveTime;
ValidationTime = validationTime;
DrawingsByPartId = drawingsByPartId;
}
public string EngineName { get; }
@@ -62,6 +65,7 @@ public sealed class NestPipelineResult
public NestJobStopReason StopReason => Raw.StopReason;
public TimeSpan SolveTime { get; }
public TimeSpan ValidationTime { get; }
internal IReadOnlyDictionary<string, Drawing> DrawingsByPartId { get; }
}
/// <summary>
@@ -72,6 +76,15 @@ public sealed class NestPipelineResult
/// </summary>
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
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
/// Engine cancellation propagates unchanged.</summary>
@@ -93,6 +106,32 @@ public static class NestPipeline
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
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);
@@ -115,10 +154,15 @@ public static class NestPipeline
var job = new NestJob(parts, request.Stock, request.Options);
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 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.");
var solveTime = clock.Elapsed;
token.ThrowIfCancellationRequested();
@@ -129,8 +173,36 @@ public static class NestPipeline
kv => kv.Value.Name ?? kv.Key,
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;
if (canKeep && violations.Count == 0)
_ = NestJobCost.Evaluate(job, raw);
var plates = canKeep
? raw.Plates.Select(sheet => new ProposedPlate(
@@ -141,6 +213,31 @@ public static class NestPipeline
.ToList()
: 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();
return new NestPipelineResult(
engineName,
@@ -150,7 +247,8 @@ public static class NestPipeline
violations,
canKeep,
solveTime,
validationTime
validationTime,
drawingsByPartId
);
}
}
@@ -20,6 +20,11 @@ public static class NestPipelineCommit
token.ThrowIfCancellationRequested();
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.");
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>();
// 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,
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);
if (quantity < 0)
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 Size Size { get; }
+3 -1
View File
@@ -29,9 +29,11 @@ public static class NestStockBuilder
quantityPerOption,
template.PartSpacing,
template.EdgeSpacing,
template.Quadrant
template.Quadrant,
option.Cost == 0 ? null : option.Cost
));
}
NestJobValidator.ValidateStockCosts(stock);
return stock.AsReadOnly();
}
@@ -9,7 +9,7 @@ namespace OpenNest.Engine.Jobs;
/// <summary>
/// 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.
/// </summary>
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.
foreach (var part in job.Parts)
foreach (var stock in job.Plates.Where(s => s.Quantity != 0))
{
var probe = Trial(stock, new[] { WithQuantity(part, 1) });
if (probe.Placements.Count != 0)
feasible[part.Id].Add(stock.Id);
}
foreach (var stock in job.Plates.Where(s => s.Quantity != 0))
{
var probe = Trial(stock, new[] { WithQuantity(part, 1) });
if (probe.Placements.Count != 0)
feasible[part.Id].Add(stock.Id);
}
var ordered = job
.Parts.OrderBy(p => p.Priority)
.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
// compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill.
var value =
NestJobCost.NetSheetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]);
if (value < score - 1e-9)
NestJobCost.RequireFinite(NestJobCost.NetSheetCost(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]));
if (value < score - (stock.Cost.HasValue ? 0 : 1e-9))
{
winner = sheet;
score = value;
@@ -182,55 +182,55 @@ public sealed class StockLadderNestingEngine : INestingEngine
var changed = false;
// Single downgrade and adjacent pair merge only: bounded local search, no combinatorial tree.
for (var index = 0; index < sheets.Count; index++)
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)
for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--)
{
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 =
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)
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());
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;
var trial = new NestJobPlateResult(index, stock, candidate.Placements);
var cost = NestJobCost.NetSheetArea(job, trial);
if (cost >= baseline - 1e-9)
continue;
baseline = cost;
replacement = trial;
// 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;
}
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;
}
}
@@ -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)
{
@@ -109,7 +109,7 @@ public sealed class DefaultNestingEngine : INestingEngine
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.
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)
@@ -118,6 +118,8 @@ public sealed class DefaultNestingEngine : INestingEngine
return Valid;
if (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)));
if (double.IsFinite(Cost) && double.IsFinite(other.Cost) && System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
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
/// 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
/// 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
/// 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;
if (unplaced > 0 && reason == NestJobStopReason.Completed)
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>
@@ -138,7 +138,7 @@ public sealed class IrregularNestingEngine : INestingEngine
var prefix = sheets.Take(sheets.Count - k).ToList();
var tail = sheets.Skip(sheets.Count - k).ToList();
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];
foreach (var part in tail.SelectMany(s => s.Parts))
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;
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 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 fill = packer.Fill(remaining, token);
if (fill.Parts.Count > 0)
trials.Add((fill, NetArea(job.Options, fill)));
trials.Add((fill, NetCost(job.Options, fill)));
}
if (trials.Count == 0)
@@ -260,14 +260,14 @@ public sealed class IrregularNestingEngine : INestingEngine
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 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)
.ThenByDescending(t => t.Fill.Parts.Count)
.ThenBy(t => t.order)
.First();
sheets.Add(chosen.Fill);
net += chosen.Net;
net = NestJobCost.RequireFinite(net + chosen.Net);
used[chosen.Fill.Stock.Id]++;
foreach (var part in chosen.Fill.Parts)
remaining[part.Orientation.TypeIndex]--;
@@ -289,21 +289,23 @@ public sealed class IrregularNestingEngine : INestingEngine
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 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));
}
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)
{
if (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));
if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
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
/// 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
/// remaining demand priced at the best net-area-per-part-area ratio seen among the candidates.
/// the lowest estimated whole-job cost wins: its salvage-credited net cost (NestJobCost) plus the
/// 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.
/// </summary>
public sealed class RectanglesNestingEngine : INestingEngine
@@ -78,7 +78,7 @@ public sealed class RectanglesNestingEngine : INestingEngine
{
var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token);
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>
/// 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.
/// </summary>
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));
return trials
.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))
.ThenBy(t => t.Estimate)
.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;
}
private static double NetArea(NestJob job, SheetPlan plan) =>
private static double NetCost(NestJob job, SheetPlan plan) =>
plan.Envelope is { } envelope
? NestJobCost.NetSheetArea(job.Options, plan.Stock, envelope)
: plan.Stock.Area;
? NestJobCost.NetSheetCost(job.Options, plan.Stock, envelope)
: NestJobCost.GrossSheetCost(plan.Stock);
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)
{
if (index < 0)
return null;
if (Nest != null && index < Nest.Plates.Count)
return Nest.Plates[index];
@@ -27,6 +30,38 @@ namespace OpenNest.Mcp
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)
{
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
)
{
var nest = new Nest();
nest.Name = name ?? Path.GetFileNameWithoutExtension(path);
var source = _session.Nest;
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())
nest.Drawings.Add(drawing);
+247 -9
View File
@@ -10,6 +10,7 @@ using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Mcp.Tools
{
@@ -257,10 +258,7 @@ namespace OpenNest.Mcp.Tools
sb.AppendLine($" Total parts: {countAfter}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
// Breakdown by drawing
var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name);
foreach (var group in groups)
sb.AppendLine($" {group.Key}: {group.Count()}");
AppendMixSummary(sb, items, parts);
return sb.ToString();
}
@@ -333,11 +331,235 @@ namespace OpenNest.Mcp.Tools
sb.AppendLine($" Parts placed: {totalPlaced}");
sb.AppendLine($" Total parts: {plate.Parts.Count}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
foreach (var group in plate.Parts.GroupBy(p => p.BaseDrawing.Name))
sb.AppendLine($" {group.Key}: {group.Count()}");
AppendMixSummary(sb, parsed.items, proposed == null ? Enumerable.Empty<Part>() : proposed.Parts);
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>
/// Resolves the requested fill strategy; an omitted name means Fill. Returns null when the
/// name is not a single-plate placement strategy.
@@ -376,6 +598,8 @@ namespace OpenNest.Mcp.Tools
{
if (!int.TryParse(qtyStrings[i], out qtys[i]))
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)
@@ -385,14 +609,28 @@ namespace OpenNest.Mcp.Tools
);
var items = new List<NestItem>();
var seen = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
for (var i = 0; i < names.Length; i++)
{
var drawing = _session.GetDrawing(names[i]);
if (drawing == null)
var matches = _session.AllDrawings().Where(d => d.Name == names[i]).ToArray();
if (matches.Length == 0)
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);
+16
View File
@@ -58,6 +58,22 @@ namespace OpenNest.Mcp.Tools
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")]
[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)
+17 -85
View File
@@ -25,7 +25,6 @@ public static class NestPdfWriter
private const double PortraitWidth = 540;
private const double LandscapeWidth = 720;
private const double OverviewHeight = 320;
private const double DetailHeight = 400;
private static readonly XColor Fill = XColor.FromArgb(222, 222, 222);
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.
var plans = snapshot.Plates.Select(plate =>
NestReportDiagram.Plan(plate, LandscapeWidth, OverviewHeight, DetailHeight)).ToList();
NestReportDiagram.Plan(plate, LandscapeWidth, OverviewHeight)).ToList();
var document = new Document();
document.Info.Title = snapshot.Name;
@@ -131,7 +130,7 @@ public static class NestPdfWriter
else
{
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)
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));
@@ -158,7 +157,7 @@ public static class NestPdfWriter
}
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 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.Bold = true;
var info = AddTable(section, 80, 280, 90, 270);
var info = AddTable(section, 100, 260, 90, 270);
info.Borders.Visible = false;
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:",
$"{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)");
var overview = AddReservedArea(section, null, plan.Overview.Size.Height);
areas.Add((overview, (gfx, _) => DrawView(gfx, plate, plan.Overview)));
var overview = AddReservedArea(section, plan.Size.Height);
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.");
legend.Format.Font.Size = 7.5;
legend.Format.SpaceBefore = Unit.FromPoint(4);
if (plan.Overview.Grid is { } grid)
{
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 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 approval = section.AddParagraph("This report is not a geometry or CNC approval.");
approval.Format.Font.Size = 7.5;
approval.Format.SpaceAfter = Unit.FromPoint(4);
@@ -204,36 +196,23 @@ public static class NestPdfWriter
var perSheet = plate.Parts.GroupBy(part => part.ReportId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => (long)group.Count(), StringComparer.Ordinal);
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)))
{
var count = perSheet[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));
}
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>
private static Table AddReservedArea(Section section, string? caption, double height)
private static Table AddReservedArea(Section section, double height)
{
var table = AddTable(section, LandscapeWidth);
table.Borders.Visible = false;
table.LeftPadding = table.RightPadding = Unit.Zero;
table.TopPadding = table.BottomPadding = Unit.Zero;
table.Format.SpaceBefore = Unit.FromPoint(4);
if (caption != null)
{
var row = AddRow(table, "Detail caption", true, caption);
row.KeepWith = 1;
}
var reserved = table.AddRow();
reserved.HeightRule = RowHeightRule.Exactly;
reserved.Height = Unit.FromPoint(height);
@@ -276,14 +255,6 @@ public static class NestPdfWriter
private static void DrawView(XGraphics gfx, ReportPlate plate, DiagramView view)
{
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));
var outline = new XPen(XColors.Black, 0.6);
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] };
foreach (var geometry in plate.Cutoffs)
StrokeContours(gfx, geometry, fit, cutoff);
// Long dash-dot, so detail cells are never confused with dashed scrap cutoffs.
var detail = new XPen(XColors.Black, 1.6) { DashPattern = [9, 3, 1, 3] };
if (view.Grid is { } overviewGrid)
{
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 groupPen = new XPen(XColors.Black, 0.6) { DashPattern = [1, 3] };
foreach (var group in view.Groups)
gfx.DrawRectangle(groupPen, group.Boundary);
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.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)
@@ -532,7 +464,7 @@ public static class NestPdfWriter
snapshot.Thickness > 0 ? $"{Number(snapshot.Thickness)} {snapshot.Units}" : "Not specified";
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);
+8 -2
View File
@@ -21,13 +21,19 @@ public static class NestReportBuilder
{
if (byReference.TryGetValue(drawing, out var 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);
ordered.Add(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 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>
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>
internal sealed record DiagramGrid(ReportBounds Union, double CellLength, double CellWidth, int Columns, int Rows,
IReadOnlyList<(int Row, int Column)> DetailCells)
{
public string Name(int row, int column) => ReportText.RowName(row) + (column + 1).ToString(System.Globalization.CultureInfo.InvariantCulture);
/// <summary>A dotted group boundary and its shared ID, in canvas coordinates.</summary>
internal sealed record DiagramGroup(IReadOnlyList<int> Parts, XRect Boundary, DiagramLabel Label);
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,
DiagramGrid? Grid, string? Cell, ReportBounds? CellBounds);
IReadOnlyList<DiagramGroup> Groups);
internal sealed record PlateDiagramPlan(DiagramView Overview, IReadOnlyList<DiagramView> Details);
/// <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>
/// <summary>Plans best-effort labels on a single plate view; small repeated parts may share a label.</summary>
internal static class NestReportDiagram
{
internal const double LabelFontSize = 7;
internal const int MaxDetailViews = 24;
private const double LabelPadding = 1;
// Minimum white space between a label and any edge, hole, cutoff or other label.
private const double Clearance = 1.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()
{
@@ -58,100 +35,100 @@ internal static class NestReportDiagram
internal static double PolePrecision(ReportBounds bounds) =>
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 obstacles = shapes.Concat(plate.Cutoffs.Select(cutoff => new Shape(cutoff))).ToList();
var union = Union(plate, obstacles);
var font = LabelFont();
var all = Enumerable.Range(0, plate.Parts.Length).ToList();
var canvas = new XSize(width, overviewHeight);
var overviewView = new XRect(0, 0, width, overviewHeight);
var canvas = new XSize(width, height);
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));
var labels = PlaceLabels(plate, shapes, obstacles, all, fit, overviewView, font, out var failed);
if (failed.Count == 0)
return new PlateDiagramPlan(new DiagramView(canvas, fit, labels, null, null, null), []);
// Reserve map-grid gutters, then re-place what still fits at overview scale.
fit = Fit.Create(union, new XRect(GutterLeft, GutterTop, width - GutterLeft - Inset, overviewHeight - GutterTop - Inset));
labels = PlaceLabels(plate, shapes, obstacles, all, fit, overviewView, font, out failed);
var failedSet = failed.ToHashSet();
var required = failed.Max(index => RequiredScale(plate, index, font));
var inner = new XRect(Inset, Inset, width - 2 * Inset, detailHeight - 2 * Inset);
var legibleGrid = false;
foreach (var zoom in Zooms)
private static List<DiagramGroup> PlaceGroups(ReportPlate plate, List<int> failed,
List<DiagramLabel> labels, Fit fit, XRect view, XFont font)
{
var boxes = plate.Parts.Select(part => fit.Rect(part.Geometry.Bounds)).ToList();
var cutoffs = plate.Cutoffs.Select(cutoff => fit.Rect(cutoff.Bounds)).ToList();
var groups = new List<DiagramGroup>();
// A small screen-space gap also accommodates tightly packed parts with unset spacing.
var gap = System.Math.Max(4, plate.PartSpacing * fit.Scale * 2);
var remaining = failed.ToHashSet();
foreach (var seed in failed)
{
var scale = required * zoom;
// 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))
if (!remaining.Remove(seed))
continue;
legibleGrid = true;
(int Row, int Column) CellOf(int index) => CellIndex(plate, index, union, columns, rows);
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 id = plate.Parts[seed].ReportId;
var members = new List<int> { seed };
for (var cursor = 0; cursor < members.Count; cursor++)
{
var cell = grid.Cell(row, column);
var marginX = cellLength * DetailMargin;
var marginY = cellWidth * DetailMargin;
var view = new ReportBounds(cell.Left - marginX, cell.Bottom - marginY, cell.Right + marginX, cell.Top + marginY);
var detailFit = Fit.Create(view, inner);
// Parts missing from the overview claim label space first.
var members = all.Where(index => CellOf(index) == (row, column))
.OrderBy(index => failedSet.Contains(index) ? 0 : 1).ToList();
var detailLabels = PlaceLabels(plate, shapes, obstacles, members, detailFit,
new XRect(0, 0, width, detailHeight), font, out var missed);
unplaced.AddRange(missed.Where(failedSet.Contains));
details.Add(new DiagramView(new XSize(width, detailHeight), detailFit, detailLabels, null,
grid.Name(row, column), cell));
var nearby = boxes[members[cursor]];
nearby.Inflate(gap, gap);
foreach (var candidate in failed)
{
if (remaining.Contains(candidate) && plate.Parts[candidate].ReportId == id
&& nearby.IntersectsWith(boxes[candidate]))
{
remaining.Remove(candidate);
members.Add(candidate);
}
}
}
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);
}
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;
return groups;
}
/// <summary>
/// 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
/// touch an edge, hole, cutoff or another label is not moved elsewhere: the part fails here
/// and gets a zoomed detail view instead.
/// touch an edge, hole, cutoff or another label is not moved elsewhere: the individual label is skipped here
/// and may share a group label or remain unlabeled.
/// </summary>
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)
@@ -178,20 +155,6 @@ internal static class NestReportDiagram
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)
{
var left = plate.Bounds.Left;
+46 -1
View File
@@ -4,14 +4,59 @@ using System.Linq;
using System.Threading.Tasks;
using OpenNest.Api;
using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Engine.Jobs;
namespace OpenNest.Tests.Api;
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]
public async Task RunAsync_LegacySheetSize_UsesUnlimitedLegacyStockAndDerivedPartId()
{
@@ -126,7 +126,7 @@ public class ContourEntryFeasibilityTests
// 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
// 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.
Assert.True(verdict.IsClear);
var motions = ExecutionMotionReader.Read(prepared.Emit(new[] { choice }), At, null, default).Motions;
@@ -45,7 +45,9 @@ public class CuttingPlanBatchTests
var nest = new Nest();
var ready = Plate(nest, Clean("open", 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 readyProgram = ready.Parts[0].Program;
@@ -90,6 +92,10 @@ public class CuttingPlanBatchTests
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("- 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(programs, plate.Parts.Select(p => p.Program));
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
@@ -144,6 +150,63 @@ public class CuttingPlanBatchTests
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]
public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo()
{
@@ -253,7 +316,9 @@ public class CuttingPlanBatchTests
var nest = new Nest();
var ready = Plate(nest, Clean("a", 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 proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(), false).Plan();
Assert.True(proposal.Plates[0].IsReady);
@@ -8,77 +8,91 @@ namespace OpenNest.Tests.CuttingPlanning;
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]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[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 plate = new Nest().CreatePlate();
plate.Size = new Size(100, 100);
plate.Parts.Add(part);
var program = part.Program;
var original = OwnedProgramCopy.Copy(program);
var plate = BestEffortCuttingPlanTests.Plate(part);
var original = OwnedProgramCopy.Copy(part.Program);
var settings = new CuttingParameters();
if (zeroLength)
settings.ExternalLeadIn = new LineLeadIn { Length = 0, ApproachAngle = 90 };
var proposal = CuttingPlanBatch.Capture([plate], settings, keepOrder).Plan();
Assert.False(proposal.CanApply);
var result = Assert.Single(proposal.Plates).Result;
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.MissingLeadIn);
var text = string.Join("\n", proposal.Describe("in"));
Assert.Contains("Plate 1: blocked: missing or zero-length lead-in.", text);
Assert.Contains("Open Cutting Settings...", text);
Assert.Contains("other than None", text);
Assert.Contains("nonzero length", text);
Assert.Contains("Part 1 (sample):", text);
Assert.Contains("Cutting contour 1", text);
Assert.DoesNotContain("search limit", text);
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
Assert.Same(program, part.Program);
Assert.True(ProgramContent.Equal(original, part.Program));
Assert.Null(plate.CuttingParameters);
Assert.False(part.HasManualLeadIns);
if (zeroLength)
{
Assert.False(proposal.CanApply);
Assert.Contains("Lead motion is missing, degenerate or inconsistent", string.Join("\n", proposal.Describe("in")));
Assert.True(ProgramContent.Equal(original, part.Program));
return;
}
// Choosing valid settings fixes the refusal; reporting never changes settings or bypasses checks.
var ready = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), keepOrder).Plan();
Assert.True(ready.CanApply, string.Join("\n", ready.Describe("in")));
Assert.True(Assert.Single(ready.Plates).Result.IndependentlyReplayed);
Assert.DoesNotContain("missing or zero-length", string.Join("\n", ready.Describe("in")));
Assert.Same(program, part.Program);
Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
var result = Assert.Single(proposal.Plates).Result;
Assert.True(result.IndependentlyReplayed);
Assert.DoesNotContain(result.Findings, f => f.Kind == PostVerificationKind.MissingLeadIn);
Assert.Contains("Plate 1: ready.", string.Join("\n", proposal.Describe("in")));
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.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status);
}
[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))
{
LeadInsLocked = true,
};
var plate = new Nest().CreatePlate();
plate.Size = new Size(100, 100);
plate.Parts.Add(part);
var plate = BestEffortCuttingPlanTests.Plate(part);
var program = part.Program;
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
Assert.Equal(CuttingPlanStatus.ConstraintConflict, Assert.Single(proposal.Plates).Result.Status);
Assert.False(proposal.CanApply);
var text = string.Join("\n", proposal.Describe("in"));
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(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
Assert.False(Assert.Single(proposal.Plates[0].Result.ProposedOrder).IsRegenerated);
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status);
Assert.True(part.LeadInsLocked);
Assert.Same(program, part.Program);
}
[Fact]
public void Describe_LeadHitsNeighbour_SuggestsSpacingOrShorterLeadWithoutAllowingApply()
{
@@ -126,9 +126,24 @@ public class CuttingPlanServiceTests
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]
[InlineData("suppressed")]
[InlineData("missing-lead")]
[InlineData("retention-unknown")]
[InlineData("cutoff")]
public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault)
@@ -138,10 +153,6 @@ public class CuttingPlanServiceTests
switch (fault)
{
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 "cutoff": part.BaseDrawing.IsCutOff = true; break;
}
@@ -4,11 +4,55 @@ using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.CuttingPlanning;
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]
public void JointSearch_RepairsActualCompletedHoleCrossing_WithExactReplayAndNoMutation()
{
@@ -46,7 +90,6 @@ public class JointCuttingPlanTests
[Theory]
[InlineData("locked")]
[InlineData("ineligible")]
[InlineData("no-valid-entry")]
[InlineData("unsupported")]
public void JointSearch_RefusesWithoutInstallingFallbackOrMutating(string fault)
{
@@ -55,12 +98,6 @@ public class JointCuttingPlanTests
var expected = CuttingPlanStatus.ConstraintConflict;
if (fault == "locked") part.LeadInsLocked = true;
if (fault == "ineligible") eligible = [];
if (fault == "no-valid-entry")
{
parameters.ArcCircleLeadIn = new NoLeadIn();
parameters.InternalLeadIn = parameters.ArcCircleLeadIn;
expected = CuttingPlanStatus.NoSolutionWithinBudget;
}
if (fault == "unsupported")
{
part.BaseDrawing.Program.Codes.AddRange(LeadPathValidationTests.Rectangle(30, 30, 40, 40).Codes);
@@ -418,7 +455,7 @@ public class JointCuttingPlanTests
var manual = part.HasManualLeadIns;
var bounds = part.BoundingBox;
var quantity = part.BaseDrawing.Quantity.Nested;
var length = ((LineLeadIn)parameters.ExternalLeadIn).Length;
var settings = CuttingParametersSerializer.Serialize(parameters);
return () =>
{
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.Same(bounds, part.BoundingBox); Assert.Same(parameters, part.CuttingParameters);
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)
{
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 _));
}
[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)
{
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.
}
[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]
public void FreeOrder_BlockedApproach_LearnsToCutThatPartFirst()
{
+25
View File
@@ -59,6 +59,31 @@ public class NestStorageSettingsTests : IDisposable
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()
{
if (Directory.Exists(_directory))
@@ -36,6 +36,9 @@ public class PostVerificationAnalyzerTests
var report = Analyze(Cutoff(30, 30), a, Scribe(40, 40), b);
var overlap = Assert.Single(Find(report, PostVerificationKind.Overlap));
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.False(report.CanPost(false));
Assert.True(report.CanPost(true));
@@ -58,7 +61,8 @@ public class PostVerificationAnalyzerTests
{
var part = Rectangle(0, 0, 4);
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]
@@ -105,8 +109,12 @@ public class PostVerificationAnalyzerTests
public void Rapid_ThroughPreviouslyCutPartWarns()
{
var report = Analyze(Rectangle(0, 0, 4), Scribe(-2, 2), Scribe(6, 2));
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f =>
f.PartNumber == 3 && f.OtherPartNumber == 1);
var crossing = Assert.Single(Find(report, PostVerificationKind.RapidCrossing).Where(f =>
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]
@@ -169,8 +177,11 @@ public class PostVerificationAnalyzerTests
public void Rapid_CutoffTravelStillCrossesEarlierRealCuts()
{
var report = Analyze(Rectangle(0, 0, 4), Scribe(-2, 2), Cutoff(6, 2));
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f =>
f.PartNumber == 3 && f.OtherPartNumber == 1);
var crossing = Assert.Single(Find(report, PostVerificationKind.RapidCrossing).Where(f =>
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]
@@ -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);
}
}
+96 -83
View File
@@ -32,12 +32,12 @@ public sealed class NestPdfLayoutTests : IDisposable
var summary = pages[0];
Assert.Matches(@"Distinct layouts:\s+4", 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.
Assert.Matches(@"R001\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(@"R003\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(@"3\s+60 x 30 in\s+1\s+2", summary);
// 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(@"1\s+Bracket\s+5\s+8\s+0\s+3\s+1-2, 4", summary);
Assert.Matches(@"2\s+Rotated\s+1\s+3\s+0\s+2\s+1, 3", summary);
Assert.Matches(@"3\s+Bracket\s+7\s+3\s+4\s+0\s+1, 3", summary);
Assert.Matches(@"4\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summary);
Assert.Matches(@"3\s+30 x 60 in\s+1\s+2", summary);
for (var plate = 1; plate <= 4; plate++)
{
var page = pages[plate];
@@ -45,10 +45,10 @@ public sealed class NestPdfLayoutTests : IDisposable
Assert.Contains($"Page {plate + 1} of 5", page);
Assert.Contains("Nest report: Report test job", page);
}
Assert.Matches(@"R001\s+Bracket\s+1\s+3", pages[2]);
Assert.Matches(@"R002\s+Rotated\s+1\s+1", pages[3]);
Assert.Matches(@"R003\s+Bracket\s+1\s+1", pages[3]);
Assert.DoesNotContain("R001", pages[3]);
Assert.Matches(@"1\s+Bracket\s+1\s+3", pages[2]);
Assert.Matches(@"2\s+Rotated\s+1\s+1", pages[3]);
Assert.Matches(@"3\s+Bracket\s+1\s+1", pages[3]);
Assert.DoesNotMatch(@"(?m)^1\s+Bracket", pages[3]);
}
[SkippableFact]
@@ -72,23 +72,23 @@ public sealed class NestPdfLayoutTests : IDisposable
var summaryText = string.Join("\n", summaryPages);
// Every row exactly once; the Parts heading row repeats on every continuation page.
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));
// Placements first (R004-R043 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(@"R073\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);
// Placements first (4-43 on plate 1), then unplaced demand by ordinal name.
Assert.Matches(@"43\s+Demand 39\s+40\s+2\s+38\s+0\s+1", summaryText);
Assert.Matches(@"73\s+Demand 69\s+70\s+0\s+70\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();
Assert.True(platePages.Count >= 2, $"plate 1 pages: {platePages.Count}");
Assert.All(platePages, page => Assert.Contains("Nest report: Report test job", page));
// 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.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);
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);
for (var page = 1; page <= total; page++)
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.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.
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"));
Assert.True(summaryPage >= 0 && platePage > summaryPage);
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.
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.Contains("millimeters (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);
var stream = ReportPdf.ContentStreams(pdf.Pages[1]).Last();
stream = stream[(stream.IndexOf("W* n", StringComparison.Ordinal) + 4)..];
@@ -164,56 +164,73 @@ public sealed class NestPdfLayoutTests : IDisposable
}
[SkippableFact]
public void Write_TinyRepeatedPartsGetMapGridDetailViewsWithEveryIdLegible()
public void Write_TinyRepeatedPartsShareOneDottedGroupOnThePlatePage()
{
var nest = NestReportTestData.CreateDenseNest();
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320, 400);
Assert.NotNull(plan.Overview.Grid);
Assert.NotEmpty(plan.Details);
// Every placement is labeled at full size somewhere; none are dropped.
var labeled = plan.Overview.Labels.Select(label => label.Part)
.Concat(plan.Details.SelectMany(detail => detail.Labels.Select(label => label.Part))).ToHashSet();
Assert.Equal(Enumerable.Range(0, snapshot.Plates[0].Parts.Length), labeled.Order());
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320);
var group = Assert.Single(plan.Groups);
Assert.Equal("2", group.Label.Id);
Assert.Equal(Enumerable.Range(2, 24), group.Parts.Order());
Assert.Equal(2, plan.Labels.Count);
Assert.DoesNotContain(plan.Labels, label => label.Id == "2");
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();
Assert.Equal(NestReportDiagram.LabelFontSize, font.Size);
foreach (var view in plan.Details.Prepend(plan.Overview))
foreach (var label in labels)
{
foreach (var label in view.Labels)
{
Assert.True(label.Box.Width >= ReportText.Size(label.Id, font).Width);
Assert.True(label.Box.X >= 0 && label.Box.Right <= view.Size.Width && label.Box.Y >= 0 && label.Box.Bottom <= view.Size.Height);
Assert.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");
}
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.DoesNotContain(labels, other => other != label && other.Box.IntersectsWith(label.Box));
}
var path = Write(nest);
var pages = ReportPdf.Pages(path);
var plateText = string.Join("\n", pages.Skip(1));
Assert.Contains("cells outlined dash-dot have detail views", plateText);
foreach (var detail in plan.Details)
Assert.Contains($"Plate 1 detail {detail.Cell}:", plateText);
var washerLabels = pages.Skip(1).Sum(page => Regex.Matches(page, @"\bR002\b").Count);
// 24 diagram labels plus one table row.
Assert.True(washerLabels >= 25, $"R002 occurrences: {washerLabels}");
Assert.Equal(2, pages.Length);
Assert.Contains("A dotted outline groups like parts under one ID", pages[1]);
Assert.DoesNotContain("detail", pages[1]);
using var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import);
var diagram = ReportPdf.ContentStreams(pdf.Pages[1]).Last();
Assert.Matches(@"\[0\.6 1\.8\]\s*0\s+d", diagram);
}
[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]
public void Write_LabelOnHoledPartAvoidsTheHole()
{
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt);
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320, 400);
Assert.Empty(plan.Details);
var label = plan.Overview.Labels.First(label => label.Part == 0);
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320);
var label = plan.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 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));
Assert.False(label.Box.IntersectsWith(holeBox));
}
@@ -235,10 +252,9 @@ public sealed class NestPdfLayoutTests : IDisposable
nest.Plates.Add(plate);
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.Overview.Labels)
foreach (var label in plan.Labels)
{
var geometry = snapshot.Plates[0].Parts[label.Part].Geometry;
var origin = new Vector(geometry.Bounds.Left, geometry.Bounds.Bottom);
@@ -248,14 +264,14 @@ public sealed class NestPdfLayoutTests : IDisposable
foreach (var ring in rings)
ring.Offset(-origin.X, -origin.Y);
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.
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.Y + label.Box.Height / 2 - expected.Y, -tolerance, tolerance);
}
var l = plan.Overview.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 l = plan.Labels.Single(label => label.Part == 0);
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.
Assert.InRange(center.X - 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.
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateDenseNest(), NestReportTestData.GeneratedAt);
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320, 400);
foreach (var view in plan.Details.Prepend(plan.Overview))
var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320);
var offsets = plan.Labels.GroupBy(label => label.Id).SelectMany(group =>
{
var offsets = view.Labels.GroupBy(label => label.Id).SelectMany(group =>
var relative = group.Select(label =>
{
var relative = group.Select(label =>
{
var bounds = view.Fit.Rect(snapshot.Plates[0].Parts[label.Part].Geometry.Bounds);
return (X: label.Box.X - bounds.X, Y: label.Box.Y - bounds.Y);
}).ToList();
return relative.Select(offset => (offset.X - relative[0].X, offset.Y - relative[0].Y));
});
Assert.All(offsets, delta =>
{
Assert.InRange(delta.Item1, -0.01, 0.01);
Assert.InRange(delta.Item2, -0.01, 0.01);
});
}
Assert.Equal(24, plan.Details.Sum(detail => detail.Labels.Count(label => label.Id == "R002")));
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);
}).ToList();
return relative.Select(offset => (offset.X - relative[0].X, offset.Y - relative[0].Y));
});
Assert.All(offsets, delta =>
{
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"));
}
private static OpenNest.Geometry.Shape ToShape(ReportContour contour)
@@ -401,14 +414,14 @@ public sealed class NestPdfLayoutTests : IDisposable
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)
{
var words = ReportPdf.Words(path, page);
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 above = words.First(word => word.Text == "R001" && word.Top > heading.Top);
var below = words.First(word => word.Text == "R003" && word.Top > above.Top);
var above = words.First(word => word.Text == "1" && word.Left < heading.Left && word.Top > heading.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
&& word.Top > above.Bottom && word.Bottom < below.Top)
.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.Matches(@"Distinct layouts:\s+1", 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(@"R001\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(@"R003\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(@"1\s+24 x 48 in\s+2\s+4\s+\d+\.\d%", summary);
Assert.Matches(@"1\s+Bracket\s+5\s+4\s+1\s+0\s+1", summary);
Assert.Matches(@"2\s+Rotated\s+1\s+2\s+0\s+1\s+1", summary);
Assert.Matches(@"3\s+Bracket\s+7\s+2\s+5\s+0\s+1", summary);
Assert.Matches(@"4\s+Unplaced\s+3\s+0\s+3\s+0\s+-", 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("Report test job", plate);
Assert.Matches(@"Copies:\s+2", plate);
Assert.Matches(@"R001\s+Bracket\s+2\s+4", plate);
Assert.Matches(@"R002\s+Rotated\s+1\s+2", plate);
Assert.Matches(@"R003\s+Bracket\s+1\s+2", plate);
Assert.DoesNotContain("R004", plate);
Assert.Matches(@"Quantity:\s+2", plate);
Assert.Matches(@"1\s+Bracket\s+2\s+4", plate);
Assert.Matches(@"2\s+Rotated\s+1\s+2", plate);
Assert.Matches(@"3\s+Bracket\s+1\s+2", plate);
Assert.DoesNotMatch(@"(?m)^4\s+Unplaced", plate);
Assert.DoesNotContain("Cutoff test", plate + summary);
Assert.Contains("not a dimensioned cutting drawing", plate);
Assert.Contains("not a geometry or CNC approval", plate);
@@ -75,6 +79,7 @@ public sealed class NestPdfWriterTests : IDisposable
public void Write_EmptyAndDemandOnlyJobsProduceExplicitSummaryOnlyReports()
{
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);
Assert.Contains("No plates 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));
var demand = ReportPdf.Text(WriteReport(nest, "demand.pdf"), 1);
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);
}
[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]
public void Write_TabbedLeadInPartStrokesEachOpenContourSeparately()
{
@@ -128,19 +148,25 @@ public sealed class NestPdfWriterTests : IDisposable
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]
[InlineData("illegible-label", "Plate 4, part 2 (R004)")]
[InlineData("cell-overflow", "Drawing R002")]
[InlineData("cell-overflow", "Drawing 2")]
[InlineData("header-overflow", "Page header")]
public void Write_UnsupportedLayoutFailsBeforeReplacingDestination(string scenario, string identified)
{
var nest = NestReportTestData.CreateMultiPlateNest();
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":
// 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));
@@ -174,7 +200,7 @@ public sealed class NestPdfWriterTests : IDisposable
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.Equal("keep", File.ReadAllText(path));
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
@@ -22,7 +22,7 @@ public class NestReportBuilderTests
Assert.Equal(0.125, snapshot.Thickness);
Assert.Equal("in", snapshot.Units);
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 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));
@@ -31,7 +31,7 @@ public class NestReportBuilderTests
Assert.All(snapshot.Drawings.Take(3), d => Assert.Equal(new[] { 1 }, d.Plates));
Assert.Empty(snapshot.Drawings[3].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.Equal(2, snapshot.TotalSheets);
Assert.Equal(new ReportBounds(0, 0, 48, 24), plate.Bounds);
@@ -49,8 +49,8 @@ public static class NestReportTestData
}
/// <summary>
/// Four layouts: "Bracket" R001 on plates 1, 2 and 4; R002 on plates 1 and 3; the distinct
/// same-named "Bracket" R003 on plates 1 and 3; R004 demanded but unplaced.
/// Four layouts: "Bracket" 1 on plates 1, 2 and 4; 2 on plates 1 and 3; the distinct
/// same-named "Bracket" 3 on plates 1 and 3; 4 demanded but unplaced.
/// </summary>
public static Nest CreateMultiPlateNest()
{
@@ -73,7 +73,7 @@ public static class NestReportTestData
/// <summary>
/// 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>
public static Nest CreateDenseNest()
{
@@ -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);
}
}
@@ -10,6 +10,94 @@ namespace OpenNest.WinForms.Tests.Forms;
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]
[InlineData(false)]
[InlineData(true)]
@@ -32,7 +120,7 @@ public class AutoNestFormStockOptionsTests
Assert.True(rows.AllowNew);
var added = rows.AddNew()!;
SetValue(added, "Size", "84 x 168");
SetValue(added, "Cost", 123.5);
SetValue(added, "Cost", loadSavedOptions ? 123.5 : 0);
((ICancelAddNew)rows).EndNew(rows.Count - 1);
var options = form.GetPlateOptions();
@@ -40,7 +128,7 @@ public class AutoNestFormStockOptionsTests
var option = options.Last();
Assert.Equal(84, option.Width);
Assert.Equal(168, option.Length);
Assert.Equal(123.5, option.Cost);
Assert.Equal(loadSavedOptions ? 123.5 : 0, option.Cost);
AssertNewRow(grid);
});
}
@@ -98,6 +186,7 @@ public class AutoNestFormStockOptionsTests
var rows = Assert.IsAssignableFrom<IBindingList>(grid.DataSource);
var row = rows.AddNew()!;
SetValue(row, "Size", invalid);
SetValue(row, "Cost", 25);
((ICancelAddNew)rows).EndNew(rows.Count - 1);
Assert.False(form.TryGetPlateOptions(out var options, out var error));
@@ -49,7 +49,9 @@ public class CuttingPlanFormTests
public void BlockedPlate_KeepsApplyDisabledAndShowsTheCurrentParts() => RunSta(() =>
{
var locked = Square("locked", 1, 1);
locked.LeadInsLocked = true; // A locked program is never regenerated: no lead-in blocks it.
var crossing = (OpenNest.CNC.Program)locked.Program.Clone();
crossing.MoveTo(5, 5); // A locked crossing cannot be repaired by regeneration.
Assert.True(locked.RestoreLeadInProgram(crossing, true));
var (nest, view) = CreateView(locked);
using var editor = view;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
@@ -59,10 +61,12 @@ public class CuttingPlanFormTests
Assert.False(Control<Button>(form, "applyButton").Enabled);
var summary = Control<TextBox>(form, "summaryBox").Text;
Assert.Contains("Plate 1: blocked", summary);
Assert.Contains("Part 1 (locked)", summary);
var warnings = Control<DataGridView>(form, "warningGrid");
Assert.Contains(warnings.Rows.Cast<DataGridViewRow>(), row =>
row.Cells[3].Value?.ToString()?.Contains("Part 1 (locked)") == true);
// A refused plate is never previewed; the editor already shows its part numbers.
Assert.False(Field<PlateView>(form, "preview").Visible);
Assert.StartsWith("No preview", Control<Label>(form, "previewLabel").Text);
Assert.Contains("no current, usable preview", Control<Label>(form, "previewLabel").Text);
Assert.Null(view.Plate.CuttingParameters);
});
@@ -146,6 +150,7 @@ public class CuttingPlanFormTests
Assert.Equal(CuttingCommitStatus.Stale, form.CommitResult!.Status);
Assert.Contains("changed after planning", Control<TextBox>(form, "summaryBox").Text);
Assert.False(Control<Button>(form, "applyButton").Enabled);
Assert.Empty(Control<DataGridView>(form, "warningGrid").Rows.Cast<DataGridViewRow>());
Assert.Equal(programs, view.Plate.Parts.Select(part => part.Program));
Assert.Null(view.Plate.CuttingParameters);
@@ -184,7 +189,9 @@ public class CuttingPlanFormTests
first.Parts.Add(Square("open", 1, 1));
var second = nest.CreatePlate();
var locked = Square("locked", 1, 1);
locked.LeadInsLocked = true;
var crossing = (OpenNest.CNC.Program)locked.Program.Clone();
crossing.MoveTo(5, 5);
Assert.True(locked.RestoreLeadInProgram(crossing, true));
second.Parts.Add(locked);
nest.CreatePlate(); // An empty plate, like the editor's trailing plate.
using var view = new PlateView { Plate = first };
@@ -247,13 +254,23 @@ public class CuttingPlanFormTests
Assert.False(Control<Button>(form, "applyButton").Enabled);
Assert.True(Field<PlateView>(form, "preview").Visible);
Assert.Contains("UNVERIFIED", Control<Label>(form, "previewLabel").Text);
var grid = Control<DataGridView>(form, "warningGrid");
Assert.NotEmpty(grid.Rows.Cast<DataGridViewRow>());
Assert.Contains(grid.Rows.Cast<DataGridViewRow>(), row =>
row.Cells[3].Value?.ToString()?.Contains("Material boundaries") == true);
var partRow = grid.Rows.Cast<DataGridViewRow>().First(row => row.Cells[1].Value is int);
partRow.Selected = true;
Assert.Contains("affected part extent", Control<Label>(form, "previewLabel").Text);
Assert.True(Field<PlateView>(form, "preview").Visible);
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.Null(form.CommitResult);
Assert.False(part.HasManualLeadIns);
accept.Checked = true;
Assert.True(Control<Button>(form, "applyButton").Enabled);
Invoke(form, "PlanButton_Click", null, EventArgs.Empty);
Assert.Empty(grid.Rows.Cast<DataGridViewRow>()); // No stale warning targets while replanning.
WaitForPlan(form);
Assert.NotEmpty(grid.Rows.Cast<DataGridViewRow>());
Assert.False(accept.Checked);
Assert.False(Control<Button>(form, "applyButton").Enabled);
accept.Checked = true;
@@ -0,0 +1,302 @@
using System.Drawing;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Controls;
using OpenNest.Diagnostics;
using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Forms;
public class ValidateNestFormTests
{
[Fact]
public void RapidContactCrosshairIsDrawnAtContactAndClearedWithFinding()
{
RunSta(() =>
{
using var view = new PlateView();
view.ShowValidationFinding(new PostVerificationFinding(PostVerificationKind.RapidCrossing,
1, 2, 1, "crossing")
{
RapidStart = new Vector(-1, 0),
RapidEnd = new Vector(1, 0),
ContactPoints = Array.AsReadOnly(new[] { Vector.Zero })
});
using var bitmap = new Bitmap(120, 120);
using var graphics = Graphics.FromImage(bitmap);
graphics.TranslateTransform(60, 60);
graphics.Clear(Color.Black);
view.ValidationOverlay.Draw(graphics);
// Off the horizontal rapid, on the contact's vertical crosshair.
var pixel = bitmap.GetPixel(60, 65);
Assert.True(pixel.R > 150 && pixel.B > 50 && pixel.G < 100);
view.ClearValidationFinding();
graphics.Clear(Color.Black);
view.ValidationOverlay.Draw(graphics);
Assert.Equal(Color.Black.ToArgb(), bitmap.GetPixel(60, 65).ToArgb());
});
}
#pragma warning disable xUnit1031 // Completed tasks on a dedicated STA thread.
[Fact]
public void GridNavigatesMainViewAndRestoresViewportOnDisposal()
{
RunSta(() =>
{
var nest = new Nest("interactive inspection");
var program = new CNC.Program();
program.MoveTo(0, 0);
program.LineTo(5, 0);
program.LineTo(5, 5);
program.LineTo(0, 5);
program.LineTo(0, 0);
nest.CreatePlate().Parts.Add(new Part(new Drawing("first", program)));
var second = nest.CreatePlate();
second.Parts.Add(new Part(new Drawing("second", program)));
second.Parts.Add(new Part(second.Parts[0].BaseDrawing) { Location = new Vector(2, 2) });
using var editor = new EditNestForm(nest);
editor.Show();
editor.PlateManager.LoadAt(0);
var view = editor.PlateView;
view.ZoomToArea(-10, -10, 40, 40);
var viewport = view.CaptureViewport();
view.OverlapDisplay = OverlapDisplayMode.Centroids;
using (var form = new ValidateNestForm(editor,
(value, token) => Task.FromResult(PostVerificationAnalyzer.Analyze(value, token))))
{
form.Show(editor);
form.ValidateAsync().GetAwaiter().GetResult();
Assert.True(form.TopLevel);
Assert.False(form.Modal);
Assert.True(editor.Enabled);
Assert.True(view.Enabled);
Assert.True(view.InspectionOnly);
var grid = Find<DataGridView>(form, "warningGrid");
Assert.True(grid.ReadOnly);
Assert.Equal(DataGridViewSelectionMode.FullRowSelect, grid.SelectionMode);
var row = grid.Rows.Cast<DataGridViewRow>().Single(row =>
row.Tag is PostVerificationFinding { Kind: PostVerificationKind.Overlap });
grid.CurrentCell = row.Cells[0];
Assert.Same(second, view.Plate);
Assert.Equal(1, editor.PlateManager.CurrentIndex);
Assert.NotNull(view.ValidationOverlay.Finding.Overlap);
Assert.True(view.ValidationOverlay.IsAnimating);
using var bitmap = new Bitmap(view.Width, view.Height);
view.DrawToBitmap(bitmap, view.ClientRectangle);
Assert.Contains("centroid", Find<TextBox>(form, "reportBox").Text);
view.SelectAll();
var scale = view.ViewScale;
Invoke(view, "OnMouseWheel", new MouseEventArgs(MouseButtons.None, 0, 100, 100, 120));
Assert.True(view.ViewScale > scale);
var pan = view.CaptureViewport();
Invoke(view, "OnMouseMove", new MouseEventArgs(MouseButtons.Middle, 0, 120, 110, 0));
Assert.NotEqual(pan.Origin, view.CaptureViewport().Origin);
Invoke(view, "OnKeyDown", new KeyEventArgs(Keys.Delete));
Invoke(view, "ProcessDialogKey", Keys.Right);
Invoke(view, "OnMouseDown", new MouseEventArgs(MouseButtons.Middle, 1, 120, 110, 0));
Invoke(view, "OnMouseUp", new MouseEventArgs(MouseButtons.Middle, 1, 120, 110, 0));
Assert.Equal(2, second.Parts.Count);
Assert.All(second.Parts, part => Assert.Equal(0, part.Rotation));
}
Assert.Same(nest.Plates[0], view.Plate);
Assert.Equal(viewport, view.CaptureViewport());
Assert.Equal(OverlapDisplayMode.Centroids, view.OverlapDisplay);
Assert.Null(view.ValidationOverlay.Finding);
Assert.False(view.ValidationOverlay.IsAnimating);
Assert.False(view.InspectionOnly);
Assert.Equal(new Vector(2, 2), second.Parts[1].Location);
});
}
[Fact]
public void ModelessCancellationKeepsEditingLockedUntilWorkerStops()
{
RunSta(() =>
{
var pending = new TaskCompletionSource<PostVerificationReport>();
using var editor = new EditNestForm(new Nest("inspection"));
editor.Show();
using var form = new ValidateNestForm(editor, (_, _) => pending.Task);
form.Show(editor);
var validation = form.ValidateAsync();
Assert.False(validation.IsCompleted);
Assert.True(editor.Enabled);
Assert.True(editor.PlateView.Enabled);
Assert.True(editor.PlateView.InspectionOnly);
form.Close();
Assert.True(form.Visible);
Assert.True(editor.PlateView.InspectionOnly);
pending.SetCanceled();
var timeout = System.Diagnostics.Stopwatch.StartNew();
while (!form.IsDisposed && timeout.Elapsed < TimeSpan.FromSeconds(5))
Application.DoEvents();
Assert.True(form.IsDisposed);
Assert.False(editor.PlateView.InspectionOnly);
});
}
[Fact]
public void PlateSwitchClearsAnimation()
{
RunSta(() =>
{
using var view = new PlateView();
view.ShowValidationFinding(new PostVerificationFinding(PostVerificationKind.MissingLeadIn,
1, 1, null, "missing")
{ Location = new Vector(3, 4) });
Assert.True(view.ValidationOverlay.IsAnimating);
view.Plate = new Plate();
Assert.False(view.ValidationOverlay.IsAnimating);
Assert.Null(view.ValidationOverlay.Finding);
});
}
[Fact]
public void ChecksAllPlatesWithoutChangingProgramsOrPlacements()
{
RunSta(() =>
{
var nest = new Nest("PEP inspection");
var program = new CNC.Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(5, 0));
program.Codes.Add(new LinearMove(5, 5));
program.Codes.Add(new LinearMove(0, 5));
program.Codes.Add(new LinearMove(0, 0));
var first = new Part(new Drawing("square", program));
var second = new Part(first.BaseDrawing);
nest.CreatePlate().Parts.Add(first);
nest.CreatePlate().Parts.Add(second);
var firstProgram = first.Program;
var secondProgram = second.Program;
var firstCodes = firstProgram.Codes.ToArray();
var location = first.Location;
var rotation = first.Rotation;
using var form = new ValidateNestForm(nest,
(value, token) => Task.FromResult(PostVerificationAnalyzer.Analyze(value, token)));
form.ValidateAsync().GetAwaiter().GetResult();
var report = Find<TextBox>(form, "reportBox");
Assert.True(report.ReadOnly);
Assert.Contains("Plate 1, part 1", report.Text);
Assert.Contains("Plate 2, part 1", report.Text);
Assert.DoesNotContain("Post processor", report.Text);
Assert.Contains("Warnings found", Find<Label>(form, "summaryLabel").Text);
Assert.Same(first, nest.Plates[0].Parts[0]);
Assert.Same(second, nest.Plates[1].Parts[0]);
Assert.Same(firstProgram, first.Program);
Assert.Same(secondProgram, second.Program);
Assert.Equal(firstCodes, first.Program.Codes.ToArray());
Assert.Equal(location, first.Location);
Assert.Equal(rotation, first.Rotation);
Assert.Same(Find<Button>(form, "closeButton"), form.AcceptButton);
Assert.Same(form.AcceptButton, form.CancelButton);
Assert.Empty(form.Controls.Find("postButton", true));
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DistinguishesClearAndIncompleteChecks(bool incomplete)
{
RunSta(() =>
{
var nest = new Nest("inspection");
if (incomplete)
nest.CreatePlate().Parts.Add(new Part(new Drawing("invalid", new CNC.Program())));
using var form = new ValidateNestForm(nest,
(value, token) => Task.FromResult(PostVerificationAnalyzer.Analyze(value, token)));
form.ValidateAsync().GetAwaiter().GetResult();
Assert.Contains(incomplete ? "Validation incomplete" : "No warnings found",
Find<Label>(form, "summaryLabel").Text);
});
}
[Fact]
public void FailureIsReportedWithoutAnAllClear()
{
RunSta(() =>
{
using var form = new ValidateNestForm(new Nest("inspection"),
(_, _) => Task.FromException<PostVerificationReport>(new InvalidOperationException("test failure")));
form.ValidateAsync().GetAwaiter().GetResult();
Assert.Contains("Validation failed", Find<Label>(form, "summaryLabel").Text);
Assert.Contains("test failure", Find<TextBox>(form, "reportBox").Text);
Assert.Equal("Close", Find<Button>(form, "closeButton").Text);
});
}
[Theory]
[InlineData("Cancel")]
[InlineData("Escape")]
[InlineData("Close")]
public void RealModalCancellationKeepsOwnerDisabledUntilWorkerStops(string action)
{
RunSta(() =>
{
var pending = new TaskCompletionSource<PostVerificationReport>();
using var owner = new Form();
using var form = new ValidateNestForm(new Nest("inspection"), (_, _) => pending.Task);
Exception? failure = null;
owner.Show();
using var timeout = new System.Windows.Forms.Timer { Interval = 10000 };
timeout.Tick += (_, _) =>
{
failure ??= new TimeoutException("Modal cancellation did not finish.");
pending.TrySetCanceled();
form.Close();
};
form.Shown += (_, _) => form.BeginInvoke(new System.Action(() =>
{
try
{
Assert.False(IsWindowEnabled(owner.Handle));
if (action == "Cancel")
Find<Button>(form, "closeButton").PerformClick();
else if (action == "Escape")
Invoke(form, "ProcessDialogKey", Keys.Escape);
else
form.Close();
// ShowDialog must stay active while the worker ignores cancellation.
Assert.True(form.Visible);
Assert.False(IsWindowEnabled(owner.Handle));
Assert.False(Find<Button>(form, "closeButton").Enabled);
}
catch (Exception ex)
{
failure = ex;
}
finally
{
pending.TrySetCanceled();
}
}));
timeout.Start();
var result = form.ShowDialog(owner);
timeout.Stop();
Assert.Equal(DialogResult.Cancel, result);
Assert.True(IsWindowEnabled(owner.Handle));
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
});
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool IsWindowEnabled(IntPtr window);
private static void Invoke(Control form, string method, params object?[] arguments) =>
form.GetType().GetMethod(method, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.Invoke(form, arguments);
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(30), "STA validation test timed out.");
private static T Find<T>(Form form, string name) where T : Control =>
Assert.IsType<T>(form.Controls.Find(name, true).Single());
}
@@ -1,11 +1,53 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using OpenNest.Controls;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests;
public class LayoutPartColorTests
{
[Fact]
public void ValidationMutesOnlyUnrelatedInstancesAndRestoresColors()
=> StaTestThread.Run(() =>
{
var program = new CNC.Program();
program.MoveTo(0, 0);
program.LineTo(40, 0);
program.LineTo(40, 40);
program.LineTo(0, 40);
program.LineTo(0, 0);
var drawing = new Drawing("shared drawing", program) { Color = Color.Coral };
using var view = new PlateView { Width = 240, Height = 100, DrawBounds = false };
view.Matrix = new Matrix();
for (var i = 0; i < 3; i++)
view.Plate.Parts.Add(new Part(drawing) { Location = new Vector(i * 60, 0) });
using var image = new Bitmap(240, 100);
using var graphics = Graphics.FromImage(image);
view.ShowValidationFinding(new PostVerificationFinding(PostVerificationKind.Overlap, 1, 1, 2, "overlap"));
view.LayoutParts[2].IsSelected = true;
view.Renderer.DrawParts(graphics);
Assert.Equal(Color.Coral.ToArgb(), image.GetPixel(10, 10).ToArgb());
Assert.Equal(Color.Coral.ToArgb(), image.GetPixel(70, 10).ToArgb());
Assert.Equal(Color.Gainsboro.ToArgb(), image.GetPixel(130, 10).ToArgb());
Assert.True(view.LayoutParts[2].IsSelected);
view.LayoutParts[2].IsSelected = false;
view.ShowValidationFinding(new PostVerificationFinding(PostVerificationKind.MissingLeadIn, 1, 3, null, "lead"));
view.Renderer.DrawParts(graphics);
Assert.Equal(Color.Gainsboro.ToArgb(), image.GetPixel(10, 10).ToArgb());
Assert.Equal(Color.Gainsboro.ToArgb(), image.GetPixel(70, 10).ToArgb());
Assert.Equal(Color.Coral.ToArgb(), image.GetPixel(130, 10).ToArgb());
view.ClearValidationFinding();
view.Renderer.DrawParts(graphics);
for (var i = 0; i < 3; i++)
Assert.Equal(Color.Coral.ToArgb(), image.GetPixel(i * 60 + 10, 10).ToArgb());
Assert.Equal(Color.Coral, drawing.Color);
Assert.All(view.LayoutParts, part => Assert.Equal(Color.Coral, part.Color));
}, TimeSpan.FromMinutes(1), "Validation color test timed out.");
[Theory]
[InlineData(false)]
[InlineData(true)]
+48 -116
View File
@@ -1,169 +1,101 @@
using System.Collections.Generic;
using System;
using System.ComponentModel;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.Converters;
using OpenNest.Forms;
using OpenNest.Geometry;
using OpenNest.Sequencing;
namespace OpenNest.Actions
{
[DisplayName("Set Sequence")]
public class ActionSetSequence : Action
{
private readonly SequenceForm SequenceForm;
private readonly List<Pair> ShapePartPairs;
private readonly Pen pen;
private readonly Forms.SequenceForm sequenceForm;
private readonly Pen pen = new Pen(Color.Blue, 2.0f);
private LayoutPart hoveredPart;
private bool disconnecting;
private Shape ClosestShape;
private Part ClosestPart;
private int sequenceNumber = 1;
public int SequenceNumber
{
get { return sequenceNumber; }
set
{
if (
value <= SequenceForm.numericUpDown1.Maximum
&& value >= SequenceForm.numericUpDown1.Minimum
)
{
sequenceNumber = value;
SequenceForm.numericUpDown1.Value = sequenceNumber;
}
}
}
// Escape ends this tool; it must not resurrect its closed window.
public override bool ResumeOnEscape => false;
public ActionSetSequence(PlateView plateView)
: base(plateView)
{
SequenceForm = new Forms.SequenceForm();
SequenceForm.numericUpDown1.DataBindings.Add(
"Value",
this,
"SequenceNumber",
false,
DataSourceUpdateMode.OnPropertyChanged
);
SequenceForm.numericUpDown1.Maximum = plateView.Plate.Parts.Count;
SequenceForm.Owner = Application.OpenForms[0];
SequenceForm.Show();
plateView.Invalidate();
pen = new Pen(Color.Blue, 2.0f);
ShapePartPairs = new List<Pair>();
foreach (var part in plateView.Plate.Parts)
sequenceForm = new Forms.SequenceForm();
sequenceForm.numericUpDown1.Maximum = System.Math.Max(1, plateView.Plate.Parts.Count);
sequenceForm.DoneClicked += (_, _) => plateView.EndAction();
sequenceForm.FormClosed += (_, _) =>
{
var entities = ConvertProgram
.ToGeometry(part.Program)
.Where(e => e.Layer == SpecialLayers.Cut)
.ToList();
entities.ForEach(entity => entity.Offset(part.Location));
var shapes = ShapeBuilder.GetShapes(entities);
var shape = new Shape();
shape.Entities.AddRange(shapes);
ShapePartPairs.Add(new Pair() { Part = part, Shape = shape });
}
if (!disconnecting)
plateView.EndAction();
};
sequenceForm.Show(plateView.FindForm());
plateView.MouseMove += plateView_MouseMove;
plateView.MouseLeave += plateView_MouseLeave;
plateView.MouseClick += plateView_MouseClick;
plateView.Paint += plateView_Paint;
SequenceNumber = 1;
}
private void plateView_MouseClick(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Right)
CancelAction();
{
plateView.EndAction();
return;
}
if (e.Button != MouseButtons.Left)
return;
if (SequenceNumber > plateView.Plate.Parts.Count)
SequenceNumber = plateView.Plate.Parts.Count;
if (ClosestPart == null)
// Hit-test at the click, not at the previous mouse-move location.
var hit = plateView.GetPartAtControlPoint(e.Location);
var part = hit?.BasePart;
if (part == null || !ManualPartSequencing.Move(plateView.Plate, part, (int)sequenceForm.numericUpDown1.Value))
return;
plateView.Plate.Parts.Remove(ClosestPart);
plateView.Plate.Parts.Insert(SequenceNumber - 1, ClosestPart);
SequenceNumber++;
if (sequenceForm.numericUpDown1.Value < sequenceForm.numericUpDown1.Maximum)
sequenceForm.numericUpDown1.Value++;
plateView.Invalidate();
}
private void plateView_MouseMove(object sender, MouseEventArgs e)
{
var pair = GetClosestShape();
ClosestShape = pair.HasValue ? pair.Value.Shape : null;
ClosestPart = pair.HasValue ? pair.Value.Part : null;
var hit = plateView.GetPartAtControlPoint(e.Location);
if (ReferenceEquals(hit, hoveredPart))
return;
hoveredPart = hit;
plateView.Invalidate();
}
private void plateView_MouseLeave(object sender, EventArgs e)
{
hoveredPart = null;
plateView.Invalidate();
}
private void plateView_Paint(object sender, PaintEventArgs e)
{
if (ClosestShape == null)
return;
var path = ClosestShape.GetGraphicsPath();
path.Transform(plateView.Matrix);
e.Graphics.DrawPath(pen, path);
path.Dispose();
}
private Pair? GetClosestShape()
{
Pair? closestShape = null;
double distance = double.MaxValue;
for (int i = 0; i < ShapePartPairs.Count; i++)
{
var shape = ShapePartPairs[i];
var closestPt = shape.Shape.ClosestPointTo(plateView.CurrentPoint);
var distance2 = closestPt.DistanceTo(plateView.CurrentPoint);
if (distance2 < distance)
{
closestShape = shape;
distance = distance2;
}
}
return closestShape;
var path = hoveredPart?.SelectionPath;
if (path != null)
e.Graphics.DrawPath(pen, path);
}
public override void DisconnectEvents()
{
disconnecting = true;
plateView.Paint -= plateView_Paint;
plateView.MouseMove -= plateView_MouseMove;
plateView.MouseLeave -= plateView_MouseLeave;
plateView.MouseClick -= plateView_MouseClick;
hoveredPart = null;
sequenceForm.Close();
sequenceForm.Dispose();
pen.Dispose();
plateView.Invalidate();
SequenceForm.Close();
}
public override void CancelAction()
{
SequenceNumber = 1;
SequenceForm.numericUpDown1.Value = SequenceNumber + 1;
}
public override void CancelAction() { }
public override bool IsBusy()
{
return false;
}
private struct Pair
{
public Part Part;
public Shape Shape;
}
public override bool IsBusy() => false;
}
}
+12 -10
View File
@@ -121,10 +121,12 @@ namespace OpenNest.Controls
if (!part.DisplayBounds.IntersectsWith(viewBounds))
continue;
part.Draw(g, (i + 1).ToString());
DrawBendLines(g, part.BasePart);
DrawEtchMarks(g, part.BasePart);
DrawGrainWarning(g, part.BasePart);
var finding = view.ValidationOverlay.Finding;
var muted = finding != null && finding.PartNumber != i + 1 && finding.OtherPartNumber != i + 1;
part.Draw(g, (i + 1).ToString(), muted);
DrawBendLines(g, part.BasePart, muted);
DrawEtchMarks(g, part.BasePart, muted);
DrawGrainWarning(g, part.BasePart, muted);
}
var previewParts = view.PreviewParts;
@@ -264,7 +266,7 @@ namespace OpenNest.Controls
}
}
private void DrawBendLines(Graphics g, Part part)
private void DrawBendLines(Graphics g, Part part, bool muted)
{
if (
!view.ShowBendLines
@@ -273,7 +275,7 @@ namespace OpenNest.Controls
)
return;
using var bendPen = new Pen(Color.Yellow, 1.5f)
using var bendPen = new Pen(muted ? Color.Gray : Color.Yellow, 1.5f)
{
DashStyle = System.Drawing.Drawing2D.DashStyle.Dash,
};
@@ -299,7 +301,7 @@ namespace OpenNest.Controls
}
}
private void DrawEtchMarks(Graphics g, Part part)
private void DrawEtchMarks(Graphics g, Part part, bool muted)
{
if (
!view.ShowBendLines
@@ -308,7 +310,7 @@ namespace OpenNest.Controls
)
return;
using var etchPen = new Pen(Color.Green, 1.5f);
using var etchPen = new Pen(muted ? Color.Gray : Color.Green, 1.5f);
var etchLength = 1.0;
foreach (var bend in part.BaseDrawing.Bends)
@@ -353,7 +355,7 @@ namespace OpenNest.Controls
}
}
private void DrawGrainWarning(Graphics g, Part part)
private void DrawGrainWarning(Graphics g, Part part, bool muted)
{
var plate = view.Plate;
if (
@@ -389,7 +391,7 @@ namespace OpenNest.Controls
var pt2 = view.PointWorldToGraph(
new Vector(location.X + box.Length, location.Y + box.Width)
);
using var warnPen = new Pen(Color.FromArgb(180, 255, 140, 0), 2f);
using var warnPen = new Pen(muted ? Color.Gray : Color.FromArgb(180, 255, 140, 0), 2f);
g.DrawRectangle(
warnPen,
pt1.X,
+59 -4
View File
@@ -25,6 +25,7 @@ namespace OpenNest.Controls
{
private readonly Timer redrawTimer;
private readonly OverlapOverlayController overlapOverlay;
private readonly ValidationOverlay validationOverlay;
private string status;
private Plate plate;
@@ -102,6 +103,7 @@ namespace OpenNest.Controls
public PlateView(ColorScheme colorScheme)
{
overlapOverlay = new OverlapOverlayController(this);
validationOverlay = new ValidationOverlay(this);
Plate = new Plate(60, 120);
origin = new PointF();
parts = new List<LayoutPart>();
@@ -151,6 +153,8 @@ namespace OpenNest.Controls
public ColorScheme ColorScheme { get; set; }
internal bool InspectionOnly { get; set; }
public bool AllowZoom { get; set; }
public bool AllowSelect { get; set; }
@@ -218,6 +222,7 @@ namespace OpenNest.Controls
selection.Clear();
}
validationOverlay.Clear();
plate = p;
plate.PartAdded += plate_PartAdded;
plate.PartRemoved += plate_PartRemoved;
@@ -231,6 +236,25 @@ namespace OpenNest.Controls
}
internal OverlapOverlayController OverlapOverlay => overlapOverlay;
internal ValidationOverlay ValidationOverlay => validationOverlay;
internal (float Scale, PointF Origin) CaptureViewport() => (ViewScale, origin);
internal void RestoreViewport((float Scale, PointF Origin) viewport)
{
ViewScale = viewport.Scale;
origin = viewport.Origin;
UpdateMatrix();
Invalidate();
}
internal void ShowValidationFinding(PostVerificationFinding finding) => validationOverlay.Show(finding);
internal void ClearValidationFinding() => validationOverlay.Clear();
internal void CenterValidationPoint(Vector location, Point target)
{
var current = PointWorldToControl(location);
origin.X += target.X - current.X;
origin.Y += target.Y - current.Y;
UpdateMatrix();
Invalidate();
}
public OverlapCheckStatus OverlapStatus => overlapOverlay.Status;
public PlateOverlapReport OverlapReport => overlapOverlay.Report;
public bool IsOverlapCheckRunning => overlapOverlay.IsRunning;
@@ -317,6 +341,8 @@ namespace OpenNest.Controls
protected override void OnDragEnter(DragEventArgs drgevent)
{
if (InspectionOnly)
return;
if (drgevent.Data.GetData(typeof(Drawing)) != null)
drgevent.Effect = DragDropEffects.Copy;
else if (FilesDropped != null && drgevent.Data.GetDataPresent(DataFormats.FileDrop))
@@ -325,6 +351,8 @@ namespace OpenNest.Controls
protected override void OnDragDrop(DragEventArgs drgevent)
{
if (InspectionOnly)
return;
if (drgevent.Data.GetDataPresent(DataFormats.FileDrop))
{
FilesDropped?.Invoke(this, (string[])drgevent.Data.GetData(DataFormats.FileDrop));
@@ -351,6 +379,9 @@ namespace OpenNest.Controls
if (e.Button == MouseButtons.Middle)
middleMouseDownPoint = e.Location;
if (InspectionOnly)
return;
if (e.Button == MouseButtons.Left && actionManager.CurrentAction is ActionSelect)
{
var hitCutOff = cutOffHandler.TryStartDrag(CurrentPoint, 5.0 / ViewScale);
@@ -373,6 +404,8 @@ namespace OpenNest.Controls
protected override void OnMouseUp(MouseEventArgs e)
{
if (InspectionOnly)
return;
if (e.Button == MouseButtons.Middle && SelectedParts.Count > 0)
{
var dx = e.X - middleMouseDownPoint.X;
@@ -396,11 +429,12 @@ namespace OpenNest.Controls
protected override void OnMouseWheel(MouseEventArgs e)
{
base.OnMouseWheel(e);
if (!InspectionOnly)
base.OnMouseWheel(e);
var multiplier = System.Math.Abs(e.Delta / 120);
if (SelectedParts.Count > 0 && ((ModifierKeys & Keys.Shift) == Keys.Shift))
if (!InspectionOnly && SelectedParts.Count > 0 && ((ModifierKeys & Keys.Shift) == Keys.Shift))
{
var increment =
(ModifierKeys & Keys.Control) == Keys.Control
@@ -456,6 +490,9 @@ namespace OpenNest.Controls
lastPoint = e.Location;
if (InspectionOnly)
return;
if (cutOffHandler.IsDragging && selection.SelectedCutOffs.Count > 0)
{
cutOffHandler.UpdateDrag(CurrentPoint, selection.SelectedCutOffs[0]);
@@ -484,14 +521,21 @@ namespace OpenNest.Controls
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
base.OnMouseDoubleClick(e);
if (!InspectionOnly)
base.OnMouseDoubleClick(e);
if (e.Button == MouseButtons.Middle && SelectedParts.Count == 0)
if (e.Button == MouseButtons.Middle && (InspectionOnly || SelectedParts.Count == 0))
ZoomToFit();
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (InspectionOnly)
{
if (keyData == Keys.F)
ZoomToFit();
return true;
}
// Audited: DrawControl/PlateView, Action key handlers, EditNestForm,
// MainForm.ProcessCmdKey and menu shortcuts have no plain PgUp/PgDn
// binding. BestFitViewerForm owns these only in its separate dialog.
@@ -504,6 +548,12 @@ namespace OpenNest.Controls
protected override void OnKeyDown(KeyEventArgs e)
{
if (InspectionOnly)
{
if (e.KeyData == Keys.F)
ZoomToFit();
return;
}
switch (e.KeyCode)
{
case Keys.Delete:
@@ -529,6 +579,8 @@ namespace OpenNest.Controls
protected override bool ProcessDialogKey(Keys keyData)
{
if (InspectionOnly)
return true;
// Only handle TAB, RETURN, ESC, and ARROW KEYS here.
// All other keys can be handled in OnKeyDown method.
@@ -603,6 +655,7 @@ namespace OpenNest.Controls
renderer.DrawActiveWorkArea(e.Graphics);
renderer.DrawDebugRemnants(e.Graphics);
DrawOverlapOverlay(e.Graphics);
validationOverlay.Draw(e.Graphics);
base.OnPaint(e);
@@ -633,6 +686,7 @@ namespace OpenNest.Controls
{
ClearHover();
overlapOverlay.ReleaseHandle();
validationOverlay.Clear();
base.OnHandleDestroyed(e);
actionManager.Cleanup();
}
@@ -649,6 +703,7 @@ namespace OpenNest.Controls
plate.PartsReordered -= plate_PartsReordered;
}
overlapOverlay?.Dispose();
validationOverlay?.Dispose();
hoverTimer?.Dispose();
hoverTimer = null;
redrawTimer?.Dispose();
+129
View File
@@ -0,0 +1,129 @@
using System;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Controls;
/// <summary>Display-only findings, valid while the inspector keeps editing disabled.</summary>
internal sealed class ValidationOverlay : IDisposable
{
private readonly PlateView view;
private readonly Timer timer;
private readonly Stopwatch elapsed = new();
private PostVerificationFinding finding;
private GraphicsPath areas;
private float scale;
public ValidationOverlay(PlateView view)
{
this.view = view;
timer = new Timer { Interval = 30 };
timer.Tick += (_, _) => view.Invalidate();
}
internal bool IsAnimating => timer.Enabled;
internal PostVerificationFinding Finding => finding;
public void Show(PostVerificationFinding value)
{
Clear();
finding = value;
var canAnimate = value?.Kind == PostVerificationKind.RapidCrossing
? value.RapidStart is { } start && IsFinite(start) && value.RapidEnd is { } end && IsFinite(end)
: value?.Location is { } point && IsFinite(point);
if (canAnimate)
{
elapsed.Restart();
timer.Start();
}
view.Invalidate();
}
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
public void Clear()
{
timer.Stop();
elapsed.Reset();
finding = null;
areas?.Dispose();
areas = null;
view.Invalidate();
}
public void Draw(Graphics graphics)
{
if (finding == null)
return;
var state = graphics.Save();
try
{
graphics.SmoothingMode = SmoothingMode.AntiAlias;
if (finding.Overlap is { } overlap)
{
if (areas == null || scale != view.ViewScale)
{
areas?.Dispose();
areas = new GraphicsPath(FillMode.Winding);
foreach (var region in overlap.Regions)
areas.AddPolygon(region.Vertices.Select(view.PointWorldToGraph).ToArray());
scale = view.ViewScale;
}
using var brush = new SolidBrush(Color.FromArgb(100, 255, 0, 80));
graphics.FillPath(brush, areas);
}
var dpi = view.DeviceDpi / 96f;
if (finding.Kind == PostVerificationKind.RapidCrossing)
{
if (finding.RapidStart is { } start && IsFinite(start)
&& finding.RapidEnd is { } end && IsFinite(end))
{
var pulse = (1 + System.Math.Cos(elapsed.Elapsed.TotalSeconds * System.Math.PI * 2 / 1.2)) / 2;
using var rapid = new Pen(Color.FromArgb((int)(65 + 190 * pulse), Color.DarkOrange), 3 * dpi)
{ DashStyle = DashStyle.Dash };
graphics.DrawLine(rapid, view.PointWorldToGraph(start), view.PointWorldToGraph(end));
}
using var contactHalo = new Pen(Color.White, 5 * dpi);
using var contactPen = new Pen(Color.DeepPink, 2 * dpi);
foreach (var contact in finding.ContactPoints.Where(IsFinite))
{
var point = view.PointWorldToGraph(contact);
foreach (var pen in new[] { contactHalo, contactPen })
{
graphics.DrawEllipse(pen, point.X - 8 * dpi, point.Y - 8 * dpi, 16 * dpi, 16 * dpi);
graphics.DrawLine(pen, point.X - 12 * dpi, point.Y, point.X + 12 * dpi, point.Y);
graphics.DrawLine(pen, point.X, point.Y - 12 * dpi, point.X, point.Y + 12 * dpi);
}
}
return;
}
if (finding.Location is not { } location || !double.IsFinite(location.X) || !double.IsFinite(location.Y))
return;
var center = view.PointWorldToGraph(location);
var phase = timer.Enabled ? (float)(elapsed.Elapsed.TotalSeconds % 0.8 / 0.8) : 0;
var radius = (12 + 24 * phase) * dpi;
var alpha = (int)(255 * (1 - phase));
using var halo = new Pen(Color.FromArgb(alpha, Color.White), 5 * dpi);
using var ring = new Pen(Color.FromArgb(alpha, Color.DeepPink), 3 * dpi);
graphics.DrawEllipse(halo, center.X - radius, center.Y - radius, radius * 2, radius * 2);
graphics.DrawEllipse(ring, center.X - radius, center.Y - radius, radius * 2, radius * 2);
graphics.DrawLine(ring, center.X - 5 * dpi, center.Y, center.X + 5 * dpi, center.Y);
graphics.DrawLine(ring, center.X, center.Y - 5 * dpi, center.X, center.Y + 5 * dpi);
}
finally
{
graphics.Restore(state);
}
}
public void Dispose()
{
timer.Dispose();
areas?.Dispose();
}
}
+1 -1
View File
@@ -173,7 +173,7 @@ namespace OpenNest.Forms
this.plateGrid.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.plateGrid.Location = new System.Drawing.Point(10, 22);
this.plateGrid.Name = "plateGrid";
this.plateGrid.RowHeadersVisible = false;
this.plateGrid.RowHeadersVisible = true;
this.plateGrid.AutoGenerateColumns = false;
this.plateGrid.Size = new System.Drawing.Size(508, 130);
this.plateGrid.TabIndex = 1;
+27
View File
@@ -152,6 +152,7 @@ namespace OpenNest.Forms
{
DataPropertyName = "Cost",
HeaderText = "Cost",
ToolTipText = "Per physical sheet, in one common unit. Use zero on every row for area scoring, or positive costs on every row. Salvage credits cost proportionally; set salvage to zero for purchase totals.",
Width = 70,
AutoSizeMode = DataGridViewAutoSizeColumnMode.None,
}
@@ -235,6 +236,15 @@ namespace OpenNest.Forms
}
}
if (plateGrid.IsCurrentCellInEditMode && plateGrid.CurrentCell != null
&& plateGrid.Columns[plateGrid.CurrentCell.ColumnIndex].DataPropertyName == "Cost"
&& plateGrid.EditingControl != null
&& !ValidCost(plateGrid.EditingControl.Text))
{
error = $"Invalid stock cost in row {plateGrid.CurrentCell.RowIndex + 1}. Enter a finite positive cost, or zero on every row.";
return false;
}
bool committed;
try
{
@@ -269,6 +279,14 @@ namespace OpenNest.Forms
return false;
}
if (!double.IsFinite(item.Cost) || item.Cost < 0
|| validated.Count > 0 && (validated[0].Cost == 0) != (item.Cost == 0))
{
error = $"Invalid stock cost in row {index + 1}. Use finite positive costs on every active row, or zero on every row.";
if (index < plateGrid.Rows.Count)
plateGrid.Rows[index].ErrorText = error;
return false;
}
if (index < plateGrid.Rows.Count)
plateGrid.Rows[index].ErrorText = "";
validated.Add(new PlateOption { Width = width, Length = length, Cost = item.Cost });
@@ -378,8 +396,17 @@ namespace OpenNest.Forms
UpdateSummary();
}
private static bool ValidCost(string text) =>
double.TryParse(text, out var cost) && double.IsFinite(cost) && cost >= 0;
private void PlateGrid_CellValidating(object sender, DataGridViewCellValidatingEventArgs e)
{
if (plateGrid.Columns[e.ColumnIndex].DataPropertyName == "Cost")
{
e.Cancel = !plateGrid.Rows[e.RowIndex].IsNewRow && !ValidCost(e.FormattedValue?.ToString());
plateGrid.Rows[e.RowIndex].ErrorText = e.Cancel ? "Enter a finite positive cost, or zero on every row." : "";
return;
}
if (plateGrid.Columns[e.ColumnIndex].DataPropertyName != "Size")
return;
+147 -11
View File
@@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
@@ -25,6 +25,8 @@ public partial class CuttingPlanForm : Form
private readonly Func<bool> isOperationBusy;
private readonly string unit;
private readonly PlateView preview;
private readonly DataGridView warningGrid;
private bool populatingWarnings;
private readonly SynchronizationContext uiContext;
private readonly string previewText;
private CuttingParameters parameters;
@@ -57,11 +59,54 @@ public partial class CuttingPlanForm : Form
this.isOperationBusy = isOperationBusy;
unit = UnitsHelper.GetShortString(nest.Units);
InitializeComponent();
// Best-effort acceptance must be able to display every warning, even on large jobs.
StartPosition = FormStartPosition.Manual;
warningGrid = new DataGridView
{
Name = "warningGrid",
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
MultiSelect = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
AutoGenerateColumns = false,
RowHeadersVisible = false,
BackgroundColor = SystemColors.Window,
};
warningGrid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "Plate",
Width = 58,
SortMode = DataGridViewColumnSortMode.NotSortable
});
warningGrid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "Part",
Width = 58,
SortMode = DataGridViewColumnSortMode.NotSortable
});
warningGrid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "With",
Width = 58,
SortMode = DataGridViewColumnSortMode.NotSortable
});
warningGrid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "Warning / finding",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
SortMode = DataGridViewColumnSortMode.NotSortable
});
warningGrid.SelectionChanged += WarningGrid_SelectionChanged;
var findingsLayout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
findingsLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 30));
findingsLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 70));
summaryBox.Dock = DockStyle.Fill;
findingsLayout.Controls.Add(summaryBox, 0, 0);
findingsLayout.Controls.Add(warningGrid, 0, 1);
split.Panel2.Controls.Add(findingsLayout);
// Keep the batch outcome visible; every individual diagnostic lives in the grid.
summaryBox.MaxLength = int.MaxValue;
// Captured once: when Application.DoEvents ends the outermost message loop, WinForms
// uninstalls its ambient context, so progress and results must not depend on whichever
// context is current when planning starts.
uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
preview = new PlateView
@@ -118,6 +163,12 @@ public partial class CuttingPlanForm : Form
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
// Use the database browser's owner-monitor sizing, without maximizing a modal window.
var area = Screen.FromControl(Owner ?? this).WorkingArea;
var width = System.Math.Min(area.Width, System.Math.Max(MinimumSize.Width, area.Width * 9 / 10));
var height = System.Math.Min(area.Height, System.Math.Max(MinimumSize.Height, area.Height * 9 / 10));
Bounds = new Rectangle(area.Left + (area.Width - width) / 2,
area.Top + (area.Height - height) / 2, width, height);
// Min sizes and the splitter only fit once the container has its real size.
split.Panel1MinSize = 200;
split.Panel2MinSize = 220;
@@ -242,7 +293,26 @@ public partial class CuttingPlanForm : Form
private void ShowProposal(CuttingPlanProposal result)
{
proposal = result;
summaryBox.Text = string.Join(Environment.NewLine, result.Describe(unit));
summaryBox.Text = string.Join(Environment.NewLine, result.Describe(unit)
.Where(line => !line.TrimStart().StartsWith("- ", StringComparison.Ordinal)
&& !line.TrimStart().StartsWith("... and ", StringComparison.Ordinal)));
var warningRows = result.DiagnosticRows();
populatingWarnings = true;
try
{
warningGrid.Rows.Clear();
foreach (var row in warningRows)
{
var display = warningGrid.Rows.Add(row.PlateNumber, row.PartNumber, row.OtherPartNumber,
row.Message);
warningGrid.Rows[display].Tag = row;
}
warningGrid.ClearSelection();
}
finally
{
populatingWarnings = false;
}
acceptWarningsCheckBox.Checked = false;
acceptWarningsCheckBox.Visible = result.RequiresWarningAcceptance;
statusLabel.Text = result.CanApply ? "Review the plan, then apply it."
@@ -250,23 +320,89 @@ public partial class CuttingPlanForm : Form
: "Nothing can be applied.";
applyButton.Enabled = result.CanApply;
var index = Array.IndexOf(plates, activePlate);
var plate = index < 0 ? null : result.BuildPreview(index);
ShowPlate(index);
}
private void ShowPlate(int index)
{
preview.Visible = false;
if (proposal == null || index < 0 || index >= proposal.Plates.Count)
return;
var plate = proposal.BuildPreview(index);
if (plate == null)
{
if (index >= 0)
previewLabel.Text = "No preview: this plate's plan is not ready. Part numbers in the summary "
+ "match the editor.";
previewLabel.Text = $"Plate {plateNumbers[index]}: no current, usable preview. "
+ "Part numbers refer to the editor; no location can be certified here.";
return;
}
previewLabel.Text = result.Plates[index].IsReady ? previewText : "UNVERIFIED — " + previewText;
previewLabel.Text = proposal.Plates[index].IsReady
? $"Plate {plateNumbers[index]} — preview, numbered in proposed cutting order:"
: $"UNVERIFIED — Plate {plateNumbers[index]} — preview, numbered in proposed cutting order:";
preview.Plate = plate;
preview.Visible = true;
preview.ZoomToFit();
}
private void WarningGrid_SelectionChanged(object sender, EventArgs e)
{
if (populatingWarnings || proposal == null || warningGrid.SelectedRows.Count == 0)
return;
// SelectionChanged precedes CurrentCell's update; SelectedRows is the new selection.
if (warningGrid.SelectedRows[0].Tag is not CuttingPlanDiagnosticRow row)
return;
ShowPlate(row.PlateIndex);
if (!preview.Visible || row.PartNumber is not int partNumber)
{
if (preview.Visible)
previewLabel.Text += " No part location was provided for this warning.";
return;
}
var plan = proposal.Plates[row.PlateIndex].Result;
var matched = plan.ProposedOrder.Select((part, position) => (part, position))
.FirstOrDefault(item => item.part.SourceOrdinal == partNumber - 1);
if (matched.part == null)
{
previewLabel.Text += " This source part has no proposed preview location.";
return;
}
var bounds = preview.Plate.Parts[matched.position].BoundingBox;
if (row.OtherPartNumber is int otherNumber)
{
var other = plan.ProposedOrder.Select((part, position) => (part, position))
.FirstOrDefault(item => item.part.SourceOrdinal == otherNumber - 1);
if (other.part != null)
{
var second = preview.Plate.Parts[other.position].BoundingBox;
var left = System.Math.Min(bounds.Left, second.Left);
var bottom = System.Math.Min(bounds.Bottom, second.Bottom);
bounds = new OpenNest.Geometry.Box(left, bottom,
System.Math.Max(bounds.Right, second.Right) - left,
System.Math.Max(bounds.Top, second.Top) - bottom);
}
}
if (double.IsFinite(bounds.X) && double.IsFinite(bounds.Y)
&& double.IsFinite(bounds.Length) && double.IsFinite(bounds.Width)
&& bounds.Length > 0 && bounds.Width > 0)
{
var pad = System.Math.Max(0.5, System.Math.Max(bounds.Length, bounds.Width) * 0.12);
preview.ZoomToArea(bounds.X - pad, bounds.Y - pad,
bounds.Length + 2 * pad, bounds.Width + 2 * pad);
}
previewLabel.Text += " Showing affected part extent, not an exact warning point.";
}
private void ClearProposal()
{
proposal = null;
populatingWarnings = true;
try
{
warningGrid.Rows.Clear();
}
finally
{
populatingWarnings = false;
}
acceptWarningsCheckBox.Checked = false;
acceptWarningsCheckBox.Visible = false;
applyButton.Enabled = false;
+58 -13
View File
@@ -104,11 +104,11 @@ namespace OpenNest.Forms
plateInfoLabel = new Label
{
AutoSize = true,
AutoSize = false,
AutoEllipsis = true,
TextAlign = ContentAlignment.MiddleLeft,
Font = new Font("Segoe UI", 12f, FontStyle.Bold),
ForeColor = Color.FromArgb(120, 120, 120),
Dock = DockStyle.Left,
Padding = new Padding(4, 4, 4, 4),
};
@@ -126,10 +126,30 @@ namespace OpenNest.Forms
btnLastPlate = CreateNavButton(Resources.move_last);
btnLastPlate.Click += (s, e) => PlateManager.LoadLast();
// Panel that holds the nav buttons and centers itself in the header
var zoomOutButton = CreateNavButton(Resources.zoom_out);
zoomOutButton.Name = "btnZoomOut";
zoomOutButton.AccessibleName = "Zoom Out";
zoomOutButton.Click += (s, e) => ZoomPlateView(1.0f / 1.5f);
var zoomInButton = CreateNavButton(Resources.zoom_in);
zoomInButton.Name = "btnZoomIn";
zoomInButton.AccessibleName = "Zoom In";
zoomInButton.Click += (s, e) => ZoomPlateView(1.5f);
var zoomToFitButton = CreateNavButton(Resources.zoom_all);
zoomToFitButton.Name = "btnZoomToFit";
zoomToFitButton.AccessibleName = "Zoom To Fit";
zoomToFitButton.Click += (s, e) => PlateView.ZoomToFit();
var toolTip = new ToolTip(components);
toolTip.SetToolTip(zoomOutButton, "Zoom Out (Ctrl+Minus)");
toolTip.SetToolTip(zoomInButton, "Zoom In (Ctrl+Plus)");
toolTip.SetToolTip(zoomToFitButton, "Zoom To Fit (F)");
// Keep navigation and zoom controls together in the header.
var navPanel = new Panel
{
Width = btnSize.Width * 4,
Width = btnSize.Width * 7 + 8,
Height = btnSize.Height,
Anchor = AnchorStyles.None,
};
@@ -139,22 +159,47 @@ namespace OpenNest.Forms
btnNextPlate.Location = new Point(btnSize.Width * 2, 0);
btnLastPlate.Location = new Point(btnSize.Width * 3, 0);
zoomOutButton.Location = new Point(btnSize.Width * 4 + 8, 0);
zoomInButton.Location = new Point(btnSize.Width * 5 + 8, 0);
zoomToFitButton.Location = new Point(btnSize.Width * 6 + 8, 0);
var separator = new Panel
{
Location = new Point(btnSize.Width * 4 + 3, 4),
Size = new System.Drawing.Size(1, btnSize.Height - 8),
BackColor = SystemColors.ControlDark,
TabStop = false,
};
navPanel.Controls.AddRange(
new Control[] { btnFirstPlate, btnPreviousPlate, btnNextPlate, btnLastPlate }
new Control[]
{
btnFirstPlate, btnPreviousPlate, btnNextPlate, btnLastPlate,
separator, zoomOutButton, zoomInButton, zoomToFitButton
}
);
plateHeaderPanel.Controls.Add(navPanel);
plateHeaderPanel.Controls.Add(plateInfoLabel);
// Center the nav panel on resize
CenterNavPanel(navPanel);
plateHeaderPanel.Resize += (s, e) => CenterNavPanel(navPanel);
plateHeaderPanel.Controls.Add(navPanel);
plateHeaderPanel.Layout += (s, e) => CenterPlateHeaderControls(navPanel);
CenterPlateHeaderControls(navPanel);
}
private void CenterNavPanel(Panel navPanel)
private void CenterPlateHeaderControls(Panel navPanel)
{
navPanel.Left = (plateHeaderPanel.Width - navPanel.Width) / 2;
navPanel.Top = (plateHeaderPanel.Height - navPanel.Height) / 2;
navPanel.Left = (plateHeaderPanel.ClientSize.Width - navPanel.Width) / 2;
navPanel.Top = (plateHeaderPanel.ClientSize.Height - navPanel.Height) / 2;
// Reserve the space before the centered buttons for the plate label.
var labelLeft = plateHeaderPanel.Padding.Left;
plateInfoLabel.SetBounds(labelLeft, 0,
System.Math.Max(0, navPanel.Left - labelLeft), plateHeaderPanel.ClientSize.Height);
}
private void ZoomPlateView(float factor)
{
var point = new Point(PlateView.Width / 2, PlateView.Height / 2);
PlateView.ZoomToControlPoint(point, factor);
}
private void CreateSidePanel()
+12 -55
View File
@@ -1,4 +1,4 @@
namespace OpenNest.Forms
namespace OpenNest.Forms
{
partial class MainForm
{
@@ -95,6 +95,7 @@
runAutoNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
autoSequenceAllPlatesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
mnuNestRemoveEmptyPlates = new System.Windows.Forms.ToolStripMenuItem();
mnuNestValidate = new System.Windows.Forms.ToolStripMenuItem();
mnuNestPost = new System.Windows.Forms.ToolStripMenuItem();
toolStripMenuItem19 = new System.Windows.Forms.ToolStripSeparator();
calculateCutTimeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
@@ -169,10 +170,6 @@
btnSave = new System.Windows.Forms.ToolStripButton();
btnSaveAs = new System.Windows.Forms.ToolStripButton();
toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator();
btnZoomOut = new System.Windows.Forms.ToolStripButton();
btnZoomIn = new System.Windows.Forms.ToolStripButton();
btnZoomToFit = new System.Windows.Forms.ToolStripButton();
toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator();
engineLabel = new System.Windows.Forms.ToolStripLabel();
engineComboBox = new System.Windows.Forms.ToolStripComboBox();
btnAutoNest = new System.Windows.Forms.ToolStripButton();
@@ -571,7 +568,7 @@
//
// mnuNest
//
mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuNestEdit, mnuNestImportDrawing, mnuNestShapeLibrary, toolStripMenuItem7, mnuNestFirstPlate, mnuNestLastPlate, toolStripMenuItem6, mnuNestNextPlate, mnuNestPreviousPlate, toolStripMenuItem12, runAutoNestToolStripMenuItem, autoSequenceAllPlatesToolStripMenuItem, mnuNestRemoveEmptyPlates, mnuNestPost, toolStripMenuItem19, calculateCutTimeToolStripMenuItem, toolStripMenuItem22, mnuNestAutomaticCutOff, mnuNestPlanCutting, mnuNestAssignLeadIns, mnuNestRemoveLeadIns });
mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuNestEdit, mnuNestImportDrawing, mnuNestShapeLibrary, toolStripMenuItem7, mnuNestFirstPlate, mnuNestLastPlate, toolStripMenuItem6, mnuNestNextPlate, mnuNestPreviousPlate, toolStripMenuItem12, runAutoNestToolStripMenuItem, autoSequenceAllPlatesToolStripMenuItem, mnuNestRemoveEmptyPlates, mnuNestValidate, mnuNestPost, toolStripMenuItem19, calculateCutTimeToolStripMenuItem, toolStripMenuItem22, mnuNestAutomaticCutOff, mnuNestPlanCutting, mnuNestAssignLeadIns, mnuNestRemoveLeadIns });
mnuNest.Name = "mnuNest";
mnuNest.Size = new System.Drawing.Size(43, 20);
mnuNest.Text = "&Nest";
@@ -668,6 +665,13 @@
mnuNestRemoveEmptyPlates.Text = "Remove Empty Plates";
mnuNestRemoveEmptyPlates.Click += RemoveEmptyPlates_Click;
//
// mnuNestValidate
//
mnuNestValidate.Name = "mnuNestValidate";
mnuNestValidate.Size = new System.Drawing.Size(205, 22);
mnuNestValidate.Text = "Validate Nest...";
mnuNestValidate.Click += ValidateNest_Click;
//
// mnuNestPost
//
mnuNestPost.Name = "mnuNestPost";
@@ -1133,7 +1137,7 @@
// toolStrip1
//
toolStrip1.AutoSize = false;
toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { btnNew, btnOpen, btnSave, btnSaveAs, toolStripSeparator1, btnZoomOut, btnZoomIn, btnZoomToFit, toolStripSeparator4, engineLabel, engineComboBox, btnAutoNest, btnShowRemnants, toolStripSeparator5, btnCutOff });
toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { btnNew, btnOpen, btnSave, btnSaveAs, toolStripSeparator1, engineLabel, engineComboBox, btnAutoNest, btnShowRemnants, toolStripSeparator5, btnCutOff });
toolStrip1.Location = new System.Drawing.Point(0, 24);
toolStrip1.Name = "toolStrip1";
toolStrip1.Size = new System.Drawing.Size(1281, 40);
@@ -1193,50 +1197,6 @@
toolStripSeparator1.Name = "toolStripSeparator1";
toolStripSeparator1.Size = new System.Drawing.Size(6, 40);
//
// btnZoomOut
//
btnZoomOut.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image;
btnZoomOut.Image = Properties.Resources.zoom_out;
btnZoomOut.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None;
btnZoomOut.ImageTransparentColor = System.Drawing.Color.Magenta;
btnZoomOut.Name = "btnZoomOut";
btnZoomOut.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
btnZoomOut.RightToLeft = System.Windows.Forms.RightToLeft.No;
btnZoomOut.Size = new System.Drawing.Size(38, 37);
btnZoomOut.Text = "Zoom Out";
btnZoomOut.Click += ZoomOut_Click;
//
// btnZoomIn
//
btnZoomIn.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image;
btnZoomIn.Image = Properties.Resources.zoom_in;
btnZoomIn.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None;
btnZoomIn.ImageTransparentColor = System.Drawing.Color.Magenta;
btnZoomIn.Name = "btnZoomIn";
btnZoomIn.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
btnZoomIn.RightToLeft = System.Windows.Forms.RightToLeft.No;
btnZoomIn.Size = new System.Drawing.Size(38, 37);
btnZoomIn.Text = "Zoom In";
btnZoomIn.Click += ZoomIn_Click;
//
// btnZoomToFit
//
btnZoomToFit.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image;
btnZoomToFit.Image = Properties.Resources.zoom_all;
btnZoomToFit.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None;
btnZoomToFit.ImageTransparentColor = System.Drawing.Color.Magenta;
btnZoomToFit.Name = "btnZoomToFit";
btnZoomToFit.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
btnZoomToFit.RightToLeft = System.Windows.Forms.RightToLeft.No;
btnZoomToFit.Size = new System.Drawing.Size(38, 37);
btnZoomToFit.Text = "Zoom To Fit";
btnZoomToFit.Click += ZoomToFit_Click;
//
// toolStripSeparator4
//
toolStripSeparator4.Name = "toolStripSeparator4";
toolStripSeparator4.Size = new System.Drawing.Size(6, 40);
//
// engineLabel
//
engineLabel.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, 0);
@@ -1420,12 +1380,12 @@
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem23;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem24;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem12;
private System.Windows.Forms.ToolStripMenuItem mnuNestValidate;
private System.Windows.Forms.ToolStripMenuItem mnuNestPost;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem13;
private System.Windows.Forms.ToolStripMenuItem mnuPlateViewInCad;
private System.Windows.Forms.ToolStripButton btnNew;
private System.Windows.Forms.ToolStripMenuItem mnuPlateAdd;
private System.Windows.Forms.ToolStripButton btnZoomToFit;
private System.Windows.Forms.ToolStripMenuItem mnuNestRemoveEmptyPlates;
private System.Windows.Forms.ToolStripMenuItem mnuViewZoomIn;
private System.Windows.Forms.ToolStripMenuItem mnuViewZoomOut;
@@ -1435,8 +1395,6 @@
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem15;
private System.Windows.Forms.ToolStripMenuItem mnuPlateSetAsDefault;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem16;
private System.Windows.Forms.ToolStripButton btnZoomOut;
private System.Windows.Forms.ToolStripButton btnZoomIn;
private System.Windows.Forms.ToolStripStatusLabel statusLabel1;
private System.Windows.Forms.ToolStripStatusLabel spacerLabel;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem18;
@@ -1460,7 +1418,6 @@
private System.Windows.Forms.ToolStripMenuItem centerPartsToolStripMenuItem;
private System.Windows.Forms.ToolStripStatusLabel gpuStatusLabel;
private System.Windows.Forms.ToolStripStatusLabel selectionStatusLabel;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator4;
private System.Windows.Forms.ToolStripLabel engineLabel;
private System.Windows.Forms.ToolStripComboBox engineComboBox;
private System.Windows.Forms.ToolStripButton btnAutoNest;
+39 -3
View File
@@ -27,6 +27,7 @@ namespace OpenNest.Forms
public partial class MainForm : Form
{
private EditNestForm activeForm;
private ValidateNestForm validationForm;
private bool clickUpdateLocation;
private bool nestingInProgress;
private CancellationTokenSource nestingCts;
@@ -218,7 +219,6 @@ namespace OpenNest.Forms
var hasValue = activeForm != null;
btnZoomToFit.Enabled = hasValue;
mnuFileSave.Enabled = hasValue && !databaseSaveInProgress;
btnSave.Enabled = hasValue && !databaseSaveInProgress;
btnSaveAs.Enabled = hasValue && !databaseSaveInProgress;
@@ -227,8 +227,6 @@ namespace OpenNest.Forms
mnuFileExport.Enabled = hasValue;
mnuFileExportAll.Enabled = hasValue;
mnuFileExportNestReport.Enabled = hasValue && !databaseSaveInProgress;
btnZoomOut.Enabled = hasValue;
btnZoomIn.Enabled = hasValue;
mnuEdit.Visible = hasValue;
mnuView.Visible = hasValue;
mnuNest.Visible = hasValue;
@@ -646,6 +644,7 @@ namespace OpenNest.Forms
var serverUrl = NestStorage.Settings.ServerUrl;
databaseOpenInProgress = true;
storageModeMenu.Enabled = false;
mnuToolsOptions.Enabled = false;
var file = await repository.GetFileAsync(dlg.SelectedId);
if (file == null)
throw new FileNotFoundException("This nest no longer exists on the server.");
@@ -663,7 +662,10 @@ namespace OpenNest.Forms
{
databaseOpenInProgress = false;
if (!IsDisposed)
{
storageModeMenu.Enabled = !databaseSaveInProgress;
mnuToolsOptions.Enabled = !databaseSaveInProgress;
}
}
}
@@ -707,6 +709,7 @@ namespace OpenNest.Forms
{
databaseSaveInProgress = true;
storageModeMenu.Enabled = false;
mnuToolsOptions.Enabled = false;
EnableCheck();
try
{
@@ -729,6 +732,7 @@ namespace OpenNest.Forms
if (!IsDisposed)
{
storageModeMenu.Enabled = !databaseOpenInProgress;
mnuToolsOptions.Enabled = !databaseOpenInProgress;
EnableCheck();
}
}
@@ -1494,6 +1498,7 @@ namespace OpenNest.Forms
allowInvalid = true;
}
var costs = result.IsValid && result.CanKeep ? NestCostSummary.FromAccepted(result) : null;
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, target.PlateManager,
allowInvalid, cts.Token);
if (applied.Count > 0)
@@ -1509,6 +1514,9 @@ namespace OpenNest.Forms
target.OverlapDisplay = OverlapDisplayMode.Both;
await target.CheckOverlapsAsync();
}
if (costs?.Basis == "supplied-cost")
MessageBox.Show(this, $"Gross sheet cost: {costs.GrossTotal:G}\nEstimated salvage credit: {costs.SalvageCredit:G}\nNet score: {costs.NetScore:G}",
"Auto Nest Cost", MessageBoxButtons.OK, MessageBoxIcon.Information);
if (result.Status != NestJobStatus.Complete)
MessageBox.Show(this, $"{engineName} could not place every part ({result.StopReason}).",
"Auto Nest", MessageBoxButtons.OK, MessageBoxIcon.Information);
@@ -1543,6 +1551,34 @@ namespace OpenNest.Forms
activeForm.AutoSequenceAllPlates();
}
private void ValidateNest_Click(object sender, EventArgs e)
{
var editForm = activeForm;
if (editForm == null)
return;
var views = MdiChildren.OfType<EditNestForm>()
.Where(form => ReferenceEquals(form.Nest, editForm.Nest))
.Select(form => form.PlateView).ToArray();
if (nestingInProgress || Application.OpenForms.OfType<NestProgressForm>().Any()
|| views.Any(view => view.IsFillInProgress || view.Actions.CurrentAction?.IsBusy() == true))
{
MessageBox.Show(this, "Finish or cancel the current nesting or plate action before validating.",
"Validate Nest", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
foreach (var view in views)
view.SetAction(typeof(ActionSelect));
if (validationForm is { IsDisposed: false })
{
validationForm.Activate();
return;
}
validationForm = new ValidateNestForm(editForm);
validationForm.Show(this);
}
private void PostProcessor_Click(object sender, EventArgs e)
{
var menuItem = (ToolStripMenuItem)sender;
+42 -3
View File
@@ -41,9 +41,13 @@
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
this.colorSchemeLabel = new System.Windows.Forms.Label();
this.colorSchemeCombo = new System.Windows.Forms.ComboBox();
this.serverGroupBox = new System.Windows.Forms.GroupBox();
this.serverUrlBox = new System.Windows.Forms.TextBox();
this.setServerButton = new System.Windows.Forms.Button();
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
this.tableLayoutPanel1.SuspendLayout();
this.bottomPanel1.SuspendLayout();
this.serverGroupBox.SuspendLayout();
this.SuspendLayout();
//
// checkBox1
@@ -135,7 +139,7 @@
this.bottomPanel1.Controls.Add(this.cancelButton);
this.bottomPanel1.Controls.Add(this.saveButton);
this.bottomPanel1.Dock = System.Windows.Forms.DockStyle.Bottom;
this.bottomPanel1.Location = new System.Drawing.Point(0, 368);
this.bottomPanel1.Location = new System.Drawing.Point(0, 443);
this.bottomPanel1.Name = "bottomPanel1";
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
this.bottomPanel1.TabIndex = 1;
@@ -158,7 +162,7 @@
// strategyGroupBox
//
this.strategyGroupBox.Controls.Add(this.strategyGrid);
this.strategyGroupBox.Location = new System.Drawing.Point(12, 178);
this.strategyGroupBox.Location = new System.Drawing.Point(12, 253);
this.strategyGroupBox.Name = "strategyGroupBox";
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
this.strategyGroupBox.TabIndex = 2;
@@ -184,13 +188,43 @@
this.colorSchemeCombo.Size = new System.Drawing.Size(130, 24);
this.colorSchemeCombo.TabIndex = 11;
//
// serverGroupBox
//
this.serverGroupBox.Controls.Add(this.serverUrlBox);
this.serverGroupBox.Controls.Add(this.setServerButton);
this.serverGroupBox.Location = new System.Drawing.Point(12, 178);
this.serverGroupBox.Name = "serverGroupBox";
this.serverGroupBox.Size = new System.Drawing.Size(684, 69);
this.serverGroupBox.TabIndex = 1;
this.serverGroupBox.TabStop = false;
this.serverGroupBox.Text = "Nest server URL (File / Database mode is selected under File > Storage Mode)";
//
// serverUrlBox
//
this.serverUrlBox.Location = new System.Drawing.Point(12, 29);
this.serverUrlBox.Name = "serverUrlBox";
this.serverUrlBox.PlaceholderText = "http://server:8090";
this.serverUrlBox.Size = new System.Drawing.Size(485, 22);
this.serverUrlBox.TabIndex = 0;
//
// setServerButton
//
this.setServerButton.Location = new System.Drawing.Point(504, 26);
this.setServerButton.Name = "setServerButton";
this.setServerButton.Size = new System.Drawing.Size(168, 28);
this.setServerButton.TabIndex = 1;
this.setServerButton.Text = "Set / Change Server";
this.setServerButton.UseVisualStyleBackColor = true;
this.setServerButton.Click += new System.EventHandler(this.SetServer_Click);
//
// OptionsForm
//
this.AcceptButton = this.saveButton;
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
this.CancelButton = this.cancelButton;
this.ClientSize = new System.Drawing.Size(708, 418);
this.ClientSize = new System.Drawing.Size(708, 493);
this.Controls.Add(this.strategyGroupBox);
this.Controls.Add(this.serverGroupBox);
this.Controls.Add(this.tableLayoutPanel1);
this.Controls.Add(this.bottomPanel1);
this.Font = new System.Drawing.Font("Microsoft Sans Serif", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
@@ -206,6 +240,8 @@
this.tableLayoutPanel1.ResumeLayout(false);
this.tableLayoutPanel1.PerformLayout();
this.bottomPanel1.ResumeLayout(false);
this.serverGroupBox.ResumeLayout(false);
this.serverGroupBox.PerformLayout();
this.ResumeLayout(false);
}
@@ -224,5 +260,8 @@
private System.Windows.Forms.GroupBox strategyGroupBox;
private System.Windows.Forms.Label colorSchemeLabel;
private System.Windows.Forms.ComboBox colorSchemeCombo;
private System.Windows.Forms.GroupBox serverGroupBox;
private System.Windows.Forms.TextBox serverUrlBox;
private System.Windows.Forms.Button setServerButton;
}
}
+25
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Engine.Strategies;
using OpenNest.Properties;
@@ -72,6 +73,7 @@ namespace OpenNest.Forms
private void LoadSettings()
{
serverUrlBox.Text = NestStorage.Settings.ServerUrl;
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
@@ -142,5 +144,28 @@ namespace OpenNest.Forms
{
SaveSettings();
}
private void SetServer_Click(object sender, EventArgs e)
{
if (!NestStorageSettings.TryNormalizeServerUrl(serverUrlBox.Text, out var url))
{
MessageBox.Show(this, "Enter a valid http or https server URL.",
"Server URL", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
try
{
NestStorage.Save(NestStorage.Settings.Mode, url);
serverUrlBox.Text = NestStorage.Settings.ServerUrl;
MessageBox.Show(this, "Server URL saved. Storage mode has not changed.",
"Server URL", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show(this, $"Could not save the server URL: {ex.Message}",
"Server URL", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
}
+10 -1
View File
@@ -70,8 +70,17 @@ public sealed class SavedNestsForm : Form
{
this.repository = repository;
this.serverUrl = serverUrl ?? "";
session = new NestBrowseSession(repository);
var settings = Properties.Settings.Default;
var sort = Enum.TryParse<NestSortField>(settings.SavedNestsSortColumn, out var savedSort)
&& Enum.IsDefined(savedSort) ? savedSort : NestSortField.SavedAt;
session = new NestBrowseSession(repository, sort: sort, descending: settings.SavedNestsSortDescending);
details = new NestDetailsSession(LoadDetailsAsync);
FormClosed += (_, _) =>
{
settings.SavedNestsSortColumn = session.Sort.ToString();
settings.SavedNestsSortDescending = session.Descending;
settings.Save();
};
Text = WindowTitle;
// Placed on the owner's screen in Load; see FitToScreen.
+17 -5
View File
@@ -31,6 +31,7 @@
tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel();
label1 = new System.Windows.Forms.Label();
numericUpDown1 = new OpenNest.Controls.NumericUpDown();
doneButton = new System.Windows.Forms.Button();
tableLayoutPanel1.SuspendLayout();
((System.ComponentModel.ISupportInitialize)numericUpDown1).BeginInit();
SuspendLayout();
@@ -42,13 +43,15 @@
tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
tableLayoutPanel1.Controls.Add(label1, 0, 0);
tableLayoutPanel1.Controls.Add(numericUpDown1, 1, 0);
tableLayoutPanel1.Controls.Add(doneButton, 1, 1);
tableLayoutPanel1.Dock = System.Windows.Forms.DockStyle.Fill;
tableLayoutPanel1.Location = new System.Drawing.Point(0, 0);
tableLayoutPanel1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
tableLayoutPanel1.Name = "tableLayoutPanel1";
tableLayoutPanel1.RowCount = 1;
tableLayoutPanel1.RowCount = 2;
tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Absolute, 35F));
tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
tableLayoutPanel1.Size = new System.Drawing.Size(266, 35);
tableLayoutPanel1.Size = new System.Drawing.Size(266, 74);
tableLayoutPanel1.TabIndex = 0;
//
// label1
@@ -74,20 +77,28 @@
numericUpDown1.TabIndex = 1;
numericUpDown1.Value = new decimal(new int[] { 1, 0, 0, 0 });
numericUpDown1.Leave += numericUpDown1_Leave;
// doneButton
//
doneButton.Anchor = System.Windows.Forms.AnchorStyles.Right;
doneButton.Location = new System.Drawing.Point(182, 40);
doneButton.Name = "doneButton";
doneButton.Size = new System.Drawing.Size(80, 28);
doneButton.TabIndex = 2;
doneButton.Text = "Done";
doneButton.UseVisualStyleBackColor = true;
doneButton.Click += doneButton_Click;
// SequenceForm
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(266, 35);
ControlBox = false;
ClientSize = new System.Drawing.Size(266, 74);
Controls.Add(tableLayoutPanel1);
FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow;
Location = new System.Drawing.Point(100, 100);
Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
MaximizeBox = false;
MinimizeBox = false;
MinimumSize = new System.Drawing.Size(268, 74);
MinimumSize = new System.Drawing.Size(268, 113);
Name = "SequenceForm";
ShowIcon = false;
ShowInTaskbar = false;
@@ -105,5 +116,6 @@
private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1;
private System.Windows.Forms.Label label1;
public Controls.NumericUpDown numericUpDown1;
private System.Windows.Forms.Button doneButton;
}
}
+8 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Windows.Forms;
namespace OpenNest.Forms
@@ -10,6 +10,13 @@ namespace OpenNest.Forms
InitializeComponent();
}
public event EventHandler DoneClicked;
private void doneButton_Click(object sender, EventArgs e)
{
DoneClicked?.Invoke(this, EventArgs.Empty);
}
private void numericUpDown1_Leave(object sender, EventArgs e)
{
numericUpDown1.Validate();
+326
View File
@@ -0,0 +1,326 @@
using System;
using System.Drawing;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Diagnostics;
namespace OpenNest.Forms;
/// <summary>Modeless inspection; the editor scope blocks edits while leaving canvas navigation available.</summary>
public sealed class ValidateNestForm : Form
{
private readonly Nest nest;
private readonly ValidationInspectionScope inspection;
private readonly Func<Nest, CancellationToken, Task<PostVerificationReport>> analyze;
private readonly CancellationTokenSource cancellation = new();
private readonly Label summaryLabel;
private readonly TextBox reportBox;
private readonly DataGridView warningGrid;
private readonly EditNestForm editor;
private readonly int originalPlate;
private bool populating;
private bool editorRestored;
private readonly (float Scale, PointF Origin) originalViewport;
private readonly OverlapDisplayMode originalOverlapDisplay;
private readonly Button closeButton;
private bool started;
private bool analyzing;
private bool closeRequested;
private bool disposedResources;
public ValidateNestForm(Nest nest)
: this(nest, (value, token) => Task.Run(() => PostVerificationAnalyzer.Analyze(value, token), token))
{ }
public ValidateNestForm(EditNestForm editor)
: this(editor, (value, token) => Task.Run(() => PostVerificationAnalyzer.Analyze(value, token), token))
{ }
internal ValidateNestForm(EditNestForm editor, Func<Nest, CancellationToken, Task<PostVerificationReport>> analyze)
: this(editor.Nest, analyze)
{
this.editor = editor;
inspection = new ValidationInspectionScope(editor, this);
originalPlate = editor.PlateManager.CurrentIndex;
originalViewport = editor.PlateView.CaptureViewport();
originalOverlapDisplay = editor.PlateView.OverlapDisplay;
editor.PlateView.OverlapDisplay = OverlapDisplayMode.Off;
StartPosition = FormStartPosition.Manual;
MinimumSize = new Size(480, 440);
ClientSize = new Size(560, 650);
}
internal ValidateNestForm(Nest nest, Func<Nest, CancellationToken, Task<PostVerificationReport>> analyze)
{
this.nest = nest ?? throw new ArgumentNullException(nameof(nest));
this.analyze = analyze ?? throw new ArgumentNullException(nameof(analyze));
Text = "Validate Nest";
Name = nameof(ValidateNestForm);
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(850, 560);
MinimumSize = new Size(700, 440);
ShowInTaskbar = true;
MinimizeBox = true;
AutoScaleMode = AutoScaleMode.Font;
var layout = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 1,
RowCount = 4,
};
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
summaryLabel = new Label
{
Name = "summaryLabel",
AutoSize = true,
Dock = DockStyle.Fill,
Text = "Checking all plates: overlaps, missing lead-ins, and rapid crossings...",
Margin = new Padding(3, 3, 3, 10),
};
var scopeLabel = new Label
{
AutoSize = true,
Dock = DockStyle.Fill,
Text = "Select an issue to locate it in the main plate view. Its highlight pulses; unrelated parts appear gray.\r\n"
+ "Minimum spacing, plate boundaries, and final post-processor output are not checked.",
Margin = new Padding(3, 3, 3, 10),
};
reportBox = new TextBox
{
Name = "reportBox",
Dock = DockStyle.Fill,
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
WordWrap = true,
MaxLength = 0,
AccessibleName = "Nest validation findings",
Text = "Validation is running...",
};
warningGrid = new DataGridView
{
Name = "warningGrid",
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
MultiSelect = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
AutoGenerateColumns = false,
RowHeadersVisible = false,
BackgroundColor = SystemColors.Window,
AccessibleName = "Nest validation findings",
};
foreach (var title in new[] { "Plate", "Part", "With", "Issue", "Finding" })
warningGrid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = title,
Width = title == "Issue" ? 115 : 48,
AutoSizeMode = title == "Finding" ? DataGridViewAutoSizeColumnMode.Fill : DataGridViewAutoSizeColumnMode.None,
SortMode = DataGridViewColumnSortMode.NotSortable,
});
warningGrid.CurrentCellChanged += (_, _) => ShowSelectedFinding();
warningGrid.CellClick += (_, _) => ShowSelectedFinding();
var findings = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
findings.RowStyles.Add(new RowStyle(SizeType.Percent, 65));
findings.RowStyles.Add(new RowStyle(SizeType.Percent, 35));
findings.Controls.Add(warningGrid, 0, 0);
findings.Controls.Add(reportBox, 0, 1);
closeButton = new Button
{
Name = "closeButton",
Text = "Cancel",
AutoSize = true,
DialogResult = DialogResult.Cancel,
};
closeButton.Click += (_, _) => Close();
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
AutoSize = true,
FlowDirection = FlowDirection.RightToLeft,
};
buttons.Controls.Add(closeButton);
layout.Controls.Add(summaryLabel, 0, 0);
layout.Controls.Add(scopeLabel, 0, 1);
layout.Controls.Add(findings, 0, 2);
layout.Controls.Add(buttons, 0, 3);
Controls.Add(layout);
AcceptButton = closeButton;
CancelButton = closeButton;
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
if (editor == null)
return;
var area = Screen.FromControl(editor).WorkingArea;
Bounds = new Rectangle(area.Right - Width, area.Top, Width, System.Math.Min(Height, area.Height));
}
private void ShowSelectedFinding()
{
if (editorRestored || populating || editor == null || editor.IsDisposed)
return;
var view = editor.PlateView;
view.ClearValidationFinding();
if (warningGrid.CurrentRow?.Tag is not PostVerificationFinding finding
|| finding.PlateNumber < 1 || finding.PlateNumber > nest.Plates.Count)
return;
if (editor.PlateManager.CurrentIndex != finding.PlateNumber - 1)
editor.PlateManager.LoadAt(finding.PlateNumber - 1);
var location = finding.Location;
if (location == null && finding.PartNumber is { } part && part > 0 && part <= view.Plate.Parts.Count)
location = view.Plate.Parts[part - 1]?.BoundingBox?.Center;
if (location is { } point && double.IsFinite(point.X) && double.IsFinite(point.Y))
{
var extent = finding.Overlap?.Bounds;
var size = System.Math.Max(10, System.Math.Max(extent?.Length ?? 0, extent?.Width ?? 0) * 2);
if (finding.RapidStart is { } start && finding.RapidEnd is { } end)
size = System.Math.Max(size, start.DistanceTo(end) * 1.5);
view.ZoomToArea(point.X - size / 2, point.Y - size / 2, size, size);
// Keep the marker in the portion of the main canvas left of this floating inspector.
var screen = view.RectangleToScreen(view.ClientRectangle);
var available = System.Math.Clamp(Left - screen.Left, 0, view.Width);
if (available > 100)
view.CenterValidationPoint(point, new Point(available / 2, view.Height / 2));
}
else
view.ZoomToFit();
view.ShowValidationFinding(finding with { Location = location });
ShowFindingDetails(finding);
}
private void ShowFindingDetails(PostVerificationFinding finding)
{
var hint = finding.Kind switch
{
PostVerificationKind.Overlap => "Shaded material is the overlap. The ring marks its area centroid, which can lie between separate patches.",
PostVerificationKind.RapidCrossing => finding.ContactPoints.Count > 0
? "Crosshairs mark contacts with the completed contour. For travel along an edge, they mark the contact span's endpoints. The pulsing dashed orange line shows the rapid."
: "The pulsing dashed orange line shows the rapid inside the completed contour; there is no boundary contact point to mark.",
PostVerificationKind.MissingLeadIn => "The ring marks the cutting contour start where a lead-in is missing.",
_ => "The ring marks the affected part's center when available; no exact problem location is known.",
};
reportBox.Text = $"Plate {finding.PlateNumber}, part {finding.PartNumber}: {finding.Message}"
+ Environment.NewLine + Environment.NewLine + hint;
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
RestoreEditor();
base.OnFormClosed(e);
}
private void RestoreEditor()
{
if (editorRestored || editor == null || editor.IsDisposed)
return;
editorRestored = true;
inspection?.Dispose();
editor.PlateView.ClearValidationFinding();
if (editor.PlateManager.CurrentIndex != originalPlate)
editor.PlateManager.LoadAt(originalPlate);
editor.PlateView.RestoreViewport(originalViewport);
editor.PlateView.OverlapDisplay = originalOverlapDisplay;
}
protected override async void OnShown(EventArgs e)
{
base.OnShown(e);
await ValidateAsync();
}
internal async Task ValidateAsync()
{
if (started)
return;
started = true;
analyzing = true;
try
{
var report = await analyze(nest, cancellation.Token);
if (closeRequested || IsDisposed)
return;
populating = true;
foreach (var finding in report.Findings)
{
var index = warningGrid.Rows.Add(finding.PlateNumber, finding.PartNumber, finding.OtherPartNumber,
finding.Kind, finding.Message);
warningGrid.Rows[index].Tag = finding;
}
populating = false;
ShowSelectedFinding();
reportBox.Text = report.ToDisplayText("Nest validation");
if (editor != null && warningGrid.CurrentRow?.Tag is PostVerificationFinding selected)
ShowFindingDetails(selected);
reportBox.SelectionStart = 0;
reportBox.SelectionLength = 0;
summaryLabel.Text = report.Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete)
? "Validation incomplete. Review the findings; this nest has not passed all checks."
: report.HasWarnings ? "Warnings found. Review the findings below."
: "Validation complete. No warnings found by these checks.";
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
closeRequested = true;
}
catch (Exception ex)
{
if (!IsDisposed)
{
summaryLabel.Text = "Validation failed. This nest has not passed the checks.";
reportBox.Text = "Validation could not finish. Close this window and try again."
+ Environment.NewLine + Environment.NewLine + ex.Message;
}
}
finally
{
analyzing = false;
if (!IsDisposed)
{
closeButton.Text = "Close";
if (closeRequested)
{
DialogResult = DialogResult.Cancel;
Close();
}
}
}
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (analyzing)
{
closeRequested = true;
cancellation.Cancel();
closeButton.Enabled = false;
summaryLabel.Text = "Canceling validation...";
e.Cancel = true;
}
base.OnFormClosing(e);
}
protected override void Dispose(bool disposing)
{
if (disposing && !disposedResources)
{
disposedResources = true;
RestoreEditor();
inspection?.Dispose();
cancellation.Cancel();
cancellation.Dispose();
}
base.Dispose(disposing);
}
}
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more