mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 07:34:14 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69304d3851 | ||
|
|
1c6d02c480 | ||
|
|
d328c5f691 | ||
|
|
e897258a85 | ||
|
|
1b9d8a36d8 | ||
|
|
b750475228 | ||
|
|
138c86526a | ||
|
|
07e682a654 | ||
|
|
0b29c683b9 | ||
|
|
71bd964344 | ||
|
|
1d50c6ce60 | ||
|
|
962ae8c825 | ||
|
|
d3e325f36c | ||
|
|
575412a464 | ||
|
|
60fb009329 | ||
|
|
41737a4f4c | ||
|
|
f1d1df3f17 | ||
|
|
81404fc673 | ||
|
|
a5b6c8a00e | ||
|
|
2a6e5504cd | ||
|
|
3d5c73a353 | ||
|
|
8b94e49711 | ||
|
|
2623517219 |
No files matched your search
@@ -106,8 +106,13 @@ internal static class PostVerificationGeometry
|
|||||||
Center = center;
|
Center = center;
|
||||||
Radius = radius;
|
Radius = radius;
|
||||||
Sweep = sweep;
|
Sweep = sweep;
|
||||||
|
nativeEntity = new Lazy<Entity>(ToEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Never expose this mutable native wrapper; its geometry is read-only after creation.
|
||||||
|
// Lazy preserves the old conversion point for unsupported curves and is safe for concurrent checks.
|
||||||
|
private readonly Lazy<Entity> nativeEntity;
|
||||||
|
|
||||||
internal Vector Start { get; }
|
internal Vector Start { get; }
|
||||||
internal Vector End { get; }
|
internal Vector End { get; }
|
||||||
private Vector? Center { get; }
|
private Vector? Center { get; }
|
||||||
@@ -170,12 +175,12 @@ internal static class PostVerificationGeometry
|
|||||||
: Start + (End - Start) * (distance / Length);
|
: Start + (End - Start) * (distance / Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon;
|
internal bool Contains(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= Epsilon;
|
||||||
|
|
||||||
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
||||||
{
|
{
|
||||||
var entity = ToEntity();
|
var entity = nativeEntity.Value;
|
||||||
var candidate = other.ToEntity();
|
var candidate = other.nativeEntity.Value;
|
||||||
// Native arc filters discard NaN supporting-circle intersections. Inspect
|
// Native arc filters discard NaN supporting-circle intersections. Inspect
|
||||||
// both unfiltered queries first: roundoff can differ by operand direction.
|
// both unfiltered queries first: roundoff can differ by operand direction.
|
||||||
// Coincident supports have separate overlap handling below.
|
// Coincident supports have separate overlap handling below.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,18 @@ public sealed class NestStorageSettings
|
|||||||
public bool IsDatabaseMode =>
|
public bool IsDatabaseMode =>
|
||||||
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
|
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
|
||||||
|
|
||||||
|
/// <summary>Validate and normalize a nest server base URL without changing storage mode.</summary>
|
||||||
|
public static bool TryNormalizeServerUrl(string? url, out string normalized)
|
||||||
|
{
|
||||||
|
normalized = NormalizeUrl(url);
|
||||||
|
if (Uri.TryCreate(normalized, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
normalized = "";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>%APPDATA%\OpenNest\storage.json.</summary>
|
/// <summary>%APPDATA%\OpenNest\storage.json.</summary>
|
||||||
public static string DefaultPath => Path.Combine(
|
public static string DefaultPath => Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
using OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||||
|
|
||||||
|
public class CutoutNfpProposalTests
|
||||||
|
{
|
||||||
|
private const double Spacing = 0.25;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeInsertFitsRoundCutoutAndRemainsValidAfterFrameRotation()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1, RotationPolicy.Fixed(System.Math.PI / 2));
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(0));
|
||||||
|
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
|
||||||
|
Assert.NotNull(pose);
|
||||||
|
Assert.Equal(insert.Id, pose.PartId);
|
||||||
|
Assert.Equal(0, pose.InstanceIndex);
|
||||||
|
Assert.True(insert.Rotation.Allows(pose.Rotation));
|
||||||
|
AssertSquareInRoundHole(pose, 6, Spacing);
|
||||||
|
|
||||||
|
var localFrame = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
||||||
|
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), localFrame,
|
||||||
|
JobPartGeometry.Read(insert.Geometry), pose, Spacing));
|
||||||
|
var framePose = new NestJobPlacement(frame.Id, 0, 11, 11, System.Math.PI / 2);
|
||||||
|
var movedInsert = pose with
|
||||||
|
{
|
||||||
|
X = framePose.X - pose.Y,
|
||||||
|
Y = framePose.Y + pose.X,
|
||||||
|
Rotation = pose.Rotation + framePose.Rotation,
|
||||||
|
};
|
||||||
|
// This insert is fixed at zero for the local proposal; the rotated variant needs
|
||||||
|
// a correspondingly legal global policy before it can be handed to a job.
|
||||||
|
var rotatedInsert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(System.Math.PI / 2));
|
||||||
|
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), framePose,
|
||||||
|
JobPartGeometry.Read(insert.Geometry), movedInsert, Spacing));
|
||||||
|
AssertSquareInRoundHole(movedInsert, 6, Spacing, framePose.X, framePose.Y);
|
||||||
|
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
|
||||||
|
var job = JobBuilder.Job(new[] { frame, rotatedInsert }, new[] { stock });
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, stock, new[] { framePose, movedInsert }) },
|
||||||
|
new[] { new PartFulfillment(frame.Id, 1, 1, 0), new PartFulfillment(insert.Id, 1, 1, 0) },
|
||||||
|
new[] { new StockUsage(stock.Id, 1, null) });
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeInsertFitsSquareCutoutWithoutUsingFillLattice()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
program.MoveTo(5, 5);
|
||||||
|
program.LineTo(5, 15);
|
||||||
|
program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, 5);
|
||||||
|
program.LineTo(5, 5);
|
||||||
|
var frame = JobBuilder.Part("square-frame", program, 1);
|
||||||
|
var insert = JobBuilder.Rectangle("large", 9, 9, 1, RotationPolicy.Fixed(0));
|
||||||
|
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
|
||||||
|
Assert.NotNull(pose);
|
||||||
|
Assert.All(new[] { (0.0, 0.0), (9.0, 0.0), (9.0, 9.0), (0.0, 9.0) }, corner =>
|
||||||
|
{
|
||||||
|
Assert.InRange(pose.X + corner.Item1, 5 + Spacing, 15 - Spacing);
|
||||||
|
Assert.InRange(pose.Y + corner.Item2, 5 + Spacing, 15 - Spacing);
|
||||||
|
});
|
||||||
|
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry),
|
||||||
|
new NestJobPlacement(frame.Id, 0, 0, 0, 0), JobPartGeometry.Read(insert.Geometry), pose, Spacing));
|
||||||
|
Assert.Null(CutoutNfpProposal.Find(frame, 0, JobBuilder.Rectangle("oversized", 9.6, 9.6, 1),
|
||||||
|
Spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObliqueFixedInsertRotationIsUsedInInnerFit()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
program.MoveTo(5, 5);
|
||||||
|
program.LineTo(5, 15);
|
||||||
|
program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, 5);
|
||||||
|
program.LineTo(5, 5);
|
||||||
|
var frame = JobBuilder.Part("frame", program, 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 3, 2, 1, RotationPolicy.Fixed(System.Math.PI / 4));
|
||||||
|
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
|
||||||
|
Assert.NotNull(pose);
|
||||||
|
Assert.True(insert.Rotation.Allows(pose.Rotation));
|
||||||
|
foreach (var (x, y) in new[] { (0.0, 0.0), (3.0, 0.0), (3.0, 2.0), (0.0, 2.0) })
|
||||||
|
{
|
||||||
|
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
|
||||||
|
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
|
||||||
|
Assert.InRange(rx, 5 + Spacing, 15 - Spacing);
|
||||||
|
Assert.InRange(ry, 5 + Spacing, 15 - Spacing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(9.51)]
|
||||||
|
[InlineData(10.0)]
|
||||||
|
[InlineData(9.6)]
|
||||||
|
public void OversizedInsertIsNeverProposed(double side)
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", side, side, 1);
|
||||||
|
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, Spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnsupportedCoordinateScaleFailsClosed()
|
||||||
|
{
|
||||||
|
const double scale = 1e15;
|
||||||
|
var program = TestShapes.Polyline((-scale, -scale), (scale, -scale),
|
||||||
|
(scale, scale), (-scale, scale));
|
||||||
|
program.MoveTo(-scale / 2, -scale / 2);
|
||||||
|
program.LineTo(-scale / 2, scale / 2);
|
||||||
|
program.LineTo(scale / 2, scale / 2);
|
||||||
|
program.LineTo(scale / 2, -scale / 2);
|
||||||
|
program.LineTo(-scale / 2, -scale / 2);
|
||||||
|
Assert.Null(CutoutNfpProposal.Find(JobBuilder.Part("huge", program, 1), 0,
|
||||||
|
JobBuilder.Rectangle("insert", 6, 6, 1), Spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImpossibleHugeSpacingFailsBeforeAnyOffset()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
|
||||||
|
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, 1e12));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertSquareInRoundHole(NestJobPlacement pose, double side, double spacing,
|
||||||
|
double centerX = 0, double centerY = 0)
|
||||||
|
{
|
||||||
|
var sin = System.Math.Sin(pose.Rotation);
|
||||||
|
var cos = System.Math.Cos(pose.Rotation);
|
||||||
|
foreach (var (x, y) in new[] { (0.0, 0.0), (side, 0.0), (side, side), (0.0, side) })
|
||||||
|
{
|
||||||
|
var dx = pose.X + x * cos - y * sin - centerX;
|
||||||
|
var dy = pose.Y + x * sin + y * cos - centerY;
|
||||||
|
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) <= 5 - spacing + 0.00001,
|
||||||
|
$"corner ({dx}, {dy}) does not clear the circular wall");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationIsNotReportedAsNoFit()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
|
||||||
|
using var cancelled = new CancellationTokenSource();
|
||||||
|
cancelled.Cancel();
|
||||||
|
Assert.Throws<OperationCanceledException>(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing,
|
||||||
|
cancelled.Token));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||||
|
|
||||||
|
[CollectionDefinition("Cutout pipeline registry", DisableParallelization = true)]
|
||||||
|
public sealed class CutoutPipelineRegistryCollection { }
|
||||||
|
|
||||||
|
[Collection("Cutout pipeline registry")]
|
||||||
|
public class CutoutPipelinePreviewTests
|
||||||
|
{
|
||||||
|
private static NestPipelineRequest Request(string engine = "Irregular")
|
||||||
|
{
|
||||||
|
var frame = new Drawing("frame", TestShapes.Ring(20, 10));
|
||||||
|
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
|
||||||
|
return new NestPipelineRequest(engine,
|
||||||
|
new[] { new NestItem { Drawing = frame, Quantity = 1 },
|
||||||
|
new NestItem { Drawing = insert, Quantity = 1 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Irregular")]
|
||||||
|
[InlineData("Default")]
|
||||||
|
public void RegisteredEngineExpandsOnlyPhysicalOriginalDrawings(string engine)
|
||||||
|
{
|
||||||
|
var request = Request(engine);
|
||||||
|
var result = NestPipeline.RunCutoutPreview(request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.True(result.CanKeep);
|
||||||
|
var plate = Assert.Single(result.Plates);
|
||||||
|
Assert.Equal(2, plate.Parts.Count);
|
||||||
|
Assert.Same(request.Items[0].Drawing, plate.Parts[0].BaseDrawing);
|
||||||
|
Assert.Same(request.Items[1].Drawing, plate.Parts[1].BaseDrawing);
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates[0].Placements.Select(p => p.PartId));
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 1, 0), (f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
Assert.Equal(0, request.Items[0].Drawing.Quantity.Nested);
|
||||||
|
Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Stub(Func<NestJob, NestJobResult> solve) : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default) => solve(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestJobResult Result(NestJob job, params NestJobPlacement[] poses)
|
||||||
|
{
|
||||||
|
var placed = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
var complete = job.Parts.All(p => placed.GetValueOrDefault(p.Id) == p.Quantity);
|
||||||
|
return new NestJobResult(complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||||
|
complete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound,
|
||||||
|
poses.Length == 0 ? Array.Empty<NestJobPlateResult>() :
|
||||||
|
new[] { new NestJobPlateResult(0, job.Plates[0], poses) },
|
||||||
|
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity,
|
||||||
|
placed.GetValueOrDefault(p.Id), p.Quantity - placed.GetValueOrDefault(p.Id))),
|
||||||
|
new[] { new StockUsage(job.Plates[0].Id, poses.Length == 0 ? 0 : 1,
|
||||||
|
job.Plates[0].Quantity - (poses.Length == 0 ? 0 : 1)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleSolveReceivesOneProxyAndNoDuplicatedInsertDemand()
|
||||||
|
{
|
||||||
|
var called = 0;
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
called++;
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
Assert.StartsWith("__cutout-proxy-", proxy.Id);
|
||||||
|
Assert.True(proxy.Rotation.Allows(0));
|
||||||
|
Assert.False(proxy.Rotation.Allows(System.Math.PI / 2));
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.Equal(1, called);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(2, result.Plates.Single().Parts.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IncompleteEngineDoesNotBindReservedInsertEvenWithInvalidConsent()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job => Result(job)), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("did not complete"));
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 0, 1),
|
||||||
|
(f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested);
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForgedFulfillmentOrDuplicateIndexDoesNotBindComposite()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var id = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")).Id;
|
||||||
|
var pose = new NestJobPlacement(id, 1, 11, 11, 0);
|
||||||
|
return Result(job, pose);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("accounting"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } };
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
var residual = job.Parts.Single(p => p.Id == "part-1");
|
||||||
|
Assert.Equal(1, residual.Quantity);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
|
||||||
|
new NestJobPlacement(residual.Id, 0, 22, 1, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1", "part-1" },
|
||||||
|
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.Single().Placements
|
||||||
|
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
|
||||||
|
Assert.All(result.Plates.Single().Parts.Where(p => p.BaseDrawing == request.Items[1].Drawing),
|
||||||
|
p => Assert.Same(request.Items[1].Drawing, p.BaseDrawing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UntrustedStockOverdrawOrRotationCannotBindAComposite()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11,
|
||||||
|
System.Math.PI / 2));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("rotation constraint"));
|
||||||
|
|
||||||
|
var overdraw = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
|
||||||
|
NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, job.Plates[0], new[] {
|
||||||
|
new NestJobPlacement(job.Parts[0].Id, 0, 11, 11, 0) }),
|
||||||
|
new NestJobPlateResult(1, job.Plates[0], new[] {
|
||||||
|
new NestJobPlacement(job.Parts[0].Id, 1, 11, 11, 0) }) },
|
||||||
|
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>())), "stub", request);
|
||||||
|
Assert.False(overdraw.CanKeep);
|
||||||
|
Assert.Empty(overdraw.Plates);
|
||||||
|
Assert.Contains(overdraw.Violations, v => v.Contains("only 1 are available"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationBeforePreparationDoesNotCallEngine()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
var engine = new Stub(_ => throw new Exception("Must not call Solve"));
|
||||||
|
Assert.Throws<OperationCanceledException>(() =>
|
||||||
|
NestPipeline.RunCutoutPreview(engine, "stub", Request("stub"), token: cts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
|
||||||
|
new NestJobPlacement("part-1", 0, 11, 11, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("required spacing"));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertCannotCommit(NestPipelineResult result, NestPipelineRequest request)
|
||||||
|
{
|
||||||
|
var nest = new Nest();
|
||||||
|
foreach (var item in request.Items)
|
||||||
|
nest.Drawings.Add(item.Drawing);
|
||||||
|
var empty = nest.CreatePlate();
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
|
||||||
|
Assert.Same(empty, Assert.Single(nest.Plates));
|
||||||
|
Assert.Empty(empty.Parts);
|
||||||
|
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CollectProgress : IProgress<NestJobProgress>
|
||||||
|
{
|
||||||
|
public List<NestJobProgress> Events { get; } = new();
|
||||||
|
public void Report(NestJobProgress value) => Events.Add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EvaluationProgressSurvivesButUnverifiedCommitsNeverEscape()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var progress = new CollectProgress();
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new ProgressStub(), "stub", request, progress);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Single(progress.Events);
|
||||||
|
Assert.Equal(NestJobStage.EvaluatingCandidate, progress.Events[0].Stage);
|
||||||
|
Assert.Equal(0, progress.Events[0].CommittedPlates);
|
||||||
|
Assert.Equal(0, progress.Events[0].CommittedParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ProgressStub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
|
||||||
|
job.Plates[0].Id, 0, 1, 1));
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
|
||||||
|
job.Plates[0].Id, 0, 1, 1));
|
||||||
|
return Result(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReorderedButContiguousEngineIndicesAreAccepted()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[1].Quantity = 3;
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) } };
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
Assert.Equal(2, job.Parts.Single(p => p.Id == "part-1").Quantity);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
|
||||||
|
new NestJobPlacement("part-1", 1, 22, 1, 0),
|
||||||
|
new NestJobPlacement("part-1", 0, 22, 9, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { 0, 1, 2 }, result.Raw.Plates.Single().Placements
|
||||||
|
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public void ForgedFulfillmentOrStockMetadataCannotBind(bool fulfillment)
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
var good = Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
return new NestJobResult(good.Status, good.StopReason, good.Plates,
|
||||||
|
fulfillment ? new[] { new PartFulfillment(proxy.Id, 1, 0, 1) } : good.Fulfillment,
|
||||||
|
fulfillment ? good.StockUsage : new[] { new StockUsage("sheet", 0, 1) });
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("accounting"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
|
||||||
|
{
|
||||||
|
var initial = Request("stub");
|
||||||
|
var request = initial with
|
||||||
|
{
|
||||||
|
Items = new[] { initial.Items[0], initial.Items[1],
|
||||||
|
new NestItem { Drawing = initial.Items[1].Drawing, Quantity = 1 } },
|
||||||
|
Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) }
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
var residual = job.Parts.Where(p => p.Id != proxy.Id)
|
||||||
|
.Select((p, i) => new NestJobPlacement(p.Id, 0, 22, 1 + 5 * i, 0));
|
||||||
|
return Result(job, new[] { new NestJobPlacement(proxy.Id, 0, 11, 11, 0) }
|
||||||
|
.Concat(residual).ToArray());
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1", "part-2" },
|
||||||
|
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
Assert.Same(result.Plates.Single().Parts[1].BaseDrawing,
|
||||||
|
result.Plates.Single().Parts[2].BaseDrawing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RepeatedFrameReservationsExpandOnTwoPhysicalSheets()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[0].Quantity = 2;
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
request = request with
|
||||||
|
{
|
||||||
|
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
|
||||||
|
Options = new NestJobOptions(maxPlates: 2)
|
||||||
|
};
|
||||||
|
var calls = 0;
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
|
||||||
|
Assert.Equal(2, proxies.Length);
|
||||||
|
Assert.Equal(2, job.Parts.Count);
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
foreach (var proxy in proxies)
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(2, result.Raw.Plates.Count);
|
||||||
|
Assert.All(result.Raw.Plates, plate => Assert.Equal(new[] { "part-0", "part-1" },
|
||||||
|
plate.Placements.Select(p => p.PartId)));
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 2, 0),
|
||||||
|
(f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
|
||||||
|
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplacedFrameReleasesInsertDemandWithoutABindablePartialProposal()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[0].Quantity = 2;
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } };
|
||||||
|
var calls = 0;
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
var first = job.Parts.Single(p => p.Id.EndsWith("-0"));
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (first.Id, 11.0, 11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.NoPlacementFound);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Equal(NestJobStatus.Incomplete, result.Raw.Status);
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 1, 1),
|
||||||
|
(f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentFrameRequirementsShareScarceInsertsByIdentity()
|
||||||
|
{
|
||||||
|
var initial = Request("stub");
|
||||||
|
var frame2 = new Drawing("second-frame", TestShapes.Ring(18, 10));
|
||||||
|
initial.Items[1].Quantity = 2;
|
||||||
|
initial.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
var request = initial with
|
||||||
|
{
|
||||||
|
Items = new[] { initial.Items[0],
|
||||||
|
new NestItem { Drawing = frame2, Quantity = 1 }, initial.Items[1] },
|
||||||
|
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
|
||||||
|
Assert.Equal(2, proxies.Length);
|
||||||
|
Assert.Equal(2, job.Parts.Count);
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
foreach (var proxy in proxies)
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-2", "part-1", "part-2" },
|
||||||
|
result.Raw.Plates.SelectMany(p => p.Placements).Select(p => p.PartId));
|
||||||
|
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
|
||||||
|
.Where(p => p.PartId == "part-2").Select(p => p.InstanceIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedQuarterTurnComposesLocalInsertRotationAndTranslation()
|
||||||
|
{
|
||||||
|
var frame = TestShapes.Rectangle(20, 20);
|
||||||
|
frame.MoveTo(5, 5);
|
||||||
|
frame.LineTo(5, 15);
|
||||||
|
frame.LineTo(15, 15);
|
||||||
|
frame.LineTo(15, 5);
|
||||||
|
frame.LineTo(5, 5);
|
||||||
|
var quarter = System.Math.PI / 2;
|
||||||
|
var request = new NestPipelineRequest("stub", new[] {
|
||||||
|
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 1,
|
||||||
|
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter },
|
||||||
|
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1,
|
||||||
|
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
Assert.True(proxy.Rotation.Allows(quarter));
|
||||||
|
Assert.False(proxy.Rotation.Allows(0));
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 21, 1, quarter));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var poses = result.Raw.Plates.Single().Placements;
|
||||||
|
Assert.Equal(quarter, poses[0].Rotation, 8);
|
||||||
|
Assert.Equal(quarter, poses[1].Rotation, 8);
|
||||||
|
// Independent square-hole oracle, not the production clearance predicate.
|
||||||
|
var localX = poses[1].Y - poses[0].Y;
|
||||||
|
var localY = -(poses[1].X - poses[0].X);
|
||||||
|
foreach (var dx in new[] { 0.0, 3.0 })
|
||||||
|
foreach (var dy in new[] { 0.0, 3.0 })
|
||||||
|
{
|
||||||
|
Assert.InRange(localX + dx, 5.25, 14.75);
|
||||||
|
Assert.InRange(localY + dy, 5.25, 14.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HeterogeneousStockUsesConservativeSpacingAndOriginalQuadrant()
|
||||||
|
{
|
||||||
|
var request = Request("stub") with
|
||||||
|
{
|
||||||
|
Stock = new[] {
|
||||||
|
new NestPlateStock("tight", new Size(22, 22), 1, 0.25),
|
||||||
|
new NestPlateStock("wide", new Size(22, 22), 1, 0.6, quadrant: 3) }
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
builder.AddSheet(job.Plates[1], new[] { (proxy.Id, -11.0, -11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal("wide", result.Raw.Plates.Single().StockId);
|
||||||
|
Assert.Equal(3, result.Plates.Single().Stock.Quadrant);
|
||||||
|
Assert.Equal(0.6, result.Plates.Single().Stock.PartSpacing);
|
||||||
|
Assert.Equal(2, result.Plates.Single().Parts.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisteredPluginRunsTheSamePhysicalExpansionBoundary()
|
||||||
|
{
|
||||||
|
var name = "CutoutPlugin-" + Guid.NewGuid().ToString("N");
|
||||||
|
var calls = 0;
|
||||||
|
NestingEngineRegistry.Register(name, "test plugin", () => new Stub(job =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = NestPipeline.RunCutoutPreview(Request(name));
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1" },
|
||||||
|
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
var registry = Assert.IsType<List<NestingEngineInfo>>(NestingEngineRegistry.AvailableEngines);
|
||||||
|
Assert.Equal(1, registry.RemoveAll(info => info.Name == name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TwoCutoutsReserveDifferentCopiesWithinOneFrame()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
foreach (var origin in new[] { 2.0, 12.0 })
|
||||||
|
{
|
||||||
|
program.MoveTo(origin, origin);
|
||||||
|
program.LineTo(origin, origin + 6);
|
||||||
|
program.LineTo(origin + 6, origin + 6);
|
||||||
|
program.LineTo(origin + 6, origin);
|
||||||
|
program.LineTo(origin, origin);
|
||||||
|
}
|
||||||
|
var frame = new Drawing("two-hole-frame", program);
|
||||||
|
var insert = new Drawing("insert", TestShapes.Rectangle(4, 4));
|
||||||
|
var request = new NestPipelineRequest("stub", new[] {
|
||||||
|
new NestItem { Drawing = frame, Quantity = 1 },
|
||||||
|
new NestItem { Drawing = insert, Quantity = 2 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 1, 1, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var inserts = result.Raw.Plates.Single().Placements.Skip(1).ToArray();
|
||||||
|
Assert.Equal(2, inserts.Length);
|
||||||
|
Assert.All(inserts, p =>
|
||||||
|
{
|
||||||
|
var localX = p.X - 1;
|
||||||
|
var localY = p.Y - 1;
|
||||||
|
Assert.Contains(new[] { 2.0, 12.0 }, origin =>
|
||||||
|
localX >= origin + 0.25 && localX + 4 <= origin + 5.75
|
||||||
|
&& localY >= origin + 0.25 && localY + 4 <= origin + 5.75);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentPrioritiesRemainIndependentInsteadOfElevatingAnInsert()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[1].Priority = 3;
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
return Result(job);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, 11.0, 11.0)]
|
||||||
|
[InlineData(2, -11.0, 11.0)]
|
||||||
|
[InlineData(3, -11.0, -11.0)]
|
||||||
|
[InlineData(4, 11.0, -11.0)]
|
||||||
|
public void AllQuadrantsKeepPhysicalStockAndExpansion(int quadrant, double x, double y)
|
||||||
|
{
|
||||||
|
var request = Request("stub") with
|
||||||
|
{
|
||||||
|
Stock = new[] {
|
||||||
|
new NestPlateStock("sheet", new Size(22, 22), 1, 0.25,
|
||||||
|
new Spacing(0.5, 0.5), quadrant) },
|
||||||
|
Options = new NestJobOptions(maxPlates: 1)
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, x, y, 0))),
|
||||||
|
"stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(quadrant, result.Plates.Single().Stock.Quadrant);
|
||||||
|
Assert.Equal((x, y), (result.Raw.Plates.Single().Placements[0].X,
|
||||||
|
result.Raw.Plates.Single().Placements[0].Y));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationAfterAnEngineIgnoresStopDiscardsEveryBundle()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
Assert.Throws<OperationCanceledException>(() => NestPipeline.RunCutoutPreview(
|
||||||
|
new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
cts.Cancel();
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}), "stub", request, token: cts.Token));
|
||||||
|
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompleteStatusWithMissingProxyIsUntrustedAndNotBindable()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
|
||||||
|
NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
Array.Empty<NestJobPlateResult>(),
|
||||||
|
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)),
|
||||||
|
job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)))), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("accounting"));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SuccessfulCompositeReportsOnlyValidatedPhysicalCommitCounts()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var progress = new CollectProgress();
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new ReportingStub(), "stub", request, progress);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { NestJobStage.EvaluatingCandidate, NestJobStage.PlateCommitted },
|
||||||
|
progress.Events.Select(e => e.Stage));
|
||||||
|
Assert.Equal(2, progress.Events.Last().CommittedParts);
|
||||||
|
Assert.Equal(1, progress.Events.Last().CommittedPlates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ReportingStub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
|
||||||
|
job.Plates[0].Id, 0, 0, 0));
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
|
||||||
|
job.Plates[0].Id, 0, 1, 1));
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutomaticBundleRetainsStockFeasibleQuarterTurn()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 10);
|
||||||
|
program.MoveTo(2, 2);
|
||||||
|
program.LineTo(2, 8);
|
||||||
|
program.LineTo(18, 8);
|
||||||
|
program.LineTo(18, 2);
|
||||||
|
program.LineTo(2, 2);
|
||||||
|
var request = new NestPipelineRequest("Irregular", new[] {
|
||||||
|
new NestItem { Drawing = new Drawing("long-frame", program), Quantity = 1 },
|
||||||
|
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1 } },
|
||||||
|
new[] { new NestPlateStock("narrow", new Size(22, 12), 1, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var frame = result.Raw.Plates.Single().Placements[0];
|
||||||
|
Assert.Equal(System.Math.PI / 2, frame.Rotation, 7);
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1" },
|
||||||
|
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChangedLiveInsertDuringSolveNeverBindsUncheckedComposite()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(40, 40);
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("insert"));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NondefaultOptionsPassUnchangedAndPlateCapRejectsOtherwiseValidOutput()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[0].Quantity = 2;
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
var options = new NestJobOptions("Strip", maxPlates: 1,
|
||||||
|
salvageRate: 0.4, minimumSalvageDimension: 2);
|
||||||
|
request = request with
|
||||||
|
{
|
||||||
|
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
|
||||||
|
Options = options
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
Assert.Same(options, job.Options);
|
||||||
|
Assert.Equal("Strip", job.Options.PlacementStrategy);
|
||||||
|
Assert.Equal(0.4, job.Options.SalvageRate);
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
foreach (var proxy in job.Parts)
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("MaxPlates"));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObliqueFramePreservesLegalGlobalInsertRotationAndWallClearance()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
program.MoveTo(5, 5);
|
||||||
|
program.LineTo(5, 15);
|
||||||
|
program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, 5);
|
||||||
|
program.LineTo(5, 5);
|
||||||
|
const double angle = 0.3;
|
||||||
|
var request = new NestPipelineRequest("stub", new[] {
|
||||||
|
new NestItem { Drawing = new Drawing("frame", program), Quantity = 1,
|
||||||
|
StepAngle = 1, RotationStart = angle, RotationEnd = angle },
|
||||||
|
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)),
|
||||||
|
Quantity = 1, StepAngle = 1, RotationStart = angle, RotationEnd = angle } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(40, 40), 1, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, 15, 5, angle))),
|
||||||
|
"stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var poses = result.Raw.Plates.Single().Placements;
|
||||||
|
Assert.Equal(angle, poses[1].Rotation, 8);
|
||||||
|
var dx = poses[1].X - poses[0].X;
|
||||||
|
var dy = poses[1].Y - poses[0].Y;
|
||||||
|
var cosine = System.Math.Cos(angle);
|
||||||
|
var sine = System.Math.Sin(angle);
|
||||||
|
var localX = dx * cosine + dy * sine;
|
||||||
|
var localY = -dx * sine + dy * cosine;
|
||||||
|
foreach (var x in new[] { 0.0, 3.0 })
|
||||||
|
foreach (var y in new[] { 0.0, 3.0 })
|
||||||
|
{
|
||||||
|
Assert.InRange(localX + x, 5.25, 14.75);
|
||||||
|
Assert.InRange(localY + y, 5.25, 14.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeDemandLeavesExcessFramesAndInsertsIndependentAfterBoundedPreparation()
|
||||||
|
{
|
||||||
|
var frame = TestShapes.Rectangle(20, 20);
|
||||||
|
frame.MoveTo(5, 5);
|
||||||
|
frame.LineTo(5, 15);
|
||||||
|
frame.LineTo(15, 15);
|
||||||
|
frame.LineTo(15, 5);
|
||||||
|
frame.LineTo(5, 5);
|
||||||
|
var request = new NestPipelineRequest("stub", new[] {
|
||||||
|
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 40,
|
||||||
|
StepAngle = 1, RotationStart = 0, RotationEnd = 0 },
|
||||||
|
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(6, 6)),
|
||||||
|
Quantity = 40, StepAngle = 1, RotationStart = 0, RotationEnd = 0 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 40, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
Assert.Equal(32, job.Parts.Count(p => p.Id.StartsWith("__cutout-proxy-")));
|
||||||
|
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-0").Quantity);
|
||||||
|
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-1").Quantity);
|
||||||
|
return Result(job);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((40, 0, 40),
|
||||||
|
(f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OrdinaryPipelineIsStillUnchanged()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.Run(new Stub(job =>
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
return Result(job);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
using OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||||
|
|
||||||
|
public class CutoutRouterTests
|
||||||
|
{
|
||||||
|
private static NestJobPart Frame()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
program.MoveTo(5, 5);
|
||||||
|
program.LineTo(5, 15);
|
||||||
|
program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, 5);
|
||||||
|
program.LineTo(5, 5);
|
||||||
|
return JobBuilder.Part("frame", program, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SmallLatticeAndLargeResidualKeepTheirOriginalRequirementIds()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var small = JobBuilder.Part("small", TestShapes.Rectangle(1, 1), 3);
|
||||||
|
var large = JobBuilder.Rectangle("large", 3, 3, 1, RotationPolicy.Fixed(0));
|
||||||
|
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25,
|
||||||
|
(pose, lattice) => origins.Add((pose, lattice)));
|
||||||
|
Assert.Equal(3, poses.Count(p => p.PartId == small.Id));
|
||||||
|
Assert.Single(poses, p => p.PartId == large.Id);
|
||||||
|
Assert.Contains(origins, row => row.Pose.PartId == small.Id && row.Lattice);
|
||||||
|
Assert.Contains(origins, row => row.Pose.PartId == large.Id && !row.Lattice);
|
||||||
|
Assert.Equal(poses, origins.Select(row => row.Pose));
|
||||||
|
Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == small.Id).Select(p => p.InstanceIndex));
|
||||||
|
AssertPhysical(frame, new[] { small, large }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImpossibleRemainderNeverDisplacesCertifiedCopies()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var small = JobBuilder.Rectangle("small", 1, 1, 3, RotationPolicy.Fixed(0));
|
||||||
|
var impossible = JobBuilder.Rectangle("impossible", 9.6, 9.6, 1, RotationPolicy.Fixed(0));
|
||||||
|
var together = CutoutRouter.Fill(frame, 0, new[] { small, impossible }, 0.25);
|
||||||
|
Assert.Equal(3, together.Count);
|
||||||
|
Assert.All(together, p => Assert.Equal(small.Id, p.PartId));
|
||||||
|
Assert.Equal(Enumerable.Range(0, 3), together.Select(p => p.InstanceIndex));
|
||||||
|
AssertPhysical(frame, new[] { small, impossible }, together);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PriorCopiesAndQuantityCannotBeBypassedByResidualSearch()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var small = JobBuilder.Rectangle("small", 2, 2, 4, RotationPolicy.Fixed(0));
|
||||||
|
var large = JobBuilder.Rectangle("large", 7, 7, 1, RotationPolicy.Fixed(0));
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25);
|
||||||
|
Assert.InRange(poses.Count(p => p.PartId == small.Id), 0, small.Quantity);
|
||||||
|
Assert.InRange(poses.Count(p => p.PartId == large.Id), 0, large.Quantity);
|
||||||
|
AssertPhysical(frame, new[] { small, large }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResidualNfpDoesNotOverlapTheFirstLargeInsert()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var insert = JobBuilder.Rectangle("large", 6, 6, 2, RotationPolicy.Fixed(0));
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
|
||||||
|
Assert.Single(poses);
|
||||||
|
AssertPhysical(frame, new[] { insert }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NfpAddsOneMoreCopyToPartialShiftedLattice()
|
||||||
|
{
|
||||||
|
// Neutral 8-inch round hole, 2x1 insert, quarter-inch spacing: the shifted
|
||||||
|
// lattice keeps 11; an independently certified NFP pose occupies its leftover.
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
|
||||||
|
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12);
|
||||||
|
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25,
|
||||||
|
(pose, lattice) => origins.Add((pose, lattice)));
|
||||||
|
Assert.Equal(12, poses.Count);
|
||||||
|
Assert.Equal(11, origins.Count(row => row.Lattice));
|
||||||
|
Assert.Single(origins, row => !row.Lattice);
|
||||||
|
Assert.Equal(poses, origins.Select(row => row.Pose));
|
||||||
|
Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex));
|
||||||
|
AssertPhysical(frame, new[] { insert }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MiddleRatioChoosesTwoLatticeCopiesOverOneNfpCopy()
|
||||||
|
{
|
||||||
|
// 4x4 / (pi*5^2) = 0.204. A hard NFP-only threshold at 0.20 loses one copy.
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(4, 4), 3);
|
||||||
|
Assert.InRange(16 / (System.Math.PI * 25), CutoutRouter.SmallRatio, CutoutRouter.LargeRatio);
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
|
||||||
|
Assert.Equal(2, poses.Count);
|
||||||
|
AssertPhysical(frame, new[] { insert }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeRatioKeepsAnNfpPoseWhenFillCannotProduceALattice()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 4.5, 4.5, 3, RotationPolicy.Fixed(0));
|
||||||
|
Assert.True(20.25 / (System.Math.PI * 16) > CutoutRouter.LargeRatio);
|
||||||
|
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, insert, 3, 0.25));
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
|
||||||
|
Assert.Single(poses);
|
||||||
|
AssertPhysical(frame, new[] { insert }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateRequirementIdsAreRejectedBeforePlacement()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var insert = JobBuilder.Rectangle("same", 1, 1, 3);
|
||||||
|
Assert.Throws<ArgumentException>(() => CutoutRouter.Fill(frame, 0,
|
||||||
|
new[] { insert, insert }, 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OccupiedAwareLatticeFillsAfterAnEarlierCopyBlocksItsFirstPose()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var first = JobBuilder.Part("first", TestShapes.Rectangle(1, 1), 1);
|
||||||
|
var more = JobBuilder.Part("more", TestShapes.Rectangle(1, 1), 3);
|
||||||
|
var early = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25);
|
||||||
|
Assert.Equal(3, early.Count);
|
||||||
|
var blocked = early[0] with { PartId = first.Id };
|
||||||
|
var occupied = new[] { (JobPartGeometry.Read(first.Geometry), blocked) };
|
||||||
|
var later = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25,
|
||||||
|
CutoutLatticeFill.DefaultShiftSteps, occupied, default);
|
||||||
|
Assert.Equal(3, later.Count);
|
||||||
|
Assert.All(later, p => Assert.True(NestLayoutCheck.Clears(occupied[0].Item1, blocked,
|
||||||
|
JobPartGeometry.Read(more.Geometry), p, 0.25)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TinyInsertDoesNotAllocateAnUnboundedLattice()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var tiny = JobBuilder.Rectangle("tiny", 0.001, 0.001, 3, RotationPolicy.Fixed(0));
|
||||||
|
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, tiny, 3, 0));
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { tiny }, 0);
|
||||||
|
Assert.Equal(3, poses.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationIsNotNoFit()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
Assert.Throws<OperationCanceledException>(() => CutoutRouter.Fill(Frame(), 0,
|
||||||
|
new[] { JobBuilder.Rectangle("small", 1, 1, 3) }, 0.25, cts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertPhysical(NestJobPart frame, NestJobPart[] inserts,
|
||||||
|
IReadOnlyList<NestJobPlacement> poses)
|
||||||
|
{
|
||||||
|
var stock = JobBuilder.Stock("sheet", 22, 22, 0.25);
|
||||||
|
var job = JobBuilder.Job(new[] { frame }.Concat(inserts).ToArray(), new[] { stock });
|
||||||
|
var frameBounds = JobPartGeometry.Read(frame.Geometry).Bounds;
|
||||||
|
var offsetX = 1 - frameBounds.Left;
|
||||||
|
var offsetY = 1 - frameBounds.Bottom;
|
||||||
|
var placements = new[] { new NestJobPlacement(frame.Id, 0, offsetX, offsetY, 0) }
|
||||||
|
.Concat(poses.Select(p => p with { X = p.X + offsetX, Y = p.Y + offsetY })).ToArray();
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, stock, placements) },
|
||||||
|
new[] { new PartFulfillment(frame.Id, 1, 1, 0) }
|
||||||
|
.Concat(inserts.Select(part => new PartFulfillment(part.Id, part.Quantity,
|
||||||
|
poses.Count(p => p.PartId == part.Id), part.Quantity - poses.Count(p => p.PartId == part.Id))))
|
||||||
|
.ToArray(),
|
||||||
|
new[] { new StockUsage(stock.Id, 1, null) });
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
|
||||||
|
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
||||||
|
foreach (var pose in poses)
|
||||||
|
{
|
||||||
|
var geometry = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry);
|
||||||
|
Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, 0.25));
|
||||||
|
// Independent square-wall oracle: every corner of each rectangular insert clears the hole.
|
||||||
|
var bounds = geometry.Bounds;
|
||||||
|
foreach (var (x, y) in new[] { (bounds.Left, bounds.Bottom), (bounds.Right, bounds.Bottom),
|
||||||
|
(bounds.Right, bounds.Top), (bounds.Left, bounds.Top) })
|
||||||
|
{
|
||||||
|
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
|
||||||
|
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
|
||||||
|
if (frameBounds.Left >= 0)
|
||||||
|
{
|
||||||
|
Assert.InRange(rx, 5.25, 14.75);
|
||||||
|
Assert.InRange(ry, 5.25, 14.75);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var radius = System.Math.Abs(frameGeometry.Cutouts[0].BoundingBox.Right);
|
||||||
|
Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= radius - 0.25 + 0.00001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Independent rectangle-vs-rectangle distance, not the production Clears predicate.
|
||||||
|
for (var i = 0; i < poses.Count; i++)
|
||||||
|
for (var j = i + 1; j < poses.Count; j++)
|
||||||
|
{
|
||||||
|
var a = Bounds(poses[i]);
|
||||||
|
var b = Bounds(poses[j]);
|
||||||
|
var dx = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right));
|
||||||
|
var dy = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top));
|
||||||
|
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.25 - 0.00001,
|
||||||
|
$"independent material gap too small: {poses[i]} vs {poses[j]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
(double Left, double Right, double Bottom, double Top) Bounds(NestJobPlacement pose)
|
||||||
|
{
|
||||||
|
var box = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry).Bounds;
|
||||||
|
var corners = new[] { (box.Left, box.Bottom), (box.Right, box.Bottom),
|
||||||
|
(box.Right, box.Top), (box.Left, box.Top) };
|
||||||
|
var x = corners.Select(c => pose.X + c.Item1 * System.Math.Cos(pose.Rotation)
|
||||||
|
- c.Item2 * System.Math.Sin(pose.Rotation)).ToArray();
|
||||||
|
var y = corners.Select(c => pose.Y + c.Item1 * System.Math.Sin(pose.Rotation)
|
||||||
|
+ c.Item2 * System.Math.Cos(pose.Rotation)).ToArray();
|
||||||
|
return (x.Min(), x.Max(), y.Min(), y.Max());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,73 @@ public class NestPipelineCommitTests
|
|||||||
Assert.Equal(2, Assert.Single(applied).Parts.Count);
|
Assert.Equal(2, Assert.Single(applied).Parts.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawingChangedAfterValidationIsRefusedBeforeCommitEvenWithConsent()
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
var empty = nest.CreatePlate();
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
var result = NestPipeline.Run(new PreMutationStub(), "stub",
|
||||||
|
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
drawing.Program = TestDrawingFactory.Rectangle(100, 100);
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
|
||||||
|
Assert.Same(empty, Assert.Single(nest.Plates));
|
||||||
|
Assert.Empty(empty.Parts);
|
||||||
|
Assert.Equal(0, drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PreMutationStub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (job.Parts[0].Id, 1.0, 1.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CancelDuringSnapshot(double x, double y, CancellationTokenSource source)
|
||||||
|
: OpenNest.CNC.LinearMove(x, y)
|
||||||
|
{
|
||||||
|
public override OpenNest.CNC.CodeType Type
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
source.Cancel();
|
||||||
|
return base.Type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationDuringFreshnessScanDoesNotMutateNest()
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
var empty = nest.CreatePlate();
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
var result = NestPipeline.Run(new PreMutationStub(), "stub",
|
||||||
|
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var proposed = Assert.Single(Assert.Single(result.Plates).Parts);
|
||||||
|
proposed.Program.Codes[1] = new CancelDuringSnapshot(10, 0, cts);
|
||||||
|
Assert.Throws<OperationCanceledException>(() =>
|
||||||
|
NestPipelineCommit.ApplyToEmptyPlates(result, manager, token: cts.Token));
|
||||||
|
Assert.True(cts.IsCancellationRequested);
|
||||||
|
Assert.Same(empty, Assert.Single(nest.Plates));
|
||||||
|
Assert.Empty(empty.Parts);
|
||||||
|
Assert.Equal(0, drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CancelledCommitDoesNotCreateAnyPlate()
|
public void CancelledCommitDoesNotCreateAnyPlate()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -258,6 +258,44 @@ public class NestPipelineTests
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawingChangedDuringSolveCannotBindGeometryOtherThanTheValidatedSnapshot()
|
||||||
|
{
|
||||||
|
var item = Item("bracket", 1);
|
||||||
|
var result = NestPipeline.Run(new StubEngine(job =>
|
||||||
|
{
|
||||||
|
item.Drawing.Program = TestDrawingFactory.Rectangle(100, 100);
|
||||||
|
return OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0));
|
||||||
|
}), "Mutator", Request("Mutator", item));
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("bracket"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TransientCloneMove(double x, double y) : LinearMove(x, y)
|
||||||
|
{
|
||||||
|
public override ICode Clone()
|
||||||
|
{
|
||||||
|
var before = EndPoint;
|
||||||
|
EndPoint = new Vector(100, 100);
|
||||||
|
try { return base.Clone(); }
|
||||||
|
finally { EndPoint = before; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BindingTransientDrawingEditCannotReturnOrCommitUncheckedGeometry()
|
||||||
|
{
|
||||||
|
var item = Item("bracket", 1);
|
||||||
|
item.Drawing.Program.Codes[^1] = new TransientCloneMove(0, 0);
|
||||||
|
var result = NestPipeline.Run(new StubEngine(job =>
|
||||||
|
OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0))),
|
||||||
|
"Transient", Request("Transient", item));
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("Bound part") && v.Contains("bracket"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CancellationPropagatesWithoutAResult()
|
public void CancellationPropagatesWithoutAResult()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ public sealed class CuttingPlanBatch
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const int ExpansionsPerPart = 400;
|
public const int ExpansionsPerPart = 400;
|
||||||
|
|
||||||
|
// Extra bounded work on hard/refused plates; the full-cap search still gets its whole budget.
|
||||||
|
internal const int FirstPassBudget = 1000;
|
||||||
|
internal const int FirstPassEntries = 8;
|
||||||
|
|
||||||
private readonly Entry[] entries;
|
private readonly Entry[] entries;
|
||||||
private readonly CuttingParameters ownedParameters;
|
private readonly CuttingParameters ownedParameters;
|
||||||
|
|
||||||
@@ -142,7 +146,7 @@ public sealed class CuttingPlanBatch
|
|||||||
if (entry.Reorder != null)
|
if (entry.Reorder != null)
|
||||||
{
|
{
|
||||||
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
|
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
|
||||||
reorder = CuttingPlanService.Plan(entry.Reorder, token);
|
reorder = PlanReorder(entry.Reorder, token);
|
||||||
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
|
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
|
||||||
{
|
{
|
||||||
plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap);
|
plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap);
|
||||||
@@ -156,6 +160,22 @@ public sealed class CuttingPlanBatch
|
|||||||
return new(plans, ownedParameters);
|
return new(plans, ownedParameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
|
||||||
|
int firstPassBudget = FirstPassBudget)
|
||||||
|
{
|
||||||
|
// A failed first pass is not a proof of impossibility: retain all original
|
||||||
|
// full-cap alternatives and the complete original budget for the retry.
|
||||||
|
if (snapshot.Failure == null && snapshot.Regeneration && snapshot.MaxEntries > FirstPassEntries
|
||||||
|
&& snapshot.ExpansionBudget > firstPassBudget)
|
||||||
|
{
|
||||||
|
var first = CuttingPlanService.Plan(snapshot.WithSearchLimits(firstPassBudget, FirstPassEntries), token);
|
||||||
|
if ((first.Status == CuttingPlanStatus.Ready && first.IndependentlyReplayed)
|
||||||
|
|| first.Status == CuttingPlanStatus.Cancelled)
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
return CuttingPlanService.Plan(snapshot, token);
|
||||||
|
}
|
||||||
|
|
||||||
private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token)
|
private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null)
|
if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null)
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ public sealed class CuttingPlanSnapshot
|
|||||||
public int ExpansionBudget { get; }
|
public int ExpansionBudget { get; }
|
||||||
internal CuttingPlanStatus? Failure { get; }
|
internal CuttingPlanStatus? Failure { get; }
|
||||||
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
|
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
|
||||||
|
|
||||||
|
// A worker-only search variant over the same owned capture. Never recapture the live plate:
|
||||||
|
// both proposals retain the identical freshness record and confirmed settings.
|
||||||
|
internal CuttingPlanSnapshot WithSearchLimits(int expansionBudget, int maxEntries) =>
|
||||||
|
new(Placements, StartPoint, expansionBudget, Failure, Findings, Regeneration,
|
||||||
|
PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters,
|
||||||
|
Dependencies, BestEffort);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class FixedProgramPlacement
|
public sealed class FixedProgramPlacement
|
||||||
|
|||||||
@@ -196,29 +196,39 @@ internal static class JointCuttingPlanSearch
|
|||||||
var progressExpansions = Expansions;
|
var progressExpansions = Expansions;
|
||||||
var stack = new Stack<Frame>();
|
var stack = new Stack<Frame>();
|
||||||
stack.Push(new(root));
|
stack.Push(new(root));
|
||||||
while (stack.Count != 0)
|
try
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
while (stack.Count != 0)
|
||||||
var frame = stack.Peek();
|
|
||||||
var node = frame.Node;
|
|
||||||
if (node.Order.Length == snapshot.Placements.Count)
|
|
||||||
{
|
{
|
||||||
attempt.Order = node.Order;
|
token.ThrowIfCancellationRequested();
|
||||||
return attempt;
|
var frame = stack.Peek();
|
||||||
|
var node = frame.Node;
|
||||||
|
if (node.Order.Length == snapshot.Placements.Count)
|
||||||
|
{
|
||||||
|
attempt.Order = node.Order;
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
if (attempt.Advance(node))
|
||||||
|
progressExpansions = Expansions;
|
||||||
|
else if (stall is int limit && Expansions - progressExpansions > limit)
|
||||||
|
return attempt;
|
||||||
|
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly).GetEnumerator();
|
||||||
|
if (!frame.Children.MoveNext())
|
||||||
|
{
|
||||||
|
stack.Pop().Children.Dispose();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stack.Push(new(frame.Children.Current.Node));
|
||||||
}
|
}
|
||||||
if (attempt.Advance(node))
|
return attempt;
|
||||||
progressExpansions = Expansions;
|
}
|
||||||
else if (stall is int limit && Expansions - progressExpansions > limit)
|
finally
|
||||||
return attempt;
|
{
|
||||||
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly);
|
// Ready, stalled, cancelled and budget-exhausted searches can all leave
|
||||||
if (frame.Next == frame.Children.Length)
|
// suspended siblings. Release their captured prefixes on every exit.
|
||||||
{
|
foreach (var frame in stack)
|
||||||
stack.Pop();
|
frame.Children?.Dispose();
|
||||||
continue;
|
|
||||||
}
|
|
||||||
stack.Push(new(frame.Children[frame.Next++].Node));
|
|
||||||
}
|
}
|
||||||
return attempt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -259,8 +269,14 @@ internal static class JointCuttingPlanSearch
|
|||||||
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
|
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
|
||||||
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
|
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
|
private IEnumerable<Edge> OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
|
||||||
{
|
{
|
||||||
|
// A prescribed next part (or an active part's next contour) has a single
|
||||||
|
// source. Expand already yields its contour/entry rank order, so emit and
|
||||||
|
// validate a sibling only when DFS reaches it. Free whole-part selection
|
||||||
|
// still needs every source's distances before it can rank them.
|
||||||
|
if (sequence != null || node.Active != null)
|
||||||
|
return Expand(node, sequence, attempt, preferredOnly);
|
||||||
var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
|
var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
|
||||||
if (edges.Count <= 1)
|
if (edges.Count <= 1)
|
||||||
return edges.ToArray();
|
return edges.ToArray();
|
||||||
@@ -334,8 +350,10 @@ internal static class JointCuttingPlanSearch
|
|||||||
// First try one ranked hole chain per endpoint. A later-part failure then
|
// First try one ranked hole chain per endpoint. A later-part failure then
|
||||||
// changes the endpoint before replaying all earlier hole combinations.
|
// changes the endpoint before replaying all earlier hole combinations.
|
||||||
// The retained pass below still searches every entry and hole order.
|
// The retained pass below still searches every entry and hole order.
|
||||||
|
if (preference != null)
|
||||||
|
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).ThenBy(c => c);
|
||||||
if (preferredOnly && preference != null)
|
if (preferredOnly && preference != null)
|
||||||
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1);
|
contours = contours.Take(1);
|
||||||
foreach (var contour in contours)
|
foreach (var contour in contours)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -593,7 +611,6 @@ internal static class JointCuttingPlanSearch
|
|||||||
private sealed class Frame(Node node)
|
private sealed class Frame(Node node)
|
||||||
{
|
{
|
||||||
internal Node Node { get; } = node;
|
internal Node Node { get; } = node;
|
||||||
internal Edge[] Children { get; set; }
|
internal IEnumerator<Edge> Children { get; set; }
|
||||||
internal int Next { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,20 +22,24 @@ namespace OpenNest.Engine.Jobs.Cutouts;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement.
|
/// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement.
|
||||||
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Not wired into
|
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Used by the
|
||||||
/// any engine or pipeline yet: placing parts in cutouts waits on containment-aware cutting order.
|
/// internal pipeline cutout preview, but not enabled for normal whole-job calls;
|
||||||
|
/// production placement waits on cutting/post safety.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static class CutoutLatticeFill
|
internal static class CutoutLatticeFill
|
||||||
{
|
{
|
||||||
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
|
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
|
||||||
internal const int DefaultShiftSteps = 8;
|
internal const int DefaultShiftSteps = 8;
|
||||||
|
|
||||||
private const double FlattenTolerance = 0.001;
|
internal const double FlattenTolerance = 0.001;
|
||||||
|
|
||||||
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
|
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
|
||||||
private const double Margin = FlattenTolerance + 0.001;
|
private const double Margin = FlattenTolerance + 0.001;
|
||||||
|
|
||||||
private const int Precision = NestTolerances.ClipperPrecision;
|
private const int Precision = NestTolerances.ClipperPrecision;
|
||||||
|
// Fill materializes an entire grid before quantity trimming. Bound work even for a
|
||||||
|
// tiny insert with demand of only three, rather than allocating millions of clones.
|
||||||
|
private const int MaxLatticePositions = 1000;
|
||||||
|
|
||||||
/// <summary>Returns up to <paramref name="maxQuantity"/> insert poses inside the cutout,
|
/// <summary>Returns up to <paramref name="maxQuantity"/> insert poses inside the cutout,
|
||||||
/// or none when no copy fits.</summary>
|
/// or none when no copy fits.</summary>
|
||||||
@@ -45,9 +49,17 @@ internal static class CutoutLatticeFill
|
|||||||
|
|
||||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||||
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token)
|
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token)
|
||||||
|
=> Fill(frame, cutoutIndex, insert, maxQuantity, spacing, shiftSteps,
|
||||||
|
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token);
|
||||||
|
|
||||||
|
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||||
|
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps,
|
||||||
|
IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, CancellationToken token)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(frame);
|
ArgumentNullException.ThrowIfNull(frame);
|
||||||
ArgumentNullException.ThrowIfNull(insert);
|
ArgumentNullException.ThrowIfNull(insert);
|
||||||
|
ArgumentNullException.ThrowIfNull(occupied);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps);
|
ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps);
|
||||||
if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0)
|
if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0)
|
||||||
return Array.Empty<NestJobPlacement>();
|
return Array.Empty<NestJobPlacement>();
|
||||||
@@ -69,6 +81,10 @@ internal static class CutoutLatticeFill
|
|||||||
var bounds = cutout.BoundingBox;
|
var bounds = cutout.BoundingBox;
|
||||||
var insertBounds = insertGeometry.Bounds;
|
var insertBounds = insertGeometry.Bounds;
|
||||||
var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing;
|
var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing;
|
||||||
|
var estimated = (System.Math.Ceiling(bounds.Length / step) + 4)
|
||||||
|
* (System.Math.Ceiling(bounds.Width / step) + 4);
|
||||||
|
if (!(step > 0) || !double.IsFinite(estimated) || estimated > MaxLatticePositions)
|
||||||
|
return Array.Empty<NestJobPlacement>();
|
||||||
|
|
||||||
var lattice = Lattice(insert, bounds, step, spacing, token);
|
var lattice = Lattice(insert, bounds, step, spacing, token);
|
||||||
if (lattice.Count == 0)
|
if (lattice.Count == 0)
|
||||||
@@ -83,7 +99,8 @@ internal static class CutoutLatticeFill
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var (dx, dy) = Shift(step, shiftSteps, i, j);
|
var (dx, dy) = Shift(step, shiftSteps, i, j);
|
||||||
var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy));
|
var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy)
|
||||||
|
&& ClearsOccupied(p, dx, dy));
|
||||||
if (Better(count, i, j, best))
|
if (Better(count, i, j, best))
|
||||||
best = (count, i, j);
|
best = (count, i, j);
|
||||||
}
|
}
|
||||||
@@ -91,13 +108,33 @@ internal static class CutoutLatticeFill
|
|||||||
return Array.Empty<NestJobPlacement>();
|
return Array.Empty<NestJobPlacement>();
|
||||||
|
|
||||||
var (sx, sy) = Shift(step, shiftSteps, best.I, best.J);
|
var (sx, sy) = Shift(step, shiftSteps, best.I, best.J);
|
||||||
var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy))
|
var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy)
|
||||||
|
&& ClearsOccupied(p, sx, sy))
|
||||||
.Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8),
|
.Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8),
|
||||||
System.Math.Round(p.Y + sy, 8), p.Rotation))
|
System.Math.Round(p.Y + sy, 8), p.Rotation))
|
||||||
.OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation)
|
.OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation)
|
||||||
.ToList();
|
.ToList();
|
||||||
return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity)
|
return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity)
|
||||||
.Select((p, index) => p with { InstanceIndex = index }).ToArray();
|
.Select((p, index) => p with { InstanceIndex = index }).ToArray();
|
||||||
|
|
||||||
|
bool ClearsOccupied(NestJobPlacement pose, double dx, double dy)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (occupied.Count == 0)
|
||||||
|
return true;
|
||||||
|
var moved = pose with
|
||||||
|
{
|
||||||
|
X = System.Math.Round(pose.X + dx, 8),
|
||||||
|
Y = System.Math.Round(pose.Y + dy, 8)
|
||||||
|
};
|
||||||
|
foreach (var other in occupied)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (!NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, moved, spacing))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Fill over the cutout's bounds grown by one step on every side, in frame
|
/// <summary>Fill over the cutout's bounds grown by one step on every side, in frame
|
||||||
@@ -134,7 +171,7 @@ internal static class CutoutLatticeFill
|
|||||||
/// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline
|
/// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline
|
||||||
/// never meets the cutout boundary (t outside boundary + reflected outline).
|
/// never meets the cutout boundary (t outside boundary + reflected outline).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
|
internal static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
|
||||||
{
|
{
|
||||||
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
|
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
|
||||||
foreach (var entity in entities)
|
foreach (var entity in entities)
|
||||||
@@ -203,7 +240,7 @@ internal static class CutoutLatticeFill
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
|
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
|
||||||
private static bool Inside(PathsD region, double x, double y)
|
internal static bool Inside(PathsD region, double x, double y)
|
||||||
{
|
{
|
||||||
var point = new PointD(x, y);
|
var point = new PointD(x, y);
|
||||||
var inside = false;
|
var inside = false;
|
||||||
@@ -218,7 +255,7 @@ internal static class CutoutLatticeFill
|
|||||||
return inside;
|
return inside;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PathD Positive(PathD path)
|
internal static PathD Positive(PathD path)
|
||||||
{
|
{
|
||||||
if (path.Count >= 3 && !Clipper.IsPositive(path))
|
if (path.Count >= 3 && !Clipper.IsPositive(path))
|
||||||
path.Reverse();
|
path.Reverse();
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using Clipper2Lib;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
|
||||||
|
/// <summary>Test-only single-insert inner-fit proposal; never changes a job or a live plate.</summary>
|
||||||
|
internal static class CutoutNfpProposal
|
||||||
|
{
|
||||||
|
// Fixed chord tolerance in the shared round offset otherwise makes extremely large
|
||||||
|
// spacing values allocate an unbounded number of arc vertices. This conservative
|
||||||
|
// test-only kernel declines scales it cannot process safely.
|
||||||
|
private const double MaxCoordinate = 1e8;
|
||||||
|
private const double MaxSpacing = 100;
|
||||||
|
|
||||||
|
/// <summary>Returns one frame-local pose inside a closed cutout, or null if no pose is certified.
|
||||||
|
/// A null result is not a proof that the insert cannot fit elsewhere in this cutout.</summary>
|
||||||
|
internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert,
|
||||||
|
double spacing, CancellationToken token = default) =>
|
||||||
|
Find(frame, cutoutIndex, insert, spacing,
|
||||||
|
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token);
|
||||||
|
|
||||||
|
/// <summary>Find a pose clearing already accepted inserts as well as the enclosing frame.</summary>
|
||||||
|
internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert,
|
||||||
|
double spacing, IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(frame);
|
||||||
|
ArgumentNullException.ThrowIfNull(insert);
|
||||||
|
ArgumentNullException.ThrowIfNull(occupied);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (!double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing)
|
||||||
|
return null;
|
||||||
|
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
|
||||||
|
var insertGeometry = JobPartGeometry.TryRead(insert.Geometry);
|
||||||
|
if (frameGeometry == null || insertGeometry == null
|
||||||
|
|| !WithinRange(frameGeometry.Bounds) || !WithinRange(insertGeometry.Bounds))
|
||||||
|
return null;
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
|
||||||
|
var cutout = frameGeometry.Cutouts[cutoutIndex];
|
||||||
|
if (!cutout.IsClosed() || !WithinRange(cutout.BoundingBox)
|
||||||
|
|| spacing * 2 >= System.Math.Min(cutout.BoundingBox.Length, cutout.BoundingBox.Width))
|
||||||
|
return null;
|
||||||
|
var hole = CutoutLatticeFill.Positive(ClipperBridge.ToPath(
|
||||||
|
ClipperBridge.Flatten(cutout, CutoutLatticeFill.FlattenTolerance, circumscribe: false),
|
||||||
|
new Vector()));
|
||||||
|
if (hole.Count < 3)
|
||||||
|
return null;
|
||||||
|
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
||||||
|
foreach (var rotation in insert.Rotation.EnumerateAngles())
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var region = CutoutLatticeFill.InnerFit(insertGeometry, rotation, hole, spacing);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
foreach (var (x, y) in Candidates(region))
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (!CutoutLatticeFill.Inside(region, x, y))
|
||||||
|
continue;
|
||||||
|
var pose = new NestJobPlacement(insert.Id, 0, x, y, rotation);
|
||||||
|
if (!NestLayoutCheck.Clears(frameGeometry, framePose, insertGeometry, pose, spacing))
|
||||||
|
continue;
|
||||||
|
var clears = true;
|
||||||
|
foreach (var other in occupied)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, pose, spacing))
|
||||||
|
continue;
|
||||||
|
clears = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (clears)
|
||||||
|
return pose;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool WithinRange(Box box) =>
|
||||||
|
double.IsFinite(box.Left) && double.IsFinite(box.Right)
|
||||||
|
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
|
||||||
|
&& System.Math.Abs(box.Left) <= MaxCoordinate && System.Math.Abs(box.Right) <= MaxCoordinate
|
||||||
|
&& System.Math.Abs(box.Bottom) <= MaxCoordinate && System.Math.Abs(box.Top) <= MaxCoordinate;
|
||||||
|
|
||||||
|
/// <summary>Sample component interiors, then points just inside each boundary toward
|
||||||
|
/// its component's center. This bounded search is not a complete inner-fit solver.</summary>
|
||||||
|
private static IEnumerable<(double X, double Y)> Candidates(PathsD region)
|
||||||
|
{
|
||||||
|
foreach (var path in region.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
|
||||||
|
{
|
||||||
|
var bounds = Clipper.GetBounds(new PathsD { path });
|
||||||
|
var cx = (bounds.left + bounds.right) / 2;
|
||||||
|
var cy = (bounds.top + bounds.bottom) / 2;
|
||||||
|
yield return (cx, cy);
|
||||||
|
foreach (var point in path)
|
||||||
|
yield return (point.x * 0.99 + cx * 0.01, point.y * 0.99 + cy * 0.01);
|
||||||
|
// Bounded interior samples let a residual insert land beside a partial lattice;
|
||||||
|
// a center/vertex-only sample often lands directly on an earlier copy.
|
||||||
|
for (var y = 1; y <= 9; y++)
|
||||||
|
for (var x = 1; x <= 9; x++)
|
||||||
|
yield return (bounds.left + (bounds.right - bounds.left) * x / 10,
|
||||||
|
bounds.top + (bounds.bottom - bounds.top) * y / 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
|
||||||
|
/// <summary>Internal-only frame bundles. No normal pipeline caller enables this.</summary>
|
||||||
|
internal sealed class CutoutPipelinePrepass
|
||||||
|
{
|
||||||
|
// A job with thousands of repeats still offers the residual as ordinary demand;
|
||||||
|
// do not multiply expensive Clipper/Fill preparation by every physical instance.
|
||||||
|
private const int MaxBundlesPerJob = 32;
|
||||||
|
private sealed record Bundle(string FrameId, IReadOnlyList<NestJobPlacement> LocalInserts);
|
||||||
|
private readonly IReadOnlyDictionary<string, Bundle> bundles;
|
||||||
|
|
||||||
|
private CutoutPipelinePrepass(NestJob engineJob, IReadOnlyDictionary<string, Bundle> bundles)
|
||||||
|
{
|
||||||
|
EngineJob = engineJob;
|
||||||
|
this.bundles = bundles;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal NestJob EngineJob { get; }
|
||||||
|
|
||||||
|
internal static CutoutPipelinePrepass? Prepare(NestJob original, CancellationToken token)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (original.Plates.Count == 0)
|
||||||
|
return null;
|
||||||
|
// One reservation vector works for every offered stock. Current jobs cannot
|
||||||
|
// represent stock-conditional insert quantities; use the largest spacing.
|
||||||
|
var spacing = original.Plates.Max(p => p.PartSpacing);
|
||||||
|
var remaining = original.Parts.ToDictionary(p => p.Id, p => p.Quantity);
|
||||||
|
var geometry = original.Parts.ToDictionary(p => p.Id, p => JobPartGeometry.TryRead(p.Geometry));
|
||||||
|
var bundles = new Dictionary<string, Bundle>(StringComparer.Ordinal);
|
||||||
|
var byFrame = new Dictionary<string, List<NestJobPart>>(StringComparer.Ordinal);
|
||||||
|
foreach (var frame in original.Parts)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (bundles.Count >= MaxBundlesPerJob)
|
||||||
|
break;
|
||||||
|
var profile = geometry[frame.Id];
|
||||||
|
if (profile == null || profile.Cutouts.Count == 0)
|
||||||
|
continue;
|
||||||
|
var proxies = new List<NestJobPart>();
|
||||||
|
byFrame[frame.Id] = proxies;
|
||||||
|
for (var instance = 0; instance < frame.Quantity; instance++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (bundles.Count >= MaxBundlesPerJob)
|
||||||
|
break;
|
||||||
|
IReadOnlyList<NestJobPlacement>? chosen = null;
|
||||||
|
double frameAngle = 0;
|
||||||
|
// Outer-perimeter symmetry cannot deduplicate asymmetric cutouts.
|
||||||
|
foreach (var angle in frame.Rotation.EnumerateAngles(maxSamples: 16))
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (!FitsOfferedStock(frame, angle, original.Plates))
|
||||||
|
continue;
|
||||||
|
var local = new List<NestJobPlacement>();
|
||||||
|
var available = new Dictionary<string, int>(remaining);
|
||||||
|
for (var hole = 0; hole < profile.Cutouts.Count; hole++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
// Flat bundles: frames cannot simultaneously be inserts in another bundle.
|
||||||
|
var candidates = original.Parts
|
||||||
|
.Where(p => p.Id != frame.Id && available[p.Id] > 0
|
||||||
|
&& p.Priority == frame.Priority
|
||||||
|
&& geometry[p.Id] is { Cutouts.Count: 0 })
|
||||||
|
.Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id],
|
||||||
|
p.Priority, LocalRotation(p.Rotation, angle)))
|
||||||
|
.ToArray();
|
||||||
|
if (candidates.Length == 0)
|
||||||
|
break;
|
||||||
|
foreach (var pose in CutoutRouter.Fill(frame, hole, candidates, spacing, token))
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var source = geometry[pose.PartId]!;
|
||||||
|
var policy = original.Parts.Single(p => p.Id == pose.PartId).Rotation;
|
||||||
|
if (!policy.Allows(angle + pose.Rotation)
|
||||||
|
|| local.Any(other => !NestLayoutCheck.Clears(
|
||||||
|
geometry[other.PartId]!, other, source, pose, spacing)))
|
||||||
|
continue;
|
||||||
|
local.Add(pose);
|
||||||
|
available[pose.PartId]--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (local.Count <= (chosen?.Count ?? 0))
|
||||||
|
continue;
|
||||||
|
chosen = local.ToArray();
|
||||||
|
frameAngle = angle;
|
||||||
|
}
|
||||||
|
if (chosen == null || chosen.Count == 0)
|
||||||
|
break;
|
||||||
|
var proxyId = $"__cutout-proxy-{frame.Id}-{instance}";
|
||||||
|
if (remaining.ContainsKey(proxyId) || bundles.ContainsKey(proxyId))
|
||||||
|
throw new InvalidOperationException("Cutout proxy ID collides with a requirement.");
|
||||||
|
bundles.Add(proxyId, new Bundle(frame.Id, chosen));
|
||||||
|
proxies.Add(new NestJobPart(proxyId, frame.Geometry, 1, frame.Priority,
|
||||||
|
RotationPolicy.Fixed(frameAngle)));
|
||||||
|
remaining[frame.Id]--;
|
||||||
|
foreach (var pose in chosen)
|
||||||
|
remaining[pose.PartId]--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bundles.Count == 0)
|
||||||
|
return null;
|
||||||
|
var transformed = new List<NestJobPart>();
|
||||||
|
foreach (var part in original.Parts)
|
||||||
|
{
|
||||||
|
if (byFrame.TryGetValue(part.Id, out var proxies))
|
||||||
|
transformed.AddRange(proxies);
|
||||||
|
if (remaining[part.Id] > 0)
|
||||||
|
transformed.Add(new NestJobPart(part.Id, part.Geometry, remaining[part.Id],
|
||||||
|
part.Priority, part.Rotation));
|
||||||
|
}
|
||||||
|
return new CutoutPipelinePrepass(new NestJob(transformed, original.Plates,
|
||||||
|
original.Options), bundles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RotationPolicy LocalRotation(RotationPolicy original, double frameAngle) =>
|
||||||
|
original.Kind switch
|
||||||
|
{
|
||||||
|
RotationPolicyKind.Automatic => RotationPolicy.Automatic,
|
||||||
|
RotationPolicyKind.Fixed => RotationPolicy.Fixed(original.Start - frameAngle,
|
||||||
|
original.Allow180Equivalent),
|
||||||
|
_ => RotationPolicy.BoundedSweep(original.Start - frameAngle,
|
||||||
|
original.End - frameAngle, original.Step, original.Allow180Equivalent),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool FitsOfferedStock(NestJobPart frame, double angle,
|
||||||
|
IReadOnlyList<NestPlateStock> stock)
|
||||||
|
{
|
||||||
|
var part = new Part(DrawingJobMapper.CreateDrawing(frame));
|
||||||
|
part.Rotate(angle);
|
||||||
|
var bounds = NestLayoutCheck.MaterialBounds(part);
|
||||||
|
return stock.Any(s => s.Quantity != 0 && s.Fits(bounds.Length, bounds.Width,
|
||||||
|
NestTolerances.WorkAreaSlack));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reject untrusted accounting before expansion. A failed trial is never bindable,
|
||||||
|
/// including with allowInvalid. Only actually placed proxies consume reserved inserts.</summary>
|
||||||
|
internal bool TryExpand(NestJob original, NestJobResult result, CancellationToken token,
|
||||||
|
out NestJobResult? expanded,
|
||||||
|
out string failure)
|
||||||
|
{
|
||||||
|
expanded = null;
|
||||||
|
failure = "Invalid cutout engine accounting";
|
||||||
|
var parts = EngineJob.Parts.ToDictionary(p => p.Id);
|
||||||
|
var counts = parts.ToDictionary(p => p.Key, _ => 0);
|
||||||
|
var indices = parts.ToDictionary(p => p.Key, _ => new HashSet<int>());
|
||||||
|
var stockById = EngineJob.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal);
|
||||||
|
var used = stockById.ToDictionary(s => s.Key, _ => 0, StringComparer.Ordinal);
|
||||||
|
var expectedIndex = 0;
|
||||||
|
foreach (var sheet in result.Plates)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (sheet.PlateIndex != expectedIndex++ || sheet.Placements.Count == 0
|
||||||
|
|| !used.ContainsKey(sheet.StockId))
|
||||||
|
return false;
|
||||||
|
used[sheet.StockId]++;
|
||||||
|
foreach (var pose in sheet.Placements)
|
||||||
|
{
|
||||||
|
if (pose.PartId == null || !parts.ContainsKey(pose.PartId)
|
||||||
|
|| pose.InstanceIndex < 0 || !indices[pose.PartId].Add(pose.InstanceIndex)
|
||||||
|
|| ++counts[pose.PartId] > parts[pose.PartId].Quantity)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var row in indices)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (row.Value.Any(index => index >= counts[row.Key]))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (result.Fulfillment.Count != parts.Count || result.StockUsage.Count != used.Count)
|
||||||
|
return false;
|
||||||
|
var seenParts = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var row in result.Fulfillment)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (row.PartId == null || !parts.TryGetValue(row.PartId, out var part)
|
||||||
|
|| !seenParts.Add(row.PartId)
|
||||||
|
|| row.Requested != part.Quantity || row.Placed != counts[row.PartId]
|
||||||
|
|| row.Unplaced != part.Quantity - counts[row.PartId])
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var seenStock = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var row in result.StockUsage)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (row.StockId == null || !stockById.TryGetValue(row.StockId, out var stock)
|
||||||
|
|| !seenStock.Add(row.StockId)
|
||||||
|
|| row.Used != used[row.StockId]
|
||||||
|
|| row.Remaining != stock.Quantity - row.Used)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var complete = parts.Values.All(p => counts[p.Id] == p.Quantity);
|
||||||
|
if (result.Status != (complete ? NestJobStatus.Complete : NestJobStatus.Incomplete)
|
||||||
|
|| (complete && result.StopReason != NestJobStopReason.Completed)
|
||||||
|
|| (!complete && result.StopReason == NestJobStopReason.Completed))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var builder = new NestJobResultBuilder(original);
|
||||||
|
foreach (var sheet in result.Plates)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var poses = new List<(string PartId, double X, double Y, double Rotation)>();
|
||||||
|
foreach (var pose in sheet.Placements)
|
||||||
|
{
|
||||||
|
if (!bundles.TryGetValue(pose.PartId, out var bundle))
|
||||||
|
{
|
||||||
|
poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
poses.Add((bundle.FrameId, pose.X, pose.Y, pose.Rotation));
|
||||||
|
var sine = System.Math.Sin(pose.Rotation);
|
||||||
|
var cosine = System.Math.Cos(pose.Rotation);
|
||||||
|
foreach (var local in bundle.LocalInserts)
|
||||||
|
poses.Add((local.PartId,
|
||||||
|
pose.X + local.X * cosine - local.Y * sine,
|
||||||
|
pose.Y + local.X * sine + local.Y * cosine,
|
||||||
|
pose.Rotation + local.Rotation));
|
||||||
|
}
|
||||||
|
// Canonical stock reference, not an engine-supplied object with altered settings.
|
||||||
|
var stock = original.Plates.Single(s => s.Id == sheet.StockId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
builder.AddSheet(stock, poses);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
expanded = builder.Build(result.StopReason);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
|
||||||
|
/// <summary>Internal frame-local cutout proposal. Never changes demand, stock or a live plate.</summary>
|
||||||
|
/// <remarks>Area ratio selects search order, not placement permission. The small-insert path
|
||||||
|
/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search;
|
||||||
|
/// the large-insert path tries NFP first. All accepted poses are clearance-checked.
|
||||||
|
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
|
||||||
|
internal static class CutoutRouter
|
||||||
|
{
|
||||||
|
// Calibrated as search-order hints, not hard geometry cutoffs. Round-hole neutral tests
|
||||||
|
// show a Fill win at 0.204, so the original proposed >0.20 NFP-only rule loses copies.
|
||||||
|
internal const double SmallRatio = 0.10;
|
||||||
|
internal const double LargeRatio = 0.35;
|
||||||
|
|
||||||
|
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||||
|
IReadOnlyList<NestJobPart> inserts, double spacing, CancellationToken token = default) =>
|
||||||
|
Fill(frame, cutoutIndex, inserts, spacing, null, token);
|
||||||
|
|
||||||
|
/// <summary>Optional internal diagnostic receives only poses from the chosen proposal,
|
||||||
|
/// after indexing; true marks a retained lattice seed, false a residual NFP pose.</summary>
|
||||||
|
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||||
|
IReadOnlyList<NestJobPart> inserts, double spacing,
|
||||||
|
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(frame);
|
||||||
|
ArgumentNullException.ThrowIfNull(inserts);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (!double.IsFinite(spacing) || spacing < 0 || spacing > 100)
|
||||||
|
return Array.Empty<NestJobPlacement>();
|
||||||
|
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
|
||||||
|
if (frameGeometry == null)
|
||||||
|
return Array.Empty<NestJobPlacement>();
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
|
||||||
|
var hole = frameGeometry.Cutouts[cutoutIndex];
|
||||||
|
if (!hole.IsClosed() || !Supported(frameGeometry.Bounds) || !Supported(hole.BoundingBox))
|
||||||
|
return Array.Empty<NestJobPlacement>();
|
||||||
|
if (inserts.Any(p => p == null || !SupportedGeometry(p)))
|
||||||
|
return Array.Empty<NestJobPlacement>();
|
||||||
|
if (inserts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != inserts.Count
|
||||||
|
|| inserts.Any(p => p.Id == frame.Id))
|
||||||
|
throw new ArgumentException("Requirement IDs must be distinct from the frame and each other.",
|
||||||
|
nameof(inserts));
|
||||||
|
|
||||||
|
var accepted = new List<NestJobPlacement>();
|
||||||
|
var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
|
||||||
|
var holeArea = System.Math.Abs(hole.Area());
|
||||||
|
// Preserve caller requirement order: priorities are a whole-job concern, not inferred here.
|
||||||
|
foreach (var part in inserts)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var geometry = JobPartGeometry.Read(part.Geometry);
|
||||||
|
var ratio = geometry.MaterialArea / holeArea;
|
||||||
|
// Unsupported or degenerate material cannot establish a useful routing ratio;
|
||||||
|
// NFP retains its own fail-closed checks and the bounded lattice is not tried.
|
||||||
|
var tryLattice = part.Quantity >= 3 && double.IsFinite(ratio) && ratio > 0;
|
||||||
|
var nfpFirst = !tryLattice || ratio >= LargeRatio;
|
||||||
|
var compareBoth = tryLattice && ratio >= SmallRatio && ratio < LargeRatio;
|
||||||
|
var nfp = nfpFirst ? Nfp(Array.Empty<NestJobPlacement>()) : null;
|
||||||
|
var lattice = tryLattice && (!nfpFirst || nfp!.Count < part.Quantity)
|
||||||
|
? CutoutLatticeFill.Fill(frame, cutoutIndex, part, part.Quantity, spacing,
|
||||||
|
CutoutLatticeFill.DefaultShiftSteps, occupied, token)
|
||||||
|
: Array.Empty<NestJobPlacement>();
|
||||||
|
IReadOnlyList<NestJobPlacement> chosen;
|
||||||
|
var retainedLattice = 0;
|
||||||
|
if (compareBoth || (nfpFirst && lattice.Count > 0))
|
||||||
|
{
|
||||||
|
nfp ??= Nfp(Array.Empty<NestJobPlacement>());
|
||||||
|
var fromLattice = Complete(lattice);
|
||||||
|
chosen = fromLattice.Count > nfp.Count ? fromLattice : nfp;
|
||||||
|
if (ReferenceEquals(chosen, fromLattice))
|
||||||
|
retainedLattice = lattice.Count;
|
||||||
|
}
|
||||||
|
else if (nfpFirst)
|
||||||
|
chosen = nfp!;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// A single Fill copy ties one NFP pose; prefer NFP's stable candidate
|
||||||
|
// order, but retain the lattice copy if bounded NFP found nothing.
|
||||||
|
var seed = lattice.Count > 1 ? lattice : Array.Empty<NestJobPlacement>();
|
||||||
|
chosen = Complete(seed);
|
||||||
|
if (chosen.Count == 0 && lattice.Count == 1)
|
||||||
|
chosen = lattice;
|
||||||
|
retainedLattice = ReferenceEquals(chosen, lattice) ? lattice.Count : seed.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
var localIndex = 0;
|
||||||
|
foreach (var pose in chosen)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var indexed = pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) };
|
||||||
|
accepted.Add(indexed);
|
||||||
|
occupied.Add((geometry, indexed));
|
||||||
|
observeAccepted?.Invoke(indexed, localIndex++ < retainedLattice);
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<NestJobPlacement> Complete(IReadOnlyList<NestJobPlacement> seed)
|
||||||
|
{
|
||||||
|
if (seed.Count >= part.Quantity)
|
||||||
|
return seed;
|
||||||
|
var result = new List<NestJobPlacement>(seed);
|
||||||
|
result.AddRange(Nfp(seed));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<NestJobPlacement> Nfp(IReadOnlyList<NestJobPlacement> seed)
|
||||||
|
{
|
||||||
|
var result = new List<NestJobPlacement>();
|
||||||
|
var local = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>(occupied);
|
||||||
|
local.AddRange(seed.Select(p => (geometry, p)));
|
||||||
|
while (seed.Count + result.Count < part.Quantity)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var pose = CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, local, token);
|
||||||
|
if (pose == null)
|
||||||
|
break;
|
||||||
|
result.Add(pose);
|
||||||
|
local.Add((geometry, pose));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
return accepted.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SupportedGeometry(NestJobPart part)
|
||||||
|
{
|
||||||
|
var geometry = JobPartGeometry.TryRead(part.Geometry);
|
||||||
|
return geometry != null && Supported(geometry.Bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Supported(OpenNest.Geometry.Box box) =>
|
||||||
|
double.IsFinite(box.Left) && double.IsFinite(box.Right)
|
||||||
|
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
|
||||||
|
&& System.Math.Abs(box.Left) <= 1e8 && System.Math.Abs(box.Right) <= 1e8
|
||||||
|
&& System.Math.Abs(box.Bottom) <= 1e8 && System.Math.Abs(box.Top) <= 1e8;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using System.Diagnostics;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest.Engine.Jobs.Adapters;
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
|
||||||
namespace OpenNest.Engine.Jobs;
|
namespace OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
@@ -36,7 +37,8 @@ public sealed class NestPipelineResult
|
|||||||
IReadOnlyList<string> violations,
|
IReadOnlyList<string> violations,
|
||||||
bool canKeep,
|
bool canKeep,
|
||||||
TimeSpan solveTime,
|
TimeSpan solveTime,
|
||||||
TimeSpan validationTime
|
TimeSpan validationTime,
|
||||||
|
IReadOnlyDictionary<string, Drawing> drawingsByPartId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
EngineName = engineName;
|
EngineName = engineName;
|
||||||
@@ -47,6 +49,7 @@ public sealed class NestPipelineResult
|
|||||||
CanKeep = canKeep;
|
CanKeep = canKeep;
|
||||||
SolveTime = solveTime;
|
SolveTime = solveTime;
|
||||||
ValidationTime = validationTime;
|
ValidationTime = validationTime;
|
||||||
|
DrawingsByPartId = drawingsByPartId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string EngineName { get; }
|
public string EngineName { get; }
|
||||||
@@ -62,6 +65,7 @@ public sealed class NestPipelineResult
|
|||||||
public NestJobStopReason StopReason => Raw.StopReason;
|
public NestJobStopReason StopReason => Raw.StopReason;
|
||||||
public TimeSpan SolveTime { get; }
|
public TimeSpan SolveTime { get; }
|
||||||
public TimeSpan ValidationTime { get; }
|
public TimeSpan ValidationTime { get; }
|
||||||
|
internal IReadOnlyDictionary<string, Drawing> DrawingsByPartId { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -72,6 +76,15 @@ public sealed class NestPipelineResult
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class NestPipeline
|
public static class NestPipeline
|
||||||
{
|
{
|
||||||
|
private sealed class PreviewProgress(IProgress<NestJobProgress> inner) : IProgress<NestJobProgress>
|
||||||
|
{
|
||||||
|
public void Report(NestJobProgress value)
|
||||||
|
{
|
||||||
|
if (value != null && value.Stage == NestJobStage.EvaluatingCandidate)
|
||||||
|
inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
|
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
|
||||||
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
|
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
|
||||||
/// Engine cancellation propagates unchanged.</summary>
|
/// Engine cancellation propagates unchanged.</summary>
|
||||||
@@ -93,6 +106,32 @@ public static class NestPipeline
|
|||||||
NestPipelineRequest request,
|
NestPipelineRequest request,
|
||||||
IProgress<NestJobProgress> progress = null,
|
IProgress<NestJobProgress> progress = null,
|
||||||
CancellationToken token = default
|
CancellationToken token = default
|
||||||
|
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false);
|
||||||
|
|
||||||
|
/// <summary>Internal test-only integration trial. Normal Run never prepares cutouts;
|
||||||
|
/// this entry point must not be called by production front ends before cutting/post gates.</summary>
|
||||||
|
internal static NestPipelineResult RunCutoutPreview(
|
||||||
|
INestingEngine engine,
|
||||||
|
string engineName,
|
||||||
|
NestPipelineRequest request,
|
||||||
|
IProgress<NestJobProgress> progress = null,
|
||||||
|
CancellationToken token = default
|
||||||
|
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: true);
|
||||||
|
|
||||||
|
internal static NestPipelineResult RunCutoutPreview(
|
||||||
|
NestPipelineRequest request,
|
||||||
|
IProgress<NestJobProgress> progress = null,
|
||||||
|
CancellationToken token = default
|
||||||
|
) => RunCutoutPreview(NestingEngineRegistry.Create(request.EngineName),
|
||||||
|
request.EngineName, request, progress, token);
|
||||||
|
|
||||||
|
private static NestPipelineResult RunCore(
|
||||||
|
INestingEngine engine,
|
||||||
|
string engineName,
|
||||||
|
NestPipelineRequest request,
|
||||||
|
IProgress<NestJobProgress> progress,
|
||||||
|
CancellationToken token,
|
||||||
|
bool cutoutPreview
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(engine);
|
ArgumentNullException.ThrowIfNull(engine);
|
||||||
@@ -115,10 +154,15 @@ public static class NestPipeline
|
|||||||
|
|
||||||
var job = new NestJob(parts, request.Stock, request.Options);
|
var job = new NestJob(parts, request.Stock, request.Options);
|
||||||
NestJobValidator.Validate(job);
|
NestJobValidator.Validate(job);
|
||||||
|
var prepass = cutoutPreview ? CutoutPipelinePrepass.Prepare(job, token) : null;
|
||||||
|
var engineJob = prepass?.EngineJob ?? job;
|
||||||
|
if (prepass != null)
|
||||||
|
NestJobValidator.Validate(engineJob);
|
||||||
|
|
||||||
var clock = Stopwatch.StartNew();
|
var clock = Stopwatch.StartNew();
|
||||||
var raw =
|
var raw =
|
||||||
engine.Solve(job, progress, token)
|
engine.Solve(engineJob, prepass == null || progress == null
|
||||||
|
? progress : new PreviewProgress(progress), token)
|
||||||
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
|
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
|
||||||
var solveTime = clock.Elapsed;
|
var solveTime = clock.Elapsed;
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -129,7 +173,33 @@ public static class NestPipeline
|
|||||||
kv => kv.Value.Name ?? kv.Key,
|
kv => kv.Value.Name ?? kv.Key,
|
||||||
StringComparer.Ordinal
|
StringComparer.Ordinal
|
||||||
);
|
);
|
||||||
var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep);
|
var checkedNames = prepass == null ? names : engineJob.Parts.ToDictionary(p => p.Id,
|
||||||
|
p => names.GetValueOrDefault(p.Id, p.Id), StringComparer.Ordinal);
|
||||||
|
var violations = NestLayoutCheck.Violations(engineJob, raw, checkedNames, out var canKeep).ToList();
|
||||||
|
if (prepass != null)
|
||||||
|
{
|
||||||
|
// A compound may be physically invalid even when its envelope is legal.
|
||||||
|
// Refuse every unverified trial, including with explicit invalid-result consent.
|
||||||
|
var failure = "Cutout engine output failed reconciliation";
|
||||||
|
if (violations.Count == 0 && prepass.TryExpand(job, raw, token, out var expanded, out failure))
|
||||||
|
{
|
||||||
|
raw = expanded;
|
||||||
|
violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep));
|
||||||
|
if (raw.Status != NestJobStatus.Complete)
|
||||||
|
violations.Add("Cutout engine did not complete the transformed job; unplaced frames' reserved inserts remain unplaced in the original job");
|
||||||
|
canKeep = physicalKeep && violations.Count == 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (violations.Count == 0)
|
||||||
|
violations.Add(failure);
|
||||||
|
canKeep = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var freshness = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId);
|
||||||
|
violations.AddRange(freshness);
|
||||||
|
if (freshness.Count > 0)
|
||||||
|
canKeep = false;
|
||||||
var validationTime = clock.Elapsed;
|
var validationTime = clock.Elapsed;
|
||||||
|
|
||||||
var plates = canKeep
|
var plates = canKeep
|
||||||
@@ -141,6 +211,31 @@ public static class NestPipeline
|
|||||||
.ToList()
|
.ToList()
|
||||||
: new List<ProposedPlate>();
|
: new List<ProposedPlate>();
|
||||||
|
|
||||||
|
// Binding clones the caller's current Program; a concurrent edit during binding
|
||||||
|
// must not return parts whose bytes differ from the already validated snapshot.
|
||||||
|
if (canKeep)
|
||||||
|
{
|
||||||
|
var changes = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId).ToList();
|
||||||
|
changes.AddRange(NestPipelineDrawingFreshness.BoundChanges(job, raw, plates, drawingsByPartId));
|
||||||
|
if (changes.Count > 0)
|
||||||
|
{
|
||||||
|
violations.AddRange(changes);
|
||||||
|
plates.Clear();
|
||||||
|
canKeep = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (prepass != null && canKeep)
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
for (var i = 0; i < raw.Plates.Count; i++)
|
||||||
|
{
|
||||||
|
count += raw.Plates[i].Placements.Count;
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
|
||||||
|
raw.Plates[i].StockId, i, i + 1, count));
|
||||||
|
}
|
||||||
|
}
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
return new NestPipelineResult(
|
return new NestPipelineResult(
|
||||||
engineName,
|
engineName,
|
||||||
@@ -150,7 +245,8 @@ public static class NestPipeline
|
|||||||
violations,
|
violations,
|
||||||
canKeep,
|
canKeep,
|
||||||
solveTime,
|
solveTime,
|
||||||
validationTime
|
validationTime,
|
||||||
|
drawingsByPartId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,11 @@ public static class NestPipelineCommit
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (!result.CanKeep || (!result.IsValid && !allowInvalid))
|
if (!result.CanKeep || (!result.IsValid && !allowInvalid))
|
||||||
throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations.");
|
throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations.");
|
||||||
|
if (NestPipelineDrawingFreshness.Changes(result.Job, result.DrawingsByPartId).Count > 0
|
||||||
|
|| NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
|
||||||
|
result.Plates, result.DrawingsByPartId).Count > 0)
|
||||||
|
throw new InvalidOperationException("A drawing or proposed part changed after the nesting proposal was validated; run Auto Nest again.");
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var applied = new List<Plate>();
|
var applied = new List<Plate>();
|
||||||
// Commit is synchronous on the caller's owning thread. Cancellation is checked
|
// Commit is synchronous on the caller's owning thread. Cancellation is checked
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
|
/// <summary>Refuse a proposal when a caller drawing no longer matches its checked snapshot.</summary>
|
||||||
|
internal static class NestPipelineDrawingFreshness
|
||||||
|
{
|
||||||
|
internal static IReadOnlyList<string> Changes(NestJob job,
|
||||||
|
IReadOnlyDictionary<string, Drawing> drawings)
|
||||||
|
{
|
||||||
|
var changed = new List<string>();
|
||||||
|
foreach (var requirement in job.Parts)
|
||||||
|
{
|
||||||
|
if (!drawings.TryGetValue(requirement.Id, out var drawing)
|
||||||
|
|| drawing?.Program == null)
|
||||||
|
{
|
||||||
|
changed.Add($"Drawing for '{requirement.Id}' changed after the nesting snapshot");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var current = PartGeometrySnapshot.FromProgram(drawing.Program);
|
||||||
|
if (Same(requirement.Geometry, current))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
|
||||||
|
or InvalidOperationException)
|
||||||
|
{
|
||||||
|
// A now-unsupported program is not the geometry that was checked.
|
||||||
|
}
|
||||||
|
changed.Add($"Drawing '{drawing.Name ?? requirement.Id}' changed after the nesting snapshot");
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Check the actual cloned programs, not only the drawing before/after
|
||||||
|
/// binding. A transient edit during Clone may leave the drawing unchanged.</summary>
|
||||||
|
internal static IReadOnlyList<string> BoundChanges(NestJob job, NestJobResult raw,
|
||||||
|
IReadOnlyList<ProposedPlate> plates, IReadOnlyDictionary<string, Drawing> drawings)
|
||||||
|
{
|
||||||
|
var changed = new List<string>();
|
||||||
|
var requirements = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
||||||
|
if (plates.Count != raw.Plates.Count)
|
||||||
|
return new[] { "Bound plate count changed after validation" };
|
||||||
|
for (var sheetIndex = 0; sheetIndex < plates.Count; sheetIndex++)
|
||||||
|
{
|
||||||
|
var poses = raw.Plates[sheetIndex].Placements;
|
||||||
|
var bound = plates[sheetIndex].Parts;
|
||||||
|
if (bound.Count != poses.Count)
|
||||||
|
return new[] { "Bound part count changed after validation" };
|
||||||
|
for (var index = 0; index < poses.Count; index++)
|
||||||
|
{
|
||||||
|
var pose = poses[index];
|
||||||
|
var actual = bound[index];
|
||||||
|
if (pose.PartId == null || !requirements.TryGetValue(pose.PartId, out var requirement)
|
||||||
|
|| !drawings.TryGetValue(pose.PartId, out var drawing)
|
||||||
|
|| !ReferenceEquals(actual.BaseDrawing, drawing))
|
||||||
|
return new[] { "A bound part no longer matches its requirement" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var expected = new Part(DrawingJobMapper.CreateDrawing(requirement));
|
||||||
|
expected.Rotate(pose.Rotation);
|
||||||
|
expected.Location = new Vector(pose.X, pose.Y);
|
||||||
|
if (Same(PartGeometrySnapshot.FromProgram(expected.Program),
|
||||||
|
PartGeometrySnapshot.FromProgram(actual.Program))
|
||||||
|
&& Bits(expected.Location.X) == Bits(actual.Location.X)
|
||||||
|
&& Bits(expected.Location.Y) == Bits(actual.Location.Y)
|
||||||
|
&& Bits(expected.Rotation) == Bits(actual.Rotation))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
|
||||||
|
or InvalidOperationException)
|
||||||
|
{
|
||||||
|
// A changed/unsupported program cannot represent the checked pose.
|
||||||
|
}
|
||||||
|
changed.Add($"Bound part for '{drawing.Name ?? requirement.Id}' changed after validation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Same(PartGeometrySnapshot a, PartGeometrySnapshot b) =>
|
||||||
|
a.Mode == b.Mode && a.Motions.Count == b.Motions.Count
|
||||||
|
&& a.Motions.Zip(b.Motions).All(pair =>
|
||||||
|
pair.First.Type == pair.Second.Type
|
||||||
|
&& Bits(pair.First.X) == Bits(pair.Second.X)
|
||||||
|
&& Bits(pair.First.Y) == Bits(pair.Second.Y)
|
||||||
|
&& Bits(pair.First.CenterX) == Bits(pair.Second.CenterX)
|
||||||
|
&& Bits(pair.First.CenterY) == Bits(pair.Second.CenterY)
|
||||||
|
&& pair.First.Rotation == pair.Second.Rotation
|
||||||
|
&& pair.First.Layer == pair.Second.Layer
|
||||||
|
&& pair.First.Suppressed == pair.Second.Suppressed);
|
||||||
|
|
||||||
|
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Mcp;
|
||||||
|
using OpenNest.Mcp.Tools;
|
||||||
|
|
||||||
|
namespace OpenNest.FrontEnd.Tests;
|
||||||
|
|
||||||
|
[Collection("FrontEndRegistry")]
|
||||||
|
public class McpMixReportingTests
|
||||||
|
{
|
||||||
|
private static Drawing Rectangle(string name, double width, double length)
|
||||||
|
{
|
||||||
|
var shape = new Shape();
|
||||||
|
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(width, 0)));
|
||||||
|
shape.Entities.Add(new Line(new Vector(width, 0), new Vector(width, length)));
|
||||||
|
shape.Entities.Add(new Line(new Vector(width, length), new Vector(0, length)));
|
||||||
|
shape.Entities.Add(new Line(new Vector(0, length), new Vector(0, 0)));
|
||||||
|
return new Drawing(name, OpenNest.Converters.ConvertGeometry.ToProgram(shape));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestSession Session()
|
||||||
|
{
|
||||||
|
var session = new NestSession();
|
||||||
|
session.Drawings.Add(Rectangle("A", 2, 2));
|
||||||
|
session.Drawings.Add(Rectangle("B", 20, 20));
|
||||||
|
session.Plates.Add(new Plate(10, 10));
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PackReportsZeroForUnfitDrawingAndCountsOnlyNewParts()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var plate = session.GetPlate(0);
|
||||||
|
plate.Parts.Add(new Part(session.Drawings[0]));
|
||||||
|
var text = new NestingTools(session).PackPlate(0, "A,B", "10,10");
|
||||||
|
Assert.Contains("A: requested=10", text);
|
||||||
|
Assert.Contains("B: requested=10, placed=0, remaining=10", text);
|
||||||
|
Assert.Contains("remaining demand", text);
|
||||||
|
Assert.DoesNotContain("ratio not met", text);
|
||||||
|
Assert.Contains($"A: requested=10, placed={plate.Parts.Count - 1}, remaining={11 - plate.Parts.Count}", text);
|
||||||
|
Assert.True(plate.Parts.Count > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutoNestReportsSkewedAndZeroProgressByRequirement()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var engine = "MixStub-" + Guid.NewGuid();
|
||||||
|
NestingEngineRegistry.Register(engine, "test", () => new Stub());
|
||||||
|
var tool = new NestingTools(session);
|
||||||
|
var text = tool.AutoNestPlate(0, "A,B", "2,2", engine);
|
||||||
|
Assert.Contains("A: requested=2, placed=1, remaining=1", text);
|
||||||
|
Assert.Contains("B: requested=2, placed=0, remaining=2", text);
|
||||||
|
Assert.Contains("remaining demand", text);
|
||||||
|
Assert.DoesNotContain("ratio not met", text);
|
||||||
|
|
||||||
|
// Duplicate tokens must be refused before the engine can mutate a fresh target.
|
||||||
|
var fresh = Session();
|
||||||
|
Assert.Contains("duplicate", new NestingTools(fresh).AutoNestPlate(0, "A,A", "2,2", engine), StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Empty(fresh.GetPlate(0).Parts);
|
||||||
|
Assert.Single(session.GetPlate(0).Parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PackZeroProgressAndAmbiguousNamesAreExplicit()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
session.GetPlate(0).Size = new Size(1, 1);
|
||||||
|
var text = new NestingTools(session).PackPlate(0, "A,B", "2,2");
|
||||||
|
Assert.Contains("A: requested=2, placed=0, remaining=2", text);
|
||||||
|
Assert.Contains("B: requested=2, placed=0, remaining=2", text);
|
||||||
|
Assert.Contains("zero progress", text);
|
||||||
|
Assert.Empty(session.GetPlate(0).Parts);
|
||||||
|
|
||||||
|
session.Drawings.Add(Rectangle("A", 1, 1));
|
||||||
|
Assert.Contains("ambiguous drawing name", new NestingTools(session).PackPlate(0, "A,B", "2,2"));
|
||||||
|
Assert.Empty(session.GetPlate(0).Parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZeroDemandHasZeroRemainingAndNegativeDemandIsRejected()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var tools = new NestingTools(session);
|
||||||
|
var text = tools.PackPlate(0, "A", "0");
|
||||||
|
Assert.Contains("A: requested=0, placed=0, remaining=0", text);
|
||||||
|
Assert.Contains("zero progress", text);
|
||||||
|
Assert.Contains("negative", tools.PackPlate(0, "A", "-1"), StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Empty(session.GetPlate(0).Parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Stub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var placed = new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) };
|
||||||
|
return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.Completed,
|
||||||
|
[new NestJobPlateResult(0, job.Plates[0], placed)],
|
||||||
|
[], []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
using OpenNest.Mcp;
|
||||||
|
using OpenNest.Mcp.Tools;
|
||||||
|
|
||||||
|
namespace OpenNest.FrontEnd.Tests;
|
||||||
|
|
||||||
|
public class McpPlateToolsTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-mcp-plates-" + Guid.NewGuid());
|
||||||
|
|
||||||
|
public McpPlateToolsTests() => Directory.CreateDirectory(directory);
|
||||||
|
public void Dispose() => Directory.Delete(directory, true);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NegativeIndexOnLoadedNestIsNotAPlate()
|
||||||
|
{
|
||||||
|
var session = new NestSession { Nest = new Nest() };
|
||||||
|
var loaded = new Plate(10, 20);
|
||||||
|
session.Nest.Plates.Add(loaded);
|
||||||
|
var appended = new Plate(30, 40);
|
||||||
|
session.Plates.Add(appended);
|
||||||
|
|
||||||
|
Assert.Null(session.GetPlate(-1));
|
||||||
|
Assert.Equal("Error: plate -1 not found", new SetupTools(session).ClearPlate(-1));
|
||||||
|
Assert.Same(loaded, session.GetPlate(0));
|
||||||
|
Assert.Same(appended, session.GetPlate(1));
|
||||||
|
Assert.Null(session.GetPlate(2));
|
||||||
|
Assert.Single(session.Nest.Plates);
|
||||||
|
Assert.Single(session.Plates);
|
||||||
|
|
||||||
|
var sessionOnly = new NestSession();
|
||||||
|
sessionOnly.Plates.Add(appended);
|
||||||
|
Assert.Null(sessionOnly.GetPlate(-1));
|
||||||
|
Assert.Same(appended, sessionOnly.GetPlate(0));
|
||||||
|
Assert.Null(sessionOnly.GetPlate(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Delete(SetupTools tools, int index) => tools.DeletePlate(index);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteEmptyLoadedMiddlePlateRenumbersAppendedAndPersists()
|
||||||
|
{
|
||||||
|
var session = new NestSession { Nest = new Nest("fixture") };
|
||||||
|
var drawing = new Drawing("square", new Program());
|
||||||
|
session.Nest.Drawings.Add(drawing);
|
||||||
|
var occupied = new Plate(20, 30) { Quantity = 2 };
|
||||||
|
occupied.Parts.Add(new Part(drawing));
|
||||||
|
var unused = new Plate(40, 50);
|
||||||
|
var appended = new Plate(60, 70) { PartSpacing = 1.25, Quantity = 3 };
|
||||||
|
session.Nest.Plates.Add(occupied);
|
||||||
|
session.Nest.Plates.Add(unused);
|
||||||
|
session.Plates.Add(appended);
|
||||||
|
var tools = new SetupTools(session);
|
||||||
|
|
||||||
|
var result = Delete(tools, 1);
|
||||||
|
Assert.Contains("Deleted plate 1", result);
|
||||||
|
Assert.Contains("2 -> 1", result);
|
||||||
|
Assert.Single(session.Nest.Plates);
|
||||||
|
Assert.Same(appended, session.GetPlate(1));
|
||||||
|
Assert.Null(session.GetPlate(2));
|
||||||
|
Assert.Contains("60.0 x 70.0", new InspectionTools(session).GetPlateInfo(1));
|
||||||
|
Assert.Equal(2, drawing.Quantity.Nested);
|
||||||
|
|
||||||
|
var path = Path.Combine(directory, "cleaned.nest");
|
||||||
|
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
|
||||||
|
var reloaded = new NestReader(path).Read();
|
||||||
|
// NestWriter already omits empty plates; the deletion is session cleanup,
|
||||||
|
// and the saved file must still retain the occupied plate and its counts.
|
||||||
|
var saved = Assert.Single(reloaded.Plates);
|
||||||
|
Assert.Single(saved.Parts);
|
||||||
|
Assert.Equal(20, saved.Size.Width);
|
||||||
|
Assert.Equal(2, saved.Quantity);
|
||||||
|
Assert.Equal(2, reloaded.Drawings.Single().Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteRefusesOccupiedCutoffAndInvalidIndicesWithoutMutation()
|
||||||
|
{
|
||||||
|
var session = new NestSession { Nest = new Nest("fixture") };
|
||||||
|
var drawing = new Drawing("square", new Program());
|
||||||
|
session.Nest.Drawings.Add(drawing);
|
||||||
|
var occupied = new Plate(20, 30) { Quantity = 4 };
|
||||||
|
var part = new Part(drawing);
|
||||||
|
occupied.Parts.Add(part);
|
||||||
|
var cutoff = new Plate(40, 50);
|
||||||
|
var definition = new CutOff(new Vector(10, 10), CutOffAxis.Vertical);
|
||||||
|
cutoff.CutOffs.Add(definition);
|
||||||
|
session.Nest.Plates.Add(occupied);
|
||||||
|
session.Plates.Add(cutoff);
|
||||||
|
var tools = new SetupTools(session);
|
||||||
|
|
||||||
|
Assert.Contains("occupied", Delete(tools, 0));
|
||||||
|
Assert.Contains("cutoff", Delete(tools, 1), StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("not found", Delete(tools, -1));
|
||||||
|
Assert.Contains("not found", Delete(tools, 2));
|
||||||
|
Assert.Same(occupied, session.GetPlate(0));
|
||||||
|
Assert.Same(cutoff, session.GetPlate(1));
|
||||||
|
Assert.Same(part, Assert.Single(occupied.Parts));
|
||||||
|
Assert.Same(definition, Assert.Single(cutoff.CutOffs));
|
||||||
|
Assert.Equal(4, drawing.Quantity.Nested);
|
||||||
|
Assert.Single(session.Nest.Plates);
|
||||||
|
Assert.Single(session.Plates);
|
||||||
|
|
||||||
|
// The session-only collection must also delete without requiring a loaded nest.
|
||||||
|
var standalone = new NestSession();
|
||||||
|
standalone.Plates.Add(new Plate(12, 13));
|
||||||
|
Assert.Contains("Deleted plate 0", Delete(new SetupTools(standalone), 0));
|
||||||
|
Assert.Empty(standalone.Plates);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
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 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();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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, [], []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ namespace OpenNest.Mcp
|
|||||||
|
|
||||||
public Plate GetPlate(int index)
|
public Plate GetPlate(int index)
|
||||||
{
|
{
|
||||||
|
if (index < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
if (Nest != null && index < Nest.Plates.Count)
|
if (Nest != null && index < Nest.Plates.Count)
|
||||||
return Nest.Plates[index];
|
return Nest.Plates[index];
|
||||||
|
|
||||||
@@ -27,6 +30,38 @@ namespace OpenNest.Mcp
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Remove only a genuinely unused sheet; loaded sheets precede appended sheets.</summary>
|
||||||
|
public bool TryDeleteEmptyPlate(int index, out string reason)
|
||||||
|
{
|
||||||
|
var plate = GetPlate(index);
|
||||||
|
if (plate == null)
|
||||||
|
{
|
||||||
|
reason = $"plate {index} not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plate.Parts.Count != 0)
|
||||||
|
{
|
||||||
|
reason = $"plate {index} is occupied";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plate.CutOffs.Count != 0)
|
||||||
|
{
|
||||||
|
reason = $"plate {index} has cutoff definitions";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadedCount = Nest?.Plates.Count ?? 0;
|
||||||
|
if (index < loadedCount)
|
||||||
|
Nest.Plates.RemoveAt(index);
|
||||||
|
else
|
||||||
|
Plates.RemoveAt(index - loadedCount);
|
||||||
|
|
||||||
|
reason = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public Drawing GetDrawing(string name)
|
public Drawing GetDrawing(string name)
|
||||||
{
|
{
|
||||||
if (Nest != null)
|
if (Nest != null)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace OpenNest.Mcp;
|
||||||
|
|
||||||
|
/// <summary>Request-local physical inventory for one whole-job solve.</summary>
|
||||||
|
public sealed class SheetStockInput
|
||||||
|
{
|
||||||
|
public double Width { get; set; }
|
||||||
|
public double Length { get; set; }
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ using OpenNest.Engine.Fill;
|
|||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.Engine.Jobs.Placement;
|
using OpenNest.Engine.Jobs.Placement;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Mcp.Tools
|
namespace OpenNest.Mcp.Tools
|
||||||
{
|
{
|
||||||
@@ -257,10 +258,7 @@ namespace OpenNest.Mcp.Tools
|
|||||||
sb.AppendLine($" Total parts: {countAfter}");
|
sb.AppendLine($" Total parts: {countAfter}");
|
||||||
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
||||||
|
|
||||||
// Breakdown by drawing
|
AppendMixSummary(sb, items, parts);
|
||||||
var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name);
|
|
||||||
foreach (var group in groups)
|
|
||||||
sb.AppendLine($" {group.Key}: {group.Count()}");
|
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
@@ -333,11 +331,217 @@ namespace OpenNest.Mcp.Tools
|
|||||||
sb.AppendLine($" Parts placed: {totalPlaced}");
|
sb.AppendLine($" Parts placed: {totalPlaced}");
|
||||||
sb.AppendLine($" Total parts: {plate.Parts.Count}");
|
sb.AppendLine($" Total parts: {plate.Parts.Count}");
|
||||||
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
||||||
foreach (var group in plate.Parts.GroupBy(p => p.BaseDrawing.Name))
|
AppendMixSummary(sb, parsed.items, proposed == null ? Enumerable.Empty<Part>() : proposed.Parts);
|
||||||
sb.AppendLine($" {group.Key}: {group.Count()}");
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[McpServerTool(Name = "autonest_job")]
|
||||||
|
[Description("Complete requested drawing quantities across physical sheets. The selected empty plate supplies common settings; optional sheets offer finite sizes and quantities. An incomplete or invalid proposal changes nothing.")]
|
||||||
|
public string AutoNestJob(
|
||||||
|
[Description("Index of an empty plate to use as the stock template")] int plateIndex,
|
||||||
|
[Description("Comma-separated drawing names")] string drawingNames,
|
||||||
|
[Description("Comma-separated total requested quantities for the entire session")] string quantities,
|
||||||
|
[Description(JobEngines)] string engine = null,
|
||||||
|
[Description("Use only already-created empty plates with matching dimensions and spacing; do not add sheets")] bool no_new_plates = false,
|
||||||
|
CancellationToken cancellationToken = default,
|
||||||
|
[Description("Optional finite physical sheet stock: width, length and available quantity per size. 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.";
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
var lines = new StringBuilder();
|
||||||
|
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);
|
||||||
|
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)
|
||||||
|
|| 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.");
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
return (stock, total, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameStock(Plate a, Plate b) => SameStock(a, b, b.Size);
|
||||||
|
|
||||||
|
private static bool SameStock(Plate a, Plate b, Size size) =>
|
||||||
|
a.Size.Width == size.Width && a.Size.Length == size.Length
|
||||||
|
&& a.PartSpacing == b.PartSpacing && a.EdgeSpacing.Equals(b.EdgeSpacing)
|
||||||
|
&& a.Quadrant == b.Quadrant && a.GrainAngle == b.GrainAngle
|
||||||
|
&& (a.CuttingParameters == null && b.CuttingParameters == null
|
||||||
|
|| a.CuttingParameters != null && b.CuttingParameters != null
|
||||||
|
&& string.Equals(CuttingParametersSerializer.Serialize(a.CuttingParameters),
|
||||||
|
CuttingParametersSerializer.Serialize(b.CuttingParameters), StringComparison.Ordinal));
|
||||||
|
|
||||||
|
private static void AppendMixSummary(StringBuilder sb, IReadOnlyList<NestItem> items, IEnumerable<Part> newlyPlaced)
|
||||||
|
{
|
||||||
|
var counts = new Dictionary<Drawing, int>(ReferenceEqualityComparer.Instance);
|
||||||
|
foreach (var part in newlyPlaced)
|
||||||
|
counts[part.BaseDrawing] = counts.TryGetValue(part.BaseDrawing, out var n) ? n + 1 : 1;
|
||||||
|
|
||||||
|
var placed = new int[items.Count];
|
||||||
|
sb.AppendLine(" Requested mix (new placements only):");
|
||||||
|
for (var i = 0; i < items.Count; i++)
|
||||||
|
{
|
||||||
|
var item = items[i];
|
||||||
|
placed[i] = counts.GetValueOrDefault(item.Drawing);
|
||||||
|
var remaining = item.Quantity - placed[i];
|
||||||
|
sb.AppendLine($" [{i}] {item.Drawing.Name}: requested={item.Quantity}, placed={placed[i]}, remaining={remaining}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placed.All(count => count == 0))
|
||||||
|
sb.AppendLine(" Mix: zero progress for all requested drawings.");
|
||||||
|
else if (items.Where((item, i) => item.Quantity > placed[i]).Any())
|
||||||
|
sb.AppendLine(" Warning: remaining demand in this call; place the outstanding quantities on another sheet before treating the job as complete.");
|
||||||
|
else
|
||||||
|
sb.AppendLine(" Mix: requested quantities placed in this call.");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the requested fill strategy; an omitted name means Fill. Returns null when the
|
/// Resolves the requested fill strategy; an omitted name means Fill. Returns null when the
|
||||||
/// name is not a single-plate placement strategy.
|
/// name is not a single-plate placement strategy.
|
||||||
@@ -376,6 +580,8 @@ namespace OpenNest.Mcp.Tools
|
|||||||
{
|
{
|
||||||
if (!int.TryParse(qtyStrings[i], out qtys[i]))
|
if (!int.TryParse(qtyStrings[i], out qtys[i]))
|
||||||
return (null, $"Error: '{qtyStrings[i]}' is not a valid quantity");
|
return (null, $"Error: '{qtyStrings[i]}' is not a valid quantity");
|
||||||
|
if (qtys[i] < 0)
|
||||||
|
return (null, $"Error: negative quantity '{qtys[i]}' is not supported");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (names.Length != qtys.Length)
|
if (names.Length != qtys.Length)
|
||||||
@@ -385,14 +591,28 @@ namespace OpenNest.Mcp.Tools
|
|||||||
);
|
);
|
||||||
|
|
||||||
var items = new List<NestItem>();
|
var items = new List<NestItem>();
|
||||||
|
var seen = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
for (var i = 0; i < names.Length; i++)
|
for (var i = 0; i < names.Length; i++)
|
||||||
{
|
{
|
||||||
var drawing = _session.GetDrawing(names[i]);
|
var matches = _session.AllDrawings().Where(d => d.Name == names[i]).ToArray();
|
||||||
if (drawing == null)
|
if (matches.Length == 0)
|
||||||
return (null, $"Error: drawing '{names[i]}' not found");
|
return (null, $"Error: drawing '{names[i]}' not found");
|
||||||
|
if (matches.Length > 1)
|
||||||
|
return (null, $"Error: ambiguous drawing name '{names[i]}' matches multiple drawings");
|
||||||
|
var drawing = matches[0];
|
||||||
|
if (!seen.Add(drawing))
|
||||||
|
return (null, $"Error: duplicate drawing '{names[i]}' in request");
|
||||||
|
|
||||||
items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] });
|
items.Add(new NestItem
|
||||||
|
{
|
||||||
|
Drawing = drawing,
|
||||||
|
Quantity = qtys[i],
|
||||||
|
Priority = drawing.Priority,
|
||||||
|
StepAngle = drawing.Constraints?.StepAngle ?? 0,
|
||||||
|
RotationStart = drawing.Constraints?.StartAngle ?? 0,
|
||||||
|
RotationEnd = drawing.Constraints?.EndAngle ?? 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (items, null);
|
return (items, null);
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ namespace OpenNest.Mcp.Tools
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[McpServerTool(Name = "delete_plate")]
|
||||||
|
[Description("Delete an empty plate without parts or cutoff definitions. Later plate indices shift down by one.")]
|
||||||
|
public string DeletePlate([Description("Index of the empty plate to delete")] int plateIndex)
|
||||||
|
{
|
||||||
|
var oldCount = _session.AllPlates().Count;
|
||||||
|
if (!_session.TryDeleteEmptyPlate(plateIndex, out var reason))
|
||||||
|
return $"Error: {reason}; no plates changed";
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"Deleted plate {plateIndex}. Plates: {oldCount} -> {oldCount - 1}.");
|
||||||
|
for (var oldIndex = plateIndex + 1; oldIndex < oldCount; oldIndex++)
|
||||||
|
sb.AppendLine($" {oldIndex} -> {oldIndex - 1}");
|
||||||
|
sb.AppendLine("Use get_plate_info with the new indices before further operations.");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "clear_plate")]
|
[McpServerTool(Name = "clear_plate")]
|
||||||
[Description("Remove all parts from a plate. Returns how many parts were removed.")]
|
[Description("Remove all parts from a plate. Returns how many parts were removed.")]
|
||||||
public string ClearPlate([Description("Index of the plate to clear")] int plateIndex)
|
public string ClearPlate([Description("Index of the plate to clear")] int plateIndex)
|
||||||
|
|||||||
@@ -144,6 +144,63 @@ public class CuttingPlanBatchTests
|
|||||||
Assert.Null(proposal.BuildPreview(0));
|
Assert.Null(proposal.BuildPreview(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlanReorder_ShortPassReady_RetainsReplayedApplicableProposal()
|
||||||
|
{
|
||||||
|
var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1));
|
||||||
|
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||||
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
||||||
|
var full = CuttingPlanService.Plan(snapshot);
|
||||||
|
var result = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||||
|
Assert.True(result.IndependentlyReplayed);
|
||||||
|
Assert.True(result.Expansions < full.Expansions,
|
||||||
|
$"Short pass {result.Expansions}, full {full.Expansions}");
|
||||||
|
Assert.InRange(result.Expansions, 1, CuttingPlanBatch.FirstPassBudget - 1);
|
||||||
|
Assert.Equal(2, result.ProposedOrder.Count);
|
||||||
|
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
||||||
|
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlanReorder_ShortPassExhausted_UsesFullCapAndOriginalBudget()
|
||||||
|
{
|
||||||
|
var plate = Plate(new Nest(), Grid(4));
|
||||||
|
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||||
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
||||||
|
var full = CuttingPlanService.Plan(snapshot);
|
||||||
|
var recovered = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None, firstPassBudget: 1);
|
||||||
|
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, full.Status);
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, recovered.Status);
|
||||||
|
Assert.True(recovered.IndependentlyReplayed);
|
||||||
|
Assert.Equal(full.Expansions, recovered.Expansions);
|
||||||
|
Assert.Equal(full.RapidDistance, recovered.RapidDistance);
|
||||||
|
Assert.Equal(full.ProposedOrder.Select(p => p.SourceOrdinal), recovered.ProposedOrder.Select(p => p.SourceOrdinal));
|
||||||
|
for (var i = 0; i < full.ProposedOrder.Count; i++)
|
||||||
|
Assert.True(ProgramContent.Equal(full.ProposedOrder[i].CopyProgram(), recovered.ProposedOrder[i].CopyProgram()));
|
||||||
|
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlanReorder_CancelledDuringShortPass_DoesNotRetryOrMutate()
|
||||||
|
{
|
||||||
|
var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1));
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var observations = 0;
|
||||||
|
var request = new CuttingPlanRequest(plate.Parts, confirmedParameters: ExplicitContourTests.Parameters())
|
||||||
|
{ ExpansionObserver = _ => { observations++; cancellation.Cancel(); } };
|
||||||
|
var snapshot = CuttingPlanService.Capture(request);
|
||||||
|
|
||||||
|
var result = CuttingPlanBatch.PlanReorder(snapshot, cancellation.Token);
|
||||||
|
|
||||||
|
Assert.Equal(CuttingPlanStatus.Cancelled, result.Status);
|
||||||
|
Assert.Equal(1, observations);
|
||||||
|
Assert.Empty(result.ProposedOrder);
|
||||||
|
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo()
|
public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -119,6 +119,50 @@ public class NativeContactRegressionTests
|
|||||||
Assert.Throws<NotSupportedException>(() => circle.Contacts(line, out _));
|
Assert.Throws<NotSupportedException>(() => circle.Contacts(line, out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReusedNativeCurves_KeepContactAndContainmentVerdictsAcrossCallsAndThreads()
|
||||||
|
{
|
||||||
|
var lineStart = new Vector(-2, 0);
|
||||||
|
var lineEnd = new Vector(2, 0);
|
||||||
|
var arcCenter = new Vector(0, 0.003);
|
||||||
|
var arcStart = new Vector(0.003, 0.003);
|
||||||
|
var circleCenter = new Vector(0, 0);
|
||||||
|
var circleStart = new Vector(0.003, 0);
|
||||||
|
var line = PostVerificationGeometry.Curve.Create(lineStart, lineEnd, null, false);
|
||||||
|
var arc = PostVerificationGeometry.Curve.Create(arcStart, new Vector(-0.003, 0.003), arcCenter, false);
|
||||||
|
var circle = PostVerificationGeometry.Curve.Create(circleStart, circleStart, circleCenter, false);
|
||||||
|
var pairs = new[] { (line, arc), (arc, line), (line, circle), (circle, line), (arc, circle), (circle, arc) };
|
||||||
|
var probes = new[] { Vector.Zero, new Vector(0.003, 0), new Vector(0, 0.006) };
|
||||||
|
|
||||||
|
string Observe(PostVerificationGeometry.Curve a, PostVerificationGeometry.Curve b)
|
||||||
|
{
|
||||||
|
var inside = string.Join(",", probes.Select(p => $"{a.Contains(p)}:{b.Contains(p)}"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var contacts = a.Contacts(b, out var overlap);
|
||||||
|
return $"{inside}|{overlap}|" + string.Join(";", contacts.Select(p =>
|
||||||
|
$"{BitConverter.DoubleToInt64Bits(p.X)}:{BitConverter.DoubleToInt64Bits(p.Y)}"));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||||
|
{
|
||||||
|
return $"{inside}|{ex.GetType().Name}:{ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var expected = pairs.Select(p => Observe(
|
||||||
|
PostVerificationGeometry.Curve.Create(p.Item1.Start, p.Item1.End,
|
||||||
|
ReferenceEquals(p.Item1, arc) ? arcCenter : ReferenceEquals(p.Item1, circle) ? circleCenter : null, false),
|
||||||
|
PostVerificationGeometry.Curve.Create(p.Item2.Start, p.Item2.End,
|
||||||
|
ReferenceEquals(p.Item2, arc) ? arcCenter : ReferenceEquals(p.Item2, circle) ? circleCenter : null, false))).ToArray();
|
||||||
|
for (var repeat = 0; repeat < 3; repeat++)
|
||||||
|
Assert.Equal(expected, pairs.Select(p => Observe(p.Item1, p.Item2)).ToArray());
|
||||||
|
Parallel.For(0, 8, _ => Assert.Equal(expected, pairs.Select(p => Observe(p.Item1, p.Item2)).ToArray()));
|
||||||
|
// A separate pair has not initialized either native wrapper before the readers race.
|
||||||
|
var coldLine = PostVerificationGeometry.Curve.Create(lineStart, lineEnd, null, false);
|
||||||
|
var coldCircle = PostVerificationGeometry.Curve.Create(circleStart, circleStart, circleCenter, false);
|
||||||
|
Parallel.For(0, 8, _ => Assert.Equal(expected[2], Observe(coldLine, coldCircle)));
|
||||||
|
}
|
||||||
|
|
||||||
private static Program RoundedRectangle(double width, double height, double radius, double angle, Vector at)
|
private static Program RoundedRectangle(double width, double height, double radius, double angle, Vector at)
|
||||||
{
|
{
|
||||||
var cos = System.Math.Cos(angle);
|
var cos = System.Math.Cos(angle);
|
||||||
|
|||||||
@@ -34,6 +34,48 @@ public class ReorderSearchTests
|
|||||||
Assert.Equal(parts, plate.Parts); // Planning alone never reorders the live plate.
|
Assert.Equal(parts, plate.Parts); // Planning alone never reorders the live plate.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void DenseGrid144_PerformanceProbe()
|
||||||
|
{
|
||||||
|
Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_CUTTING_PERF") == "1",
|
||||||
|
"Set OPENNEST_RUN_CUTTING_PERF=1 for the 144-part cutting-planner probe.");
|
||||||
|
var nest = new Nest();
|
||||||
|
var plate = nest.CreatePlate();
|
||||||
|
plate.Size = new Size(100, 100);
|
||||||
|
foreach (var part in Grid(144, false))
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
var source = plate.Parts.ToArray();
|
||||||
|
var timer = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
||||||
|
confirmedParameters: ExplicitContourTests.Parameters(), expansionBudget: 57600));
|
||||||
|
timer.Stop();
|
||||||
|
// Expanded owned execution includes called hole programs; Program.ToString() does not,
|
||||||
|
// and rounds coordinates. Hash exact scalar bits in execution order instead.
|
||||||
|
static string Bits(double value) => BitConverter.DoubleToInt64Bits(value).ToString("X16");
|
||||||
|
static string Point(Vector point) => $"{Bits(point.X)},{Bits(point.Y)}";
|
||||||
|
var payload = string.Join("\n", result.ProposedOrder.SelectMany(p =>
|
||||||
|
new[] { $"part:{p.SourceOrdinal}:{Point(p.Location)}:{Bits(p.Rotation)}" }.Concat(
|
||||||
|
p.Execution.Motions.Select(m =>
|
||||||
|
{
|
||||||
|
var geometry = m.Curve?.ToEntity() switch
|
||||||
|
{
|
||||||
|
Arc arc => $"arc:{Point(arc.Center)}:{Bits(arc.Radius)}:{Bits(arc.StartAngle)}:{Bits(arc.EndAngle)}:{arc.Rotation}",
|
||||||
|
Circle circle => $"circle:{Point(circle.Center)}:{Bits(circle.Radius)}:{circle.Rotation}",
|
||||||
|
Line => "line",
|
||||||
|
null => "none",
|
||||||
|
_ => throw new NotSupportedException("Unexpected execution curve."),
|
||||||
|
};
|
||||||
|
return $"{m.Layer}:{m.Rapid}:{(m.Start is { } start ? Point(start) : "null")}:{Point(m.End)}:{geometry}";
|
||||||
|
}))));
|
||||||
|
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(payload)));
|
||||||
|
Console.WriteLine($"Cutting144 elapsedMs={timer.Elapsed.TotalMilliseconds:F1} expansions={result.Expansions} status={result.Status} executionSha256={hash}");
|
||||||
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||||
|
Assert.True(result.IndependentlyReplayed);
|
||||||
|
Assert.Equal(source.OrderBy(Key), result.ProposedOrder.Select(p => p.SourcePart).OrderBy(Key));
|
||||||
|
Assert.Equal(source, plate.Parts);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FreeOrder_BlockedApproach_LearnsToCutThatPartFirst()
|
public void FreeOrder_BlockedApproach_LearnsToCutThatPartFirst()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,6 +59,31 @@ public class NestStorageSettingsTests : IDisposable
|
|||||||
Assert.False(settings.IsDatabaseMode);
|
Assert.False(settings.IsDatabaseMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryNormalizeServerUrl_AcceptsHttpsWithoutChangingMode()
|
||||||
|
{
|
||||||
|
var settings = new NestStorageSettings { Mode = NestStorageMode.File };
|
||||||
|
|
||||||
|
Assert.True(NestStorageSettings.TryNormalizeServerUrl(" https://shop.example:8090/ ", out var url));
|
||||||
|
settings.ServerUrl = url;
|
||||||
|
settings.Save(FilePath);
|
||||||
|
|
||||||
|
var loaded = NestStorageSettings.Load(FilePath);
|
||||||
|
Assert.Equal("https://shop.example:8090", loaded.ServerUrl);
|
||||||
|
Assert.Equal(NestStorageMode.File, loaded.Mode);
|
||||||
|
Assert.False(loaded.IsDatabaseMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("ftp://shop.example")]
|
||||||
|
[InlineData("not-a-url")]
|
||||||
|
public void TryNormalizeServerUrl_RejectsInvalidAddress(string input)
|
||||||
|
{
|
||||||
|
Assert.False(NestStorageSettings.TryNormalizeServerUrl(input, out var url));
|
||||||
|
Assert.Equal("", url);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (Directory.Exists(_directory))
|
if (Directory.Exists(_directory))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,36 @@ namespace OpenNest.WinForms.Tests.Forms;
|
|||||||
|
|
||||||
public class AutoNestFormStockOptionsTests
|
public class AutoNestFormStockOptionsTests
|
||||||
{
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void StockRowsCanBeSelectedAndRemoved(bool loadSavedOptions)
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
using var form = new AutoNestForm(new Nest());
|
||||||
|
if (loadSavedOptions)
|
||||||
|
form.LoadPlateOptions(new List<PlateOption>
|
||||||
|
{
|
||||||
|
new() { Width = 48, Length = 96, Cost = 25 },
|
||||||
|
new() { Width = 60, Length = 120, Cost = 40 },
|
||||||
|
}, 0.5);
|
||||||
|
form.OptimizePlateSize = true;
|
||||||
|
var grid = GetGrid(form);
|
||||||
|
|
||||||
|
Assert.True(grid.RowHeadersVisible);
|
||||||
|
Assert.True(grid.AllowUserToDeleteRows);
|
||||||
|
Assert.Equal(DataGridViewSelectionMode.RowHeaderSelect, grid.SelectionMode);
|
||||||
|
grid.Rows[0].Selected = true;
|
||||||
|
Assert.True(grid.Rows[0].Selected);
|
||||||
|
|
||||||
|
var remaining = form.GetPlateOptions().Count;
|
||||||
|
Assert.IsAssignableFrom<IList>(grid.DataSource).RemoveAt(grid.SelectedRows[0].Index);
|
||||||
|
Assert.Equal(remaining - 1, form.GetPlateOptions().Count);
|
||||||
|
AssertNewRow(grid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(false)]
|
[InlineData(false)]
|
||||||
[InlineData(true)]
|
[InlineData(true)]
|
||||||
|
|||||||
@@ -1,169 +1,101 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Linq;
|
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.Controls;
|
using OpenNest.Controls;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Sequencing;
|
||||||
using OpenNest.Forms;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
|
|
||||||
namespace OpenNest.Actions
|
namespace OpenNest.Actions
|
||||||
{
|
{
|
||||||
[DisplayName("Set Sequence")]
|
[DisplayName("Set Sequence")]
|
||||||
public class ActionSetSequence : Action
|
public class ActionSetSequence : Action
|
||||||
{
|
{
|
||||||
private readonly SequenceForm SequenceForm;
|
private readonly Forms.SequenceForm sequenceForm;
|
||||||
private readonly List<Pair> ShapePartPairs;
|
private readonly Pen pen = new Pen(Color.Blue, 2.0f);
|
||||||
private readonly Pen pen;
|
private LayoutPart hoveredPart;
|
||||||
|
private bool disconnecting;
|
||||||
|
|
||||||
private Shape ClosestShape;
|
// Escape ends this tool; it must not resurrect its closed window.
|
||||||
private Part ClosestPart;
|
public override bool ResumeOnEscape => false;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ActionSetSequence(PlateView plateView)
|
public ActionSetSequence(PlateView plateView)
|
||||||
: base(plateView)
|
: base(plateView)
|
||||||
{
|
{
|
||||||
SequenceForm = new Forms.SequenceForm();
|
sequenceForm = new Forms.SequenceForm();
|
||||||
SequenceForm.numericUpDown1.DataBindings.Add(
|
sequenceForm.numericUpDown1.Maximum = System.Math.Max(1, plateView.Plate.Parts.Count);
|
||||||
"Value",
|
sequenceForm.DoneClicked += (_, _) => plateView.EndAction();
|
||||||
this,
|
sequenceForm.FormClosed += (_, _) =>
|
||||||
"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)
|
|
||||||
{
|
{
|
||||||
var entities = ConvertProgram
|
if (!disconnecting)
|
||||||
.ToGeometry(part.Program)
|
plateView.EndAction();
|
||||||
.Where(e => e.Layer == SpecialLayers.Cut)
|
};
|
||||||
.ToList();
|
sequenceForm.Show(plateView.FindForm());
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
plateView.MouseMove += plateView_MouseMove;
|
plateView.MouseMove += plateView_MouseMove;
|
||||||
|
plateView.MouseLeave += plateView_MouseLeave;
|
||||||
plateView.MouseClick += plateView_MouseClick;
|
plateView.MouseClick += plateView_MouseClick;
|
||||||
plateView.Paint += plateView_Paint;
|
plateView.Paint += plateView_Paint;
|
||||||
|
|
||||||
SequenceNumber = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void plateView_MouseClick(object sender, MouseEventArgs e)
|
private void plateView_MouseClick(object sender, MouseEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Button == MouseButtons.Right)
|
if (e.Button == MouseButtons.Right)
|
||||||
CancelAction();
|
{
|
||||||
|
plateView.EndAction();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.Button != MouseButtons.Left)
|
if (e.Button != MouseButtons.Left)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (SequenceNumber > plateView.Plate.Parts.Count)
|
// Hit-test at the click, not at the previous mouse-move location.
|
||||||
SequenceNumber = plateView.Plate.Parts.Count;
|
var hit = plateView.GetPartAtControlPoint(e.Location);
|
||||||
|
var part = hit?.BasePart;
|
||||||
if (ClosestPart == null)
|
if (part == null || !ManualPartSequencing.Move(plateView.Plate, part, (int)sequenceForm.numericUpDown1.Value))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
plateView.Plate.Parts.Remove(ClosestPart);
|
if (sequenceForm.numericUpDown1.Value < sequenceForm.numericUpDown1.Maximum)
|
||||||
plateView.Plate.Parts.Insert(SequenceNumber - 1, ClosestPart);
|
sequenceForm.numericUpDown1.Value++;
|
||||||
|
|
||||||
SequenceNumber++;
|
|
||||||
|
|
||||||
plateView.Invalidate();
|
plateView.Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void plateView_MouseMove(object sender, MouseEventArgs e)
|
private void plateView_MouseMove(object sender, MouseEventArgs e)
|
||||||
{
|
{
|
||||||
var pair = GetClosestShape();
|
var hit = plateView.GetPartAtControlPoint(e.Location);
|
||||||
ClosestShape = pair.HasValue ? pair.Value.Shape : null;
|
if (ReferenceEquals(hit, hoveredPart))
|
||||||
ClosestPart = pair.HasValue ? pair.Value.Part : null;
|
return;
|
||||||
|
hoveredPart = hit;
|
||||||
|
plateView.Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void plateView_MouseLeave(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
hoveredPart = null;
|
||||||
plateView.Invalidate();
|
plateView.Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void plateView_Paint(object sender, PaintEventArgs e)
|
private void plateView_Paint(object sender, PaintEventArgs e)
|
||||||
{
|
{
|
||||||
if (ClosestShape == null)
|
var path = hoveredPart?.SelectionPath;
|
||||||
return;
|
if (path != null)
|
||||||
|
e.Graphics.DrawPath(pen, path);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void DisconnectEvents()
|
public override void DisconnectEvents()
|
||||||
{
|
{
|
||||||
|
disconnecting = true;
|
||||||
plateView.Paint -= plateView_Paint;
|
plateView.Paint -= plateView_Paint;
|
||||||
plateView.MouseMove -= plateView_MouseMove;
|
plateView.MouseMove -= plateView_MouseMove;
|
||||||
|
plateView.MouseLeave -= plateView_MouseLeave;
|
||||||
plateView.MouseClick -= plateView_MouseClick;
|
plateView.MouseClick -= plateView_MouseClick;
|
||||||
|
hoveredPart = null;
|
||||||
|
sequenceForm.Close();
|
||||||
|
sequenceForm.Dispose();
|
||||||
|
pen.Dispose();
|
||||||
plateView.Invalidate();
|
plateView.Invalidate();
|
||||||
|
|
||||||
SequenceForm.Close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void CancelAction()
|
public override void CancelAction() { }
|
||||||
{
|
|
||||||
SequenceNumber = 1;
|
|
||||||
SequenceForm.numericUpDown1.Value = SequenceNumber + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool IsBusy()
|
public override bool IsBusy() => false;
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct Pair
|
|
||||||
{
|
|
||||||
public Part Part;
|
|
||||||
public Shape Shape;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+1
-1
@@ -173,7 +173,7 @@ namespace OpenNest.Forms
|
|||||||
this.plateGrid.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
|
this.plateGrid.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
|
||||||
this.plateGrid.Location = new System.Drawing.Point(10, 22);
|
this.plateGrid.Location = new System.Drawing.Point(10, 22);
|
||||||
this.plateGrid.Name = "plateGrid";
|
this.plateGrid.Name = "plateGrid";
|
||||||
this.plateGrid.RowHeadersVisible = false;
|
this.plateGrid.RowHeadersVisible = true;
|
||||||
this.plateGrid.AutoGenerateColumns = false;
|
this.plateGrid.AutoGenerateColumns = false;
|
||||||
this.plateGrid.Size = new System.Drawing.Size(508, 130);
|
this.plateGrid.Size = new System.Drawing.Size(508, 130);
|
||||||
this.plateGrid.TabIndex = 1;
|
this.plateGrid.TabIndex = 1;
|
||||||
|
|||||||
@@ -646,6 +646,7 @@ namespace OpenNest.Forms
|
|||||||
var serverUrl = NestStorage.Settings.ServerUrl;
|
var serverUrl = NestStorage.Settings.ServerUrl;
|
||||||
databaseOpenInProgress = true;
|
databaseOpenInProgress = true;
|
||||||
storageModeMenu.Enabled = false;
|
storageModeMenu.Enabled = false;
|
||||||
|
mnuToolsOptions.Enabled = false;
|
||||||
var file = await repository.GetFileAsync(dlg.SelectedId);
|
var file = await repository.GetFileAsync(dlg.SelectedId);
|
||||||
if (file == null)
|
if (file == null)
|
||||||
throw new FileNotFoundException("This nest no longer exists on the server.");
|
throw new FileNotFoundException("This nest no longer exists on the server.");
|
||||||
@@ -663,7 +664,10 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
databaseOpenInProgress = false;
|
databaseOpenInProgress = false;
|
||||||
if (!IsDisposed)
|
if (!IsDisposed)
|
||||||
|
{
|
||||||
storageModeMenu.Enabled = !databaseSaveInProgress;
|
storageModeMenu.Enabled = !databaseSaveInProgress;
|
||||||
|
mnuToolsOptions.Enabled = !databaseSaveInProgress;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,6 +711,7 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
databaseSaveInProgress = true;
|
databaseSaveInProgress = true;
|
||||||
storageModeMenu.Enabled = false;
|
storageModeMenu.Enabled = false;
|
||||||
|
mnuToolsOptions.Enabled = false;
|
||||||
EnableCheck();
|
EnableCheck();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -729,6 +734,7 @@ namespace OpenNest.Forms
|
|||||||
if (!IsDisposed)
|
if (!IsDisposed)
|
||||||
{
|
{
|
||||||
storageModeMenu.Enabled = !databaseOpenInProgress;
|
storageModeMenu.Enabled = !databaseOpenInProgress;
|
||||||
|
mnuToolsOptions.Enabled = !databaseOpenInProgress;
|
||||||
EnableCheck();
|
EnableCheck();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+42
-3
@@ -41,9 +41,13 @@
|
|||||||
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
|
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
|
||||||
this.colorSchemeLabel = new System.Windows.Forms.Label();
|
this.colorSchemeLabel = new System.Windows.Forms.Label();
|
||||||
this.colorSchemeCombo = new System.Windows.Forms.ComboBox();
|
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();
|
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
|
||||||
this.tableLayoutPanel1.SuspendLayout();
|
this.tableLayoutPanel1.SuspendLayout();
|
||||||
this.bottomPanel1.SuspendLayout();
|
this.bottomPanel1.SuspendLayout();
|
||||||
|
this.serverGroupBox.SuspendLayout();
|
||||||
this.SuspendLayout();
|
this.SuspendLayout();
|
||||||
//
|
//
|
||||||
// checkBox1
|
// checkBox1
|
||||||
@@ -135,7 +139,7 @@
|
|||||||
this.bottomPanel1.Controls.Add(this.cancelButton);
|
this.bottomPanel1.Controls.Add(this.cancelButton);
|
||||||
this.bottomPanel1.Controls.Add(this.saveButton);
|
this.bottomPanel1.Controls.Add(this.saveButton);
|
||||||
this.bottomPanel1.Dock = System.Windows.Forms.DockStyle.Bottom;
|
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.Name = "bottomPanel1";
|
||||||
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
|
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
|
||||||
this.bottomPanel1.TabIndex = 1;
|
this.bottomPanel1.TabIndex = 1;
|
||||||
@@ -158,7 +162,7 @@
|
|||||||
// strategyGroupBox
|
// strategyGroupBox
|
||||||
//
|
//
|
||||||
this.strategyGroupBox.Controls.Add(this.strategyGrid);
|
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.Name = "strategyGroupBox";
|
||||||
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
|
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
|
||||||
this.strategyGroupBox.TabIndex = 2;
|
this.strategyGroupBox.TabIndex = 2;
|
||||||
@@ -184,13 +188,43 @@
|
|||||||
this.colorSchemeCombo.Size = new System.Drawing.Size(130, 24);
|
this.colorSchemeCombo.Size = new System.Drawing.Size(130, 24);
|
||||||
this.colorSchemeCombo.TabIndex = 11;
|
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
|
// OptionsForm
|
||||||
//
|
//
|
||||||
this.AcceptButton = this.saveButton;
|
this.AcceptButton = this.saveButton;
|
||||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
||||||
this.CancelButton = this.cancelButton;
|
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.strategyGroupBox);
|
||||||
|
this.Controls.Add(this.serverGroupBox);
|
||||||
this.Controls.Add(this.tableLayoutPanel1);
|
this.Controls.Add(this.tableLayoutPanel1);
|
||||||
this.Controls.Add(this.bottomPanel1);
|
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)));
|
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.ResumeLayout(false);
|
||||||
this.tableLayoutPanel1.PerformLayout();
|
this.tableLayoutPanel1.PerformLayout();
|
||||||
this.bottomPanel1.ResumeLayout(false);
|
this.bottomPanel1.ResumeLayout(false);
|
||||||
|
this.serverGroupBox.ResumeLayout(false);
|
||||||
|
this.serverGroupBox.PerformLayout();
|
||||||
this.ResumeLayout(false);
|
this.ResumeLayout(false);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -224,5 +260,8 @@
|
|||||||
private System.Windows.Forms.GroupBox strategyGroupBox;
|
private System.Windows.Forms.GroupBox strategyGroupBox;
|
||||||
private System.Windows.Forms.Label colorSchemeLabel;
|
private System.Windows.Forms.Label colorSchemeLabel;
|
||||||
private System.Windows.Forms.ComboBox colorSchemeCombo;
|
private System.Windows.Forms.ComboBox colorSchemeCombo;
|
||||||
|
private System.Windows.Forms.GroupBox serverGroupBox;
|
||||||
|
private System.Windows.Forms.TextBox serverUrlBox;
|
||||||
|
private System.Windows.Forms.Button setServerButton;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
using OpenNest.Data;
|
||||||
using OpenNest.Engine.Strategies;
|
using OpenNest.Engine.Strategies;
|
||||||
using OpenNest.Properties;
|
using OpenNest.Properties;
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
private void LoadSettings()
|
private void LoadSettings()
|
||||||
{
|
{
|
||||||
|
serverUrlBox.Text = NestStorage.Settings.ServerUrl;
|
||||||
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
|
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
|
||||||
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
|
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
|
||||||
|
|
||||||
@@ -142,5 +144,28 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
SaveSettings();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+17
-5
@@ -31,6 +31,7 @@
|
|||||||
tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel();
|
tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel();
|
||||||
label1 = new System.Windows.Forms.Label();
|
label1 = new System.Windows.Forms.Label();
|
||||||
numericUpDown1 = new OpenNest.Controls.NumericUpDown();
|
numericUpDown1 = new OpenNest.Controls.NumericUpDown();
|
||||||
|
doneButton = new System.Windows.Forms.Button();
|
||||||
tableLayoutPanel1.SuspendLayout();
|
tableLayoutPanel1.SuspendLayout();
|
||||||
((System.ComponentModel.ISupportInitialize)numericUpDown1).BeginInit();
|
((System.ComponentModel.ISupportInitialize)numericUpDown1).BeginInit();
|
||||||
SuspendLayout();
|
SuspendLayout();
|
||||||
@@ -42,13 +43,15 @@
|
|||||||
tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
|
tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
|
||||||
tableLayoutPanel1.Controls.Add(label1, 0, 0);
|
tableLayoutPanel1.Controls.Add(label1, 0, 0);
|
||||||
tableLayoutPanel1.Controls.Add(numericUpDown1, 1, 0);
|
tableLayoutPanel1.Controls.Add(numericUpDown1, 1, 0);
|
||||||
|
tableLayoutPanel1.Controls.Add(doneButton, 1, 1);
|
||||||
tableLayoutPanel1.Dock = System.Windows.Forms.DockStyle.Fill;
|
tableLayoutPanel1.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||||
tableLayoutPanel1.Location = new System.Drawing.Point(0, 0);
|
tableLayoutPanel1.Location = new System.Drawing.Point(0, 0);
|
||||||
tableLayoutPanel1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
tableLayoutPanel1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||||
tableLayoutPanel1.Name = "tableLayoutPanel1";
|
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.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;
|
tableLayoutPanel1.TabIndex = 0;
|
||||||
//
|
//
|
||||||
// label1
|
// label1
|
||||||
@@ -74,20 +77,28 @@
|
|||||||
numericUpDown1.TabIndex = 1;
|
numericUpDown1.TabIndex = 1;
|
||||||
numericUpDown1.Value = new decimal(new int[] { 1, 0, 0, 0 });
|
numericUpDown1.Value = new decimal(new int[] { 1, 0, 0, 0 });
|
||||||
numericUpDown1.Leave += numericUpDown1_Leave;
|
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
|
// SequenceForm
|
||||||
//
|
//
|
||||||
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||||
ClientSize = new System.Drawing.Size(266, 35);
|
ClientSize = new System.Drawing.Size(266, 74);
|
||||||
ControlBox = false;
|
|
||||||
Controls.Add(tableLayoutPanel1);
|
Controls.Add(tableLayoutPanel1);
|
||||||
FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow;
|
FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow;
|
||||||
Location = new System.Drawing.Point(100, 100);
|
Location = new System.Drawing.Point(100, 100);
|
||||||
Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||||
MaximizeBox = false;
|
MaximizeBox = false;
|
||||||
MinimizeBox = false;
|
MinimizeBox = false;
|
||||||
MinimumSize = new System.Drawing.Size(268, 74);
|
MinimumSize = new System.Drawing.Size(268, 113);
|
||||||
Name = "SequenceForm";
|
Name = "SequenceForm";
|
||||||
ShowIcon = false;
|
ShowIcon = false;
|
||||||
ShowInTaskbar = false;
|
ShowInTaskbar = false;
|
||||||
@@ -105,5 +116,6 @@
|
|||||||
private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1;
|
private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1;
|
||||||
private System.Windows.Forms.Label label1;
|
private System.Windows.Forms.Label label1;
|
||||||
public Controls.NumericUpDown numericUpDown1;
|
public Controls.NumericUpDown numericUpDown1;
|
||||||
|
private System.Windows.Forms.Button doneButton;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
|
||||||
namespace OpenNest.Forms
|
namespace OpenNest.Forms
|
||||||
@@ -10,6 +10,13 @@ namespace OpenNest.Forms
|
|||||||
InitializeComponent();
|
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)
|
private void numericUpDown1_Leave(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
numericUpDown1.Validate();
|
numericUpDown1.Validate();
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ Invalid output is printed and rejected with exit code 2 without saving or postin
|
|||||||
|
|
||||||
MCP `autonest_plate` requires an empty target. The stdio server serializes all tool calls sharing its mutable session, so another request cannot change drawings or occupy a target during a solve. `allow_invalid` defaults to false. It reports violations and makes no changes on rejection, including with an override when the output is unrepresentable or contains multiple sheets. `engine` defaults to Default, and its description lists the built-in engines with what each suits. Fill tools (`fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`) remain separate and default to the Fill strategy. Console and MCP load jobs plug-ins from `Engines/` beside their executable.
|
MCP `autonest_plate` requires an empty target. The stdio server serializes all tool calls sharing its mutable session, so another request cannot change drawings or occupy a target during a solve. `allow_invalid` defaults to false. It reports violations and makes no changes on rejection, including with an override when the output is unrepresentable or contains multiple sheets. `engine` defaults to Default, and its description lists the built-in engines with what each suits. Fill tools (`fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`) remain separate and default to the Fill strategy. Console and MCP load jobs plug-ins from `Engines/` beside their executable.
|
||||||
|
|
||||||
|
MCP `pack_plate` and `autonest_plate` report each requested drawing's newly placed count and remaining quantity, indexed by request; partial fulfillment is explicitly warned about. Per-sheet ratios are not enforced or required: an uneven sheet is acceptable if the remaining quantities are placed elsewhere in the nest. These two plate tools operate on a single selected plate and do not automatically finish a multi-sheet job; callers can use `autonest_job` for that. Duplicate request tokens and ambiguous drawing names are refused before placement. A zero-quantity pack request places zero parts for that drawing; negative quantities are rejected.
|
||||||
|
|
||||||
|
`autonest_job(plateIndex, drawingNames, quantities, engine?, no_new_plates=false, sheets?)` is the whole-session completion path. Quantities are total targets across existing placed parts (physical plate quantity included) plus newly proposed sheets. Without `sheets`, the selected empty cutoff-free plate supplies stock size, spacing, quadrant, grain and cutting settings; other empty plates with equivalent persisted settings may be reused first, then additional matching plates are created as needed. `no_new_plates=true` limits this legacy solve to existing matching empty plates. The request uses the independently validated `NestPipeline` and commits only when every requested quantity is met; invalid, incomplete, cancelled or unknown-engine proposals leave existing plates and counts unchanged. A no-placement solver result is not proof that geometry cannot fit. `pack_plate` and `autonest_plate` remain single-sheet operations.
|
||||||
|
|
||||||
|
For finite mixed-size inventory, supply `sheets` as an array, for example `{"plateIndex":0,"drawingNames":"large,small","quantities":"1,1","engine":"Rectangles","sheets":[{"width":60,"length":120,"quantity":1},{"width":48,"length":96,"quantity":2}]}`. Width and length have the same units and orientation as `create_plate`. Every row's positive integer `quantity` is the total physical sheets available for this solve, including any compatible pre-created empty plates; it is never a `Plate.Quantity` repeat multiplier or additional stock. The selected plate supplies common spacing, edge spacing, quadrant, grain and cutting settings, but is reused only if its size matches a chosen sheet. Other compatible empty sheets can be reused; occupied or cutoff-bearing sheets cannot. Only needed different-sized sheets are created. Duplicate size pairs, nonpositive or nonfinite dimensions, unusable work areas, nonpositive quantities, an empty list and excessive total inventory are refused before solving. `sheets` cannot be combined with `no_new_plates=true`. Omit `sheets` to retain the previous single-size behavior. Successful output reports `stock-0`, `stock-1`, etc. in input order with used/remaining/reused/created physical counts, plus committed session plate index to stock ID and per-drawing totals. Stock is request-local, not a persistent purchasing catalog. Unused empty templates may be omitted by the `.nest` writer on save.
|
||||||
|
|
||||||
|
MCP `delete_plate(plateIndex)` removes only a sheet with no parts and no cutoff definitions, from either loaded or newly created session plates. Occupied, cutoff-bearing and invalid indices are refused without changing the session. After deletion every later index shifts down by one; inspect `get_plate_info` before further edits. This is session cleanup, not a data purge or a save-time option. `save_nest` lists all session plates in its response, but the existing `.nest` writer omits empty cutoff-free plates from the serialized file; reloading may therefore have fewer plates than the response says. There is no opt-in switch to preserve empty plates in the file.
|
||||||
|
|
||||||
### MCP engine development harness
|
### MCP engine development harness
|
||||||
|
|
||||||
`test_engine` builds and runs `OpenNest.Console` in a configured, trusted checkout.
|
`test_engine` builds and runs `OpenNest.Console` in a configured, trusted checkout.
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ For a 120-by-81-inch sheet whose parts extend through 80 inches, the nominal ske
|
|||||||
|
|
||||||
## Part sequencing
|
## Part sequencing
|
||||||
|
|
||||||
|
For **Plate > Sequence Parts > Manual Sequence**, choose the one-based sequence number and hover a part on the sheet; only the part under the pointer is outlined. Click it to place it at that exact position, then the number advances up to the last part. Clicking empty sheet space changes nothing. Use **Done**, the window close button, right-click on the sheet, or Escape to leave the tool. Manual reordering changes neither geometry nor quantities.
|
||||||
|
|
||||||
After adding cutoffs, apply **Part Sequencing** to the current plate or all plates. Each cutoff is moved earlier as needed so it is cut before every part its nominal line passes through. Ordinary parts retain their relative order from the chosen sequencing route; a tail separator that crosses no parts keeps its normal route position rather than being forced to the front. Cutoff geometry, clearance, part programs, placements, and quantities are unchanged.
|
After adding cutoffs, apply **Part Sequencing** to the current plate or all plates. Each cutoff is moved earlier as needed so it is cut before every part its nominal line passes through. Ordinary parts retain their relative order from the chosen sequencing route; a tail separator that crosses no parts keeps its normal route position rather than being forced to the front. Cutoff geometry, clearance, part programs, placements, and quantities are unchanged.
|
||||||
|
|
||||||
The dependency check uses the nominal horizontal/vertical line and its start/end limits, not the trimmed cutting segments, which intentionally skip the parts. It conservatively checks placed part bounds (including edge contacts), so a cutoff through a concave recess can also move ahead of that part. Definitions are matched by drawing identity, not their displayed names. A cutoff part with no matching definition is conservatively ordered before all ordinary parts.
|
The dependency check uses the nominal horizontal/vertical line and its start/end limits, not the trimmed cutting segments, which intentionally skip the parts. It conservatively checks placed part bounds (including edge contacts), so a cutoff through a concave recess can also move ahead of that part. Definitions are matched by drawing identity, not their displayed names. A cutoff part with no matching definition is conservatively ordered before all ordinary parts.
|
||||||
|
|||||||
+26
-5
@@ -146,11 +146,24 @@ other curve already touches, while a contact anywhere else on the line still ref
|
|||||||
|
|
||||||
Source parts rank by modeled travel (material-centre distance at a holed-part
|
Source parts rank by modeled travel (material-centre distance at a holed-part
|
||||||
boundary); within a part, preferred contour order and facing-entry rank precede
|
boundary); within a part, preferred contour order and facing-entry rank precede
|
||||||
travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names are not tie breakers. The expansion budget
|
travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names
|
||||||
counts rejected candidates and frontier ranking as well as accepted moves, before
|
are not tie breakers.
|
||||||
emission; it is not a wall-clock timeout. Callers can cancel. Exhaustion may occur
|
|
||||||
before already-generated siblings are traversed; it returns a refusal, not an
|
When the next whole part is fixed by the tour or a part is already active, the
|
||||||
unranked fallback or a proof of geometric impossibility.
|
search generates and checks contour alternatives on demand in contour/entry rank
|
||||||
|
order. It does not emit every sibling before trying the first. Backtracking still
|
||||||
|
visits the remaining alternatives when needed; none is pruned. The free whole-part
|
||||||
|
fallback still evaluates all ready sources before ranking their travel distances.
|
||||||
|
|
||||||
|
The expansion budget counts evaluated candidates and frontier ranking, including
|
||||||
|
rejections, before emission; it is not a wall-clock timeout. An unvisited sibling
|
||||||
|
consumes no additional DFS-prefix emission work or per-sibling expansion charge;
|
||||||
|
entry-catalogue prechecks still emit isolated contours and consume budget.
|
||||||
|
Consequently a bounded search can
|
||||||
|
reach a different frontier and report different rejected approaches than eager
|
||||||
|
expansion did. All visited candidates retain their lead/rapid checks, and a selected
|
||||||
|
plan still receives fresh independent replay. Callers can cancel. Exhaustion returns
|
||||||
|
a refusal, not an unranked fallback or proof of geometric impossibility.
|
||||||
|
|
||||||
## Automatic outside entries and look-ahead
|
## Automatic outside entries and look-ahead
|
||||||
|
|
||||||
@@ -306,6 +319,14 @@ plans every plate that has parts. Both open one dialog built on
|
|||||||
obstruct it, spacing the parts farther apart. These are suggestions, not guaranteed
|
obstruct it, spacing the parts farther apart. These are suggestions, not guaranteed
|
||||||
fixes: replanning runs the same strict checks; an unverified fallback requires
|
fixes: replanning runs the same strict checks; an unverified fallback requires
|
||||||
separate warning acceptance and is not approval to post or cut.
|
separate warning acceptance and is not approval to post or cut.
|
||||||
|
- The desktop free-order batch first tries up to eight entries per contour for at most
|
||||||
|
1,000 expansions. Only a Ready proposal that passes independent replay is used;
|
||||||
|
otherwise it runs the original 16-entry search with its entire plate budget.
|
||||||
|
Cancellation stops both attempts. This short pass can choose a different valid
|
||||||
|
route, and a hard plate can take up to 1,000 extra expansions before the full
|
||||||
|
attempt. The current-order retry still uses its original 16-entry budget.
|
||||||
|
Returned expansion counts describe the retained attempt, not a discarded
|
||||||
|
short pass plus its full-cap retry.
|
||||||
- Every plate is captured on the UI thread and checked and planned on a worker. Clean part
|
- Every plate is captured on the UI thread and checked and planned on a worker. Clean part
|
||||||
material is checked for overlaps with the pre-post overlap analyzer; known overlapping parts
|
material is checked for overlaps with the pre-post overlap analyzer; known overlapping parts
|
||||||
still block that plate whatever its route. Incomplete checks remain visible warnings, never
|
still block that plate whatever its route. Incomplete checks remain visible warnings, never
|
||||||
|
|||||||
@@ -241,6 +241,10 @@ curl --fail http://<bind-address>:<port>/healthz # {"status":"ok"}
|
|||||||
|
|
||||||
In the desktop app choose **File > Storage Mode...**, Database, and enter the base
|
In the desktop app choose **File > Storage Mode...**, Database, and enter the base
|
||||||
URL `http://<bind-address>:<port>`, without `/healthz` or `/api/nests`.
|
URL `http://<bind-address>:<port>`, without `/healthz` or `/api/nests`.
|
||||||
|
To set or change just the saved server address without changing File/Database mode,
|
||||||
|
use **Tools > Options > Nest server URL > Set / Change Server**. This saves the URL
|
||||||
|
immediately, independently of the Options Save button; it does not migrate nests
|
||||||
|
or enable Database mode. Canceling Options afterward does not undo that address.
|
||||||
|
|
||||||
### Backup, restore, and upgrade
|
### Backup, restore, and upgrade
|
||||||
|
|
||||||
|
|||||||
+37
-2
@@ -70,8 +70,9 @@ remain separate sequencer/verification work.
|
|||||||
## Filling cutouts (not yet in production)
|
## Filling cutouts (not yet in production)
|
||||||
|
|
||||||
Placing parts inside another part's enclosed cutout is being built as a step that runs before any
|
Placing parts inside another part's enclosed cutout is being built as a step that runs before any
|
||||||
engine, so every engine benefits. Nothing calls it yet: a part inside a cutout must be cut before
|
engine, so every engine benefits. Only an internal test-only pipeline preview calls it: a part
|
||||||
the cutout's contour, and the sequencer does not enforce that order.
|
inside a cutout must be cut before the cutout's contour, and the cutting/post safety gates are
|
||||||
|
not complete. Ordinary whole-job calls do not enable this path.
|
||||||
|
|
||||||
`CutoutLatticeFill` (`OpenNest.Engine/Jobs/Cutouts/`) fills one closed cutout with copies of one
|
`CutoutLatticeFill` (`OpenNest.Engine/Jobs/Cutouts/`) fills one closed cutout with copies of one
|
||||||
part. It runs Fill over the cutout's bounds plus one part step on every side, then shifts
|
part. It runs Fill over the cutout's bounds plus one part step on every side, then shifts
|
||||||
@@ -83,6 +84,40 @@ layout check uses. The method suits many small copies in a large cutout; a few l
|
|||||||
meant for no-fit-polygon placement. Fill can return different, equally scored lattices on repeated
|
meant for no-fit-polygon placement. Fill can return different, equally scored lattices on repeated
|
||||||
calls for some parts, so results are not yet guaranteed identical between runs.
|
calls for some parts, so results are not yet guaranteed identical between runs.
|
||||||
|
|
||||||
|
The internal `CutoutRouter` can propose copies from the shifted lattice, then search bounded
|
||||||
|
inner-fit/NFP sample points for remaining copies and other insert requirements. It keeps
|
||||||
|
original requirement IDs, reindexes accepted copies and checks clearance against the frame
|
||||||
|
and every previously accepted insert. Lattice shifts account for occupied poses before
|
||||||
|
quantity trimming; a pre-fill work limit declines giant grids and lets bounded NFP sampling
|
||||||
|
try instead. A null NFP proposal is not proof of geometric impossibility. No material-area
|
||||||
|
ratio cutoff rejects a possible placement: the measured 0.10 and 0.35 ratios guide
|
||||||
|
search order only. Below 0.10 it starts with Fill for three or more copies; between
|
||||||
|
0.10 and 0.35 it compares Fill-plus-NFP with NFP-only counts; above 0.35 it tries
|
||||||
|
NFP first, then Fill if demand remains, taking the higher-count valid proposal.
|
||||||
|
An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole.
|
||||||
|
Six geometry-only, anonymized real-job probes and neutral ring fixtures informed
|
||||||
|
the search-order hints; these limited cases are not a global density guarantee.
|
||||||
|
The internal `NestPipeline.RunCutoutPreview` builds quantity-one proxy instances for eligible
|
||||||
|
closed-cutout frames. It considers legal frame rotations without outer-perimeter symmetry
|
||||||
|
reduction, shifts insert rotation policies into the local frame, and reserves certified router
|
||||||
|
poses by original requirement ID. It can fill multiple holes and repeated/different frame
|
||||||
|
requirements; inserts with a different priority or their own cutouts stay independent, so
|
||||||
|
bundles remain flat. It uses the largest offered spacing for every bundle because the current
|
||||||
|
job contract cannot condition insert demand on the stock selected later. Remaining demand is
|
||||||
|
offered to the selected engine once. Only actually placed proxies expand to original frame and
|
||||||
|
insert identities. An unplaced proxy's reservations return to the original unplaced demand;
|
||||||
|
they cannot be packed retroactively in that same solve. Engine indices and metadata are checked,
|
||||||
|
then every physical pose, stock, rotation, quantity and fulfillment is independently validated
|
||||||
|
before binding caller drawings. Incomplete, forged or geometrically invalid composites return
|
||||||
|
no bindable plates even with invalid-result consent. Candidate evaluation progress is forwarded
|
||||||
|
with unverified proxy commitment counts zeroed; transformed proxy commits are hidden. Physical
|
||||||
|
commits are reported only after physical validation. Preparation is bounded to 32 bundles
|
||||||
|
and 16 base sweep-angle samples per frame (automatic quarter-turns are all retained);
|
||||||
|
remaining copies stay ordinary requirements. Fixed legal proxy orientations are a
|
||||||
|
conservative search choice, not full engine choice among all legal orientations; unsupported
|
||||||
|
bundles remain ordinary independent requirements. This is an internal pipeline path, not a
|
||||||
|
production setting or cut-ready nest. Cutting/post safety remains pending before enablement.
|
||||||
|
|
||||||
## Renamed engines
|
## Renamed engines
|
||||||
|
|
||||||
The registry maps names used by earlier releases (the first two shipped as plug-ins) so saved
|
The registry maps names used by earlier releases (the first two shipped as plug-ins) so saved
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Cutting-planner native curve reuse
|
||||||
|
|
||||||
|
The opt-in 144-part synthetic grid probe measures only `CuttingPlanService.Plan`, not build,
|
||||||
|
fixture creation, assertions, desktop rendering or posting:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
OPENNEST_RUN_CUTTING_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \
|
||||||
|
--filter 'FullyQualifiedName~ReorderSearchTests.DenseGrid144_PerformanceProbe' \
|
||||||
|
--logger 'console;verbosity=detailed'
|
||||||
|
```
|
||||||
|
|
||||||
|
Run separate fresh processes on the same machine with build and filesystem caches warm;
|
||||||
|
there is no in-process plan/JIT warmup, so these are first-call measurements. Compare
|
||||||
|
`Cutting144 elapsedMs`, `expansions`, `status` and `executionSha256`. The hash covers
|
||||||
|
expanded owned motions (including called hole programs), their layers, rapid flags,
|
||||||
|
exact coordinate bits, curve type and arc/circle parameters in source order; it does
|
||||||
|
not claim byte-identical authored G-code flags or post output. The test also requires
|
||||||
|
a Ready, independently replayed result and exact source-part conservation. Without
|
||||||
|
the environment flag it skips. Timing is diagnostic, not a deadline assertion.
|
||||||
|
|
||||||
|
The native curve wrapper now lazily owns one private native `Entity` for repeated
|
||||||
|
contact/containment queries. `ToEntity()` still makes a fresh object for other callers.
|
||||||
|
No lead or rapid check is skipped: long-lead/tiny-circle numerical contacts make a
|
||||||
|
fixed-extent material prefilter unsafe. Retest the cutting-planner and native-contact
|
||||||
|
regressions, especially near tangencies, repeated/concurrent queries and incomplete
|
||||||
|
material; do not infer posted NC or machine acceptance from this probe.
|
||||||
Reference in new issue
Block a user