This commit is contained in:
aj committed 2026-10-10 12:38:50 -04:00
commit ef6ca80e6d
38 files changed
+2896 -186

No files matched your search

@@ -106,8 +106,13 @@ internal static class PostVerificationGeometry
Center = center;
Radius = radius;
Sweep = sweep;
nativeEntity = new Lazy<Entity>(ToEntity);
}
// Never expose this mutable native wrapper; its geometry is read-only after creation.
// Lazy preserves the old conversion point for unsupported curves and is safe for concurrent checks.
private readonly Lazy<Entity> nativeEntity;
internal Vector Start { get; }
internal Vector End { get; }
private Vector? Center { get; }
@@ -170,12 +175,12 @@ internal static class PostVerificationGeometry
: Start + (End - Start) * (distance / Length);
}
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon;
internal bool Contains(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= Epsilon;
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
{
var entity = ToEntity();
var candidate = other.ToEntity();
var entity = nativeEntity.Value;
var candidate = other.nativeEntity.Value;
// Native arc filters discard NaN supporting-circle intersections. Inspect
// both unfiltered queries first: roundoff can differ by operand direction.
// Coincident supports have separate overlap handling below.
@@ -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;
}
}
}
+12
View File
@@ -32,6 +32,18 @@ public sealed class NestStorageSettings
public bool IsDatabaseMode =>
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
/// <summary>Validate and normalize a nest server base URL without changing storage mode.</summary>
public static bool TryNormalizeServerUrl(string? url, out string normalized)
{
normalized = NormalizeUrl(url);
if (Uri.TryCreate(normalized, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return true;
normalized = "";
return false;
}
/// <summary>%APPDATA%\OpenNest\storage.json.</summary>
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
@@ -0,0 +1,154 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutNfpProposalTests
{
private const double Spacing = 0.25;
[Fact]
public void LargeInsertFitsRoundCutoutAndRemainsValidAfterFrameRotation()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1, RotationPolicy.Fixed(System.Math.PI / 2));
var insert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(0));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.Equal(insert.Id, pose.PartId);
Assert.Equal(0, pose.InstanceIndex);
Assert.True(insert.Rotation.Allows(pose.Rotation));
AssertSquareInRoundHole(pose, 6, Spacing);
var localFrame = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), localFrame,
JobPartGeometry.Read(insert.Geometry), pose, Spacing));
var framePose = new NestJobPlacement(frame.Id, 0, 11, 11, System.Math.PI / 2);
var movedInsert = pose with
{
X = framePose.X - pose.Y,
Y = framePose.Y + pose.X,
Rotation = pose.Rotation + framePose.Rotation,
};
// This insert is fixed at zero for the local proposal; the rotated variant needs
// a correspondingly legal global policy before it can be handed to a job.
var rotatedInsert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(System.Math.PI / 2));
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), framePose,
JobPartGeometry.Read(insert.Geometry), movedInsert, Spacing));
AssertSquareInRoundHole(movedInsert, 6, Spacing, framePose.X, framePose.Y);
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
var job = JobBuilder.Job(new[] { frame, rotatedInsert }, new[] { stock });
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { framePose, movedInsert }) },
new[] { new PartFulfillment(frame.Id, 1, 1, 0), new PartFulfillment(insert.Id, 1, 1, 0) },
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
}
[Fact]
public void LargeInsertFitsSquareCutoutWithoutUsingFillLattice()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
var frame = JobBuilder.Part("square-frame", program, 1);
var insert = JobBuilder.Rectangle("large", 9, 9, 1, RotationPolicy.Fixed(0));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.All(new[] { (0.0, 0.0), (9.0, 0.0), (9.0, 9.0), (0.0, 9.0) }, corner =>
{
Assert.InRange(pose.X + corner.Item1, 5 + Spacing, 15 - Spacing);
Assert.InRange(pose.Y + corner.Item2, 5 + Spacing, 15 - Spacing);
});
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry),
new NestJobPlacement(frame.Id, 0, 0, 0, 0), JobPartGeometry.Read(insert.Geometry), pose, Spacing));
Assert.Null(CutoutNfpProposal.Find(frame, 0, JobBuilder.Rectangle("oversized", 9.6, 9.6, 1),
Spacing));
}
[Fact]
public void ObliqueFixedInsertRotationIsUsedInInnerFit()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
var frame = JobBuilder.Part("frame", program, 1);
var insert = JobBuilder.Rectangle("insert", 3, 2, 1, RotationPolicy.Fixed(System.Math.PI / 4));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.True(insert.Rotation.Allows(pose.Rotation));
foreach (var (x, y) in new[] { (0.0, 0.0), (3.0, 0.0), (3.0, 2.0), (0.0, 2.0) })
{
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
Assert.InRange(rx, 5 + Spacing, 15 - Spacing);
Assert.InRange(ry, 5 + Spacing, 15 - Spacing);
}
}
[Theory]
[InlineData(9.51)]
[InlineData(10.0)]
[InlineData(9.6)]
public void OversizedInsertIsNeverProposed(double side)
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", side, side, 1);
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, Spacing));
}
[Fact]
public void UnsupportedCoordinateScaleFailsClosed()
{
const double scale = 1e15;
var program = TestShapes.Polyline((-scale, -scale), (scale, -scale),
(scale, scale), (-scale, scale));
program.MoveTo(-scale / 2, -scale / 2);
program.LineTo(-scale / 2, scale / 2);
program.LineTo(scale / 2, scale / 2);
program.LineTo(scale / 2, -scale / 2);
program.LineTo(-scale / 2, -scale / 2);
Assert.Null(CutoutNfpProposal.Find(JobBuilder.Part("huge", program, 1), 0,
JobBuilder.Rectangle("insert", 6, 6, 1), Spacing));
}
[Fact]
public void ImpossibleHugeSpacingFailsBeforeAnyOffset()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, 1e12));
}
private static void AssertSquareInRoundHole(NestJobPlacement pose, double side, double spacing,
double centerX = 0, double centerY = 0)
{
var sin = System.Math.Sin(pose.Rotation);
var cos = System.Math.Cos(pose.Rotation);
foreach (var (x, y) in new[] { (0.0, 0.0), (side, 0.0), (side, side), (0.0, side) })
{
var dx = pose.X + x * cos - y * sin - centerX;
var dy = pose.Y + x * sin + y * cos - centerY;
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) <= 5 - spacing + 0.00001,
$"corner ({dx}, {dy}) does not clear the circular wall");
}
}
[Fact]
public void CancellationIsNotReportedAsNoFit()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
Assert.Throws<OperationCanceledException>(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing,
cancelled.Token));
}
}
@@ -0,0 +1,749 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
[CollectionDefinition("Cutout pipeline registry", DisableParallelization = true)]
public sealed class CutoutPipelineRegistryCollection { }
[Collection("Cutout pipeline registry")]
public class CutoutPipelinePreviewTests
{
private static NestPipelineRequest Request(string engine = "Irregular")
{
var frame = new Drawing("frame", TestShapes.Ring(20, 10));
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
return new NestPipelineRequest(engine,
new[] { new NestItem { Drawing = frame, Quantity = 1 },
new NestItem { Drawing = insert, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
}
[Theory]
[InlineData("Irregular")]
[InlineData("Default")]
public void RegisteredEngineExpandsOnlyPhysicalOriginalDrawings(string engine)
{
var request = Request(engine);
var result = NestPipeline.RunCutoutPreview(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.True(result.CanKeep);
var plate = Assert.Single(result.Plates);
Assert.Equal(2, plate.Parts.Count);
Assert.Same(request.Items[0].Drawing, plate.Parts[0].BaseDrawing);
Assert.Same(request.Items[1].Drawing, plate.Parts[1].BaseDrawing);
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates[0].Placements.Select(p => p.PartId));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 1, 0), (f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(0, request.Items[0].Drawing.Quantity.Nested);
Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested);
}
private sealed class Stub(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => solve(job);
}
private static NestJobResult Result(NestJob job, params NestJobPlacement[] poses)
{
var placed = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count());
var complete = job.Parts.All(p => placed.GetValueOrDefault(p.Id) == p.Quantity);
return new NestJobResult(complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
complete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound,
poses.Length == 0 ? Array.Empty<NestJobPlateResult>() :
new[] { new NestJobPlateResult(0, job.Plates[0], poses) },
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity,
placed.GetValueOrDefault(p.Id), p.Quantity - placed.GetValueOrDefault(p.Id))),
new[] { new StockUsage(job.Plates[0].Id, poses.Length == 0 ? 0 : 1,
job.Plates[0].Quantity - (poses.Length == 0 ? 0 : 1)) });
}
[Fact]
public void SingleSolveReceivesOneProxyAndNoDuplicatedInsertDemand()
{
var called = 0;
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
called++;
var proxy = Assert.Single(job.Parts);
Assert.StartsWith("__cutout-proxy-", proxy.Id);
Assert.True(proxy.Rotation.Allows(0));
Assert.False(proxy.Rotation.Allows(System.Math.PI / 2));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request);
Assert.Equal(1, called);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Single().Parts.Count);
}
[Fact]
public void IncompleteEngineDoesNotBindReservedInsertEvenWithInvalidConsent()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job => Result(job)), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("did not complete"));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 0, 1),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested);
AssertCannotCommit(result, request);
}
[Fact]
public void ForgedFulfillmentOrDuplicateIndexDoesNotBindComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var id = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")).Id;
var pose = new NestJobPlacement(id, 1, 11, 11, 0);
return Result(job, pose);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
}
[Fact]
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
{
var request = Request("stub");
request.Items[1].Quantity = 2;
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } };
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var residual = job.Parts.Single(p => p.Id == "part-1");
Assert.Equal(1, residual.Quantity);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement(residual.Id, 0, 22, 1, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.Single().Placements
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
Assert.All(result.Plates.Single().Parts.Where(p => p.BaseDrawing == request.Items[1].Drawing),
p => Assert.Same(request.Items[1].Drawing, p.BaseDrawing));
}
[Fact]
public void UntrustedStockOverdrawOrRotationCannotBindAComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11,
System.Math.PI / 2));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("rotation constraint"));
var overdraw = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, job.Plates[0], new[] {
new NestJobPlacement(job.Parts[0].Id, 0, 11, 11, 0) }),
new NestJobPlateResult(1, job.Plates[0], new[] {
new NestJobPlacement(job.Parts[0].Id, 1, 11, 11, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>())), "stub", request);
Assert.False(overdraw.CanKeep);
Assert.Empty(overdraw.Plates);
Assert.Contains(overdraw.Violations, v => v.Contains("only 1 are available"));
}
[Fact]
public void CancellationBeforePreparationDoesNotCallEngine()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
var engine = new Stub(_ => throw new Exception("Must not call Solve"));
Assert.Throws<OperationCanceledException>(() =>
NestPipeline.RunCutoutPreview(engine, "stub", Request("stub"), token: cts.Token));
}
[Fact]
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
{
var request = Request("stub");
request.Items[1].Quantity = 2;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement("part-1", 0, 11, 11, 0));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("required spacing"));
AssertCannotCommit(result, request);
}
private static void AssertCannotCommit(NestPipelineResult result, NestPipelineRequest request)
{
var nest = new Nest();
foreach (var item in request.Items)
nest.Drawings.Add(item.Drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
Assert.Throws<InvalidOperationException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
}
private sealed class CollectProgress : IProgress<NestJobProgress>
{
public List<NestJobProgress> Events { get; } = new();
public void Report(NestJobProgress value) => Events.Add(value);
}
[Fact]
public void EvaluationProgressSurvivesButUnverifiedCommitsNeverEscape()
{
var request = Request("stub");
var progress = new CollectProgress();
var result = NestPipeline.RunCutoutPreview(new ProgressStub(), "stub", request, progress);
Assert.False(result.CanKeep);
Assert.Single(progress.Events);
Assert.Equal(NestJobStage.EvaluatingCandidate, progress.Events[0].Stage);
Assert.Equal(0, progress.Events[0].CommittedPlates);
Assert.Equal(0, progress.Events[0].CommittedParts);
}
private sealed class ProgressStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
job.Plates[0].Id, 0, 1, 1));
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
job.Plates[0].Id, 0, 1, 1));
return Result(job);
}
}
[Fact]
public void ReorderedButContiguousEngineIndicesAreAccepted()
{
var request = Request("stub");
request.Items[1].Quantity = 3;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) } };
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
Assert.Equal(2, job.Parts.Single(p => p.Id == "part-1").Quantity);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement("part-1", 1, 22, 1, 0),
new NestJobPlacement("part-1", 0, 22, 9, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { 0, 1, 2 }, result.Raw.Plates.Single().Placements
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ForgedFulfillmentOrStockMetadataCannotBind(bool fulfillment)
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var good = Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
return new NestJobResult(good.Status, good.StopReason, good.Plates,
fulfillment ? new[] { new PartFulfillment(proxy.Id, 1, 0, 1) } : good.Fulfillment,
fulfillment ? good.StockUsage : new[] { new StockUsage("sheet", 0, 1) });
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
}
[Fact]
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
{
var initial = Request("stub");
var request = initial with
{
Items = new[] { initial.Items[0], initial.Items[1],
new NestItem { Drawing = initial.Items[1].Drawing, Quantity = 1 } },
Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) }
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var residual = job.Parts.Where(p => p.Id != proxy.Id)
.Select((p, i) => new NestJobPlacement(p.Id, 0, 22, 1 + 5 * i, 0));
return Result(job, new[] { new NestJobPlacement(proxy.Id, 0, 11, 11, 0) }
.Concat(residual).ToArray());
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1", "part-2" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
Assert.Same(result.Plates.Single().Parts[1].BaseDrawing,
result.Plates.Single().Parts[2].BaseDrawing);
}
[Fact]
public void RepeatedFrameReservationsExpandOnTwoPhysicalSheets()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with
{
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
Options = new NestJobOptions(maxPlates: 2)
};
var calls = 0;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
calls++;
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
Assert.Equal(2, proxies.Length);
Assert.Equal(2, job.Parts.Count);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in proxies)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.Equal(1, calls);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Raw.Plates.Count);
Assert.All(result.Raw.Plates, plate => Assert.Equal(new[] { "part-0", "part-1" },
plate.Placements.Select(p => p.PartId)));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 2, 0),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
}
[Fact]
public void UnplacedFrameReleasesInsertDemandWithoutABindablePartialProposal()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } };
var calls = 0;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
calls++;
var first = job.Parts.Single(p => p.Id.EndsWith("-0"));
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[0], new[] { (first.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.NoPlacementFound);
}), "stub", request);
Assert.Equal(1, calls);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Equal(NestJobStatus.Incomplete, result.Raw.Status);
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 1, 1),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId));
AssertCannotCommit(result, request);
}
[Fact]
public void DifferentFrameRequirementsShareScarceInsertsByIdentity()
{
var initial = Request("stub");
var frame2 = new Drawing("second-frame", TestShapes.Ring(18, 10));
initial.Items[1].Quantity = 2;
initial.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
var request = initial with
{
Items = new[] { initial.Items[0],
new NestItem { Drawing = frame2, Quantity = 1 }, initial.Items[1] },
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
Assert.Equal(2, proxies.Length);
Assert.Equal(2, job.Parts.Count);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in proxies)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-2", "part-1", "part-2" },
result.Raw.Plates.SelectMany(p => p.Placements).Select(p => p.PartId));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
.Where(p => p.PartId == "part-2").Select(p => p.InstanceIndex));
}
[Fact]
public void FixedQuarterTurnComposesLocalInsertRotationAndTranslation()
{
var frame = TestShapes.Rectangle(20, 20);
frame.MoveTo(5, 5);
frame.LineTo(5, 15);
frame.LineTo(15, 15);
frame.LineTo(15, 5);
frame.LineTo(5, 5);
var quarter = System.Math.PI / 2;
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 1,
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1,
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
Assert.True(proxy.Rotation.Allows(quarter));
Assert.False(proxy.Rotation.Allows(0));
return Result(job, new NestJobPlacement(proxy.Id, 0, 21, 1, quarter));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var poses = result.Raw.Plates.Single().Placements;
Assert.Equal(quarter, poses[0].Rotation, 8);
Assert.Equal(quarter, poses[1].Rotation, 8);
// Independent square-hole oracle, not the production clearance predicate.
var localX = poses[1].Y - poses[0].Y;
var localY = -(poses[1].X - poses[0].X);
foreach (var dx in new[] { 0.0, 3.0 })
foreach (var dy in new[] { 0.0, 3.0 })
{
Assert.InRange(localX + dx, 5.25, 14.75);
Assert.InRange(localY + dy, 5.25, 14.75);
}
}
[Fact]
public void HeterogeneousStockUsesConservativeSpacingAndOriginalQuadrant()
{
var request = Request("stub") with
{
Stock = new[] {
new NestPlateStock("tight", new Size(22, 22), 1, 0.25),
new NestPlateStock("wide", new Size(22, 22), 1, 0.6, quadrant: 3) }
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[1], new[] { (proxy.Id, -11.0, -11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal("wide", result.Raw.Plates.Single().StockId);
Assert.Equal(3, result.Plates.Single().Stock.Quadrant);
Assert.Equal(0.6, result.Plates.Single().Stock.PartSpacing);
Assert.Equal(2, result.Plates.Single().Parts.Count);
}
[Fact]
public void RegisteredPluginRunsTheSamePhysicalExpansionBoundary()
{
var name = "CutoutPlugin-" + Guid.NewGuid().ToString("N");
var calls = 0;
NestingEngineRegistry.Register(name, "test plugin", () => new Stub(job =>
{
calls++;
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}));
try
{
var result = NestPipeline.RunCutoutPreview(Request(name));
Assert.Equal(1, calls);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
}
finally
{
var registry = Assert.IsType<List<NestingEngineInfo>>(NestingEngineRegistry.AvailableEngines);
Assert.Equal(1, registry.RemoveAll(info => info.Name == name));
}
}
[Fact]
public void TwoCutoutsReserveDifferentCopiesWithinOneFrame()
{
var program = TestShapes.Rectangle(20, 20);
foreach (var origin in new[] { 2.0, 12.0 })
{
program.MoveTo(origin, origin);
program.LineTo(origin, origin + 6);
program.LineTo(origin + 6, origin + 6);
program.LineTo(origin + 6, origin);
program.LineTo(origin, origin);
}
var frame = new Drawing("two-hole-frame", program);
var insert = new Drawing("insert", TestShapes.Rectangle(4, 4));
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = frame, Quantity = 1 },
new NestItem { Drawing = insert, Quantity = 2 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 1, 1, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var inserts = result.Raw.Plates.Single().Placements.Skip(1).ToArray();
Assert.Equal(2, inserts.Length);
Assert.All(inserts, p =>
{
var localX = p.X - 1;
var localY = p.Y - 1;
Assert.Contains(new[] { 2.0, 12.0 }, origin =>
localX >= origin + 0.25 && localX + 4 <= origin + 5.75
&& localY >= origin + 0.25 && localY + 4 <= origin + 5.75);
});
}
[Fact]
public void DifferentPrioritiesRemainIndependentInsteadOfElevatingAnInsert()
{
var request = Request("stub");
request.Items[1].Priority = 3;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job);
}), "stub", request);
Assert.True(result.CanKeep);
Assert.Empty(result.Plates);
}
[Theory]
[InlineData(1, 11.0, 11.0)]
[InlineData(2, -11.0, 11.0)]
[InlineData(3, -11.0, -11.0)]
[InlineData(4, 11.0, -11.0)]
public void AllQuadrantsKeepPhysicalStockAndExpansion(int quadrant, double x, double y)
{
var request = Request("stub") with
{
Stock = new[] {
new NestPlateStock("sheet", new Size(22, 22), 1, 0.25,
new Spacing(0.5, 0.5), quadrant) },
Options = new NestJobOptions(maxPlates: 1)
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, x, y, 0))),
"stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(quadrant, result.Plates.Single().Stock.Quadrant);
Assert.Equal((x, y), (result.Raw.Plates.Single().Placements[0].X,
result.Raw.Plates.Single().Placements[0].Y));
}
[Fact]
public void CancellationAfterAnEngineIgnoresStopDiscardsEveryBundle()
{
var request = Request("stub");
using var cts = new CancellationTokenSource();
Assert.Throws<OperationCanceledException>(() => NestPipeline.RunCutoutPreview(
new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
cts.Cancel();
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request, token: cts.Token));
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
}
[Fact]
public void CompleteStatusWithMissingProxyIsUntrustedAndNotBindable()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
NestJobStatus.Complete, NestJobStopReason.Completed,
Array.Empty<NestJobPlateResult>(),
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)),
job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)))), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
AssertCannotCommit(result, request);
}
[Fact]
public void SuccessfulCompositeReportsOnlyValidatedPhysicalCommitCounts()
{
var request = Request("stub");
var progress = new CollectProgress();
var result = NestPipeline.RunCutoutPreview(new ReportingStub(), "stub", request, progress);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { NestJobStage.EvaluatingCandidate, NestJobStage.PlateCommitted },
progress.Events.Select(e => e.Stage));
Assert.Equal(2, progress.Events.Last().CommittedParts);
Assert.Equal(1, progress.Events.Last().CommittedPlates);
}
private sealed class ReportingStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var proxy = Assert.Single(job.Parts);
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
job.Plates[0].Id, 0, 0, 0));
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
job.Plates[0].Id, 0, 1, 1));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}
}
[Fact]
public void AutomaticBundleRetainsStockFeasibleQuarterTurn()
{
var program = TestShapes.Rectangle(20, 10);
program.MoveTo(2, 2);
program.LineTo(2, 8);
program.LineTo(18, 8);
program.LineTo(18, 2);
program.LineTo(2, 2);
var request = new NestPipelineRequest("Irregular", new[] {
new NestItem { Drawing = new Drawing("long-frame", program), Quantity = 1 },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1 } },
new[] { new NestPlateStock("narrow", new Size(22, 12), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var frame = result.Raw.Plates.Single().Placements[0];
Assert.Equal(System.Math.PI / 2, frame.Rotation, 7);
Assert.Equal(new[] { "part-0", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
}
[Fact]
public void ChangedLiveInsertDuringSolveNeverBindsUncheckedComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
request.Items[1].Drawing.Program = TestShapes.Rectangle(40, 40);
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("insert"));
AssertCannotCommit(result, request);
}
[Fact]
public void NondefaultOptionsPassUnchangedAndPlateCapRejectsOtherwiseValidOutput()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
var options = new NestJobOptions("Strip", maxPlates: 1,
salvageRate: 0.4, minimumSalvageDimension: 2);
request = request with
{
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
Options = options
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.Same(options, job.Options);
Assert.Equal("Strip", job.Options.PlacementStrategy);
Assert.Equal(0.4, job.Options.SalvageRate);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in job.Parts)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("MaxPlates"));
AssertCannotCommit(result, request);
}
[Fact]
public void ObliqueFramePreservesLegalGlobalInsertRotationAndWallClearance()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
const double angle = 0.3;
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", program), Quantity = 1,
StepAngle = 1, RotationStart = angle, RotationEnd = angle },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)),
Quantity = 1, StepAngle = 1, RotationStart = angle, RotationEnd = angle } },
new[] { new NestPlateStock("sheet", new Size(40, 40), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, 15, 5, angle))),
"stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var poses = result.Raw.Plates.Single().Placements;
Assert.Equal(angle, poses[1].Rotation, 8);
var dx = poses[1].X - poses[0].X;
var dy = poses[1].Y - poses[0].Y;
var cosine = System.Math.Cos(angle);
var sine = System.Math.Sin(angle);
var localX = dx * cosine + dy * sine;
var localY = -dx * sine + dy * cosine;
foreach (var x in new[] { 0.0, 3.0 })
foreach (var y in new[] { 0.0, 3.0 })
{
Assert.InRange(localX + x, 5.25, 14.75);
Assert.InRange(localY + y, 5.25, 14.75);
}
}
[Fact]
public void LargeDemandLeavesExcessFramesAndInsertsIndependentAfterBoundedPreparation()
{
var frame = TestShapes.Rectangle(20, 20);
frame.MoveTo(5, 5);
frame.LineTo(5, 15);
frame.LineTo(15, 15);
frame.LineTo(15, 5);
frame.LineTo(5, 5);
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 40,
StepAngle = 1, RotationStart = 0, RotationEnd = 0 },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(6, 6)),
Quantity = 40, StepAngle = 1, RotationStart = 0, RotationEnd = 0 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 40, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.Equal(32, job.Parts.Count(p => p.Id.StartsWith("__cutout-proxy-")));
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-0").Quantity);
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-1").Quantity);
return Result(job);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((40, 0, 40),
(f.Requested, f.Placed, f.Unplaced)));
}
[Fact]
public void OrdinaryPipelineIsStillUnchanged()
{
var request = Request("stub");
var result = NestPipeline.Run(new Stub(job =>
{
Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job);
}), "stub", request);
Assert.True(result.CanKeep);
Assert.Empty(result.Plates);
}
}
@@ -0,0 +1,228 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutRouterTests
{
private static NestJobPart Frame()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
return JobBuilder.Part("frame", program, 1);
}
[Fact]
public void SmallLatticeAndLargeResidualKeepTheirOriginalRequirementIds()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var small = JobBuilder.Part("small", TestShapes.Rectangle(1, 1), 3);
var large = JobBuilder.Rectangle("large", 3, 3, 1, RotationPolicy.Fixed(0));
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25,
(pose, lattice) => origins.Add((pose, lattice)));
Assert.Equal(3, poses.Count(p => p.PartId == small.Id));
Assert.Single(poses, p => p.PartId == large.Id);
Assert.Contains(origins, row => row.Pose.PartId == small.Id && row.Lattice);
Assert.Contains(origins, row => row.Pose.PartId == large.Id && !row.Lattice);
Assert.Equal(poses, origins.Select(row => row.Pose));
Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == small.Id).Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { small, large }, poses);
}
[Fact]
public void ImpossibleRemainderNeverDisplacesCertifiedCopies()
{
var frame = Frame();
var small = JobBuilder.Rectangle("small", 1, 1, 3, RotationPolicy.Fixed(0));
var impossible = JobBuilder.Rectangle("impossible", 9.6, 9.6, 1, RotationPolicy.Fixed(0));
var together = CutoutRouter.Fill(frame, 0, new[] { small, impossible }, 0.25);
Assert.Equal(3, together.Count);
Assert.All(together, p => Assert.Equal(small.Id, p.PartId));
Assert.Equal(Enumerable.Range(0, 3), together.Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { small, impossible }, together);
}
[Fact]
public void PriorCopiesAndQuantityCannotBeBypassedByResidualSearch()
{
var frame = Frame();
var small = JobBuilder.Rectangle("small", 2, 2, 4, RotationPolicy.Fixed(0));
var large = JobBuilder.Rectangle("large", 7, 7, 1, RotationPolicy.Fixed(0));
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25);
Assert.InRange(poses.Count(p => p.PartId == small.Id), 0, small.Quantity);
Assert.InRange(poses.Count(p => p.PartId == large.Id), 0, large.Quantity);
AssertPhysical(frame, new[] { small, large }, poses);
}
[Fact]
public void ResidualNfpDoesNotOverlapTheFirstLargeInsert()
{
var frame = Frame();
var insert = JobBuilder.Rectangle("large", 6, 6, 2, RotationPolicy.Fixed(0));
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Single(poses);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void NfpAddsOneMoreCopyToPartialShiftedLattice()
{
// Neutral 8-inch round hole, 2x1 insert, quarter-inch spacing: the shifted
// lattice keeps 11; an independently certified NFP pose occupies its leftover.
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12);
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25,
(pose, lattice) => origins.Add((pose, lattice)));
Assert.Equal(12, poses.Count);
Assert.Equal(11, origins.Count(row => row.Lattice));
Assert.Single(origins, row => !row.Lattice);
Assert.Equal(poses, origins.Select(row => row.Pose));
Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void MiddleRatioChoosesTwoLatticeCopiesOverOneNfpCopy()
{
// 4x4 / (pi*5^2) = 0.204. A hard NFP-only threshold at 0.20 loses one copy.
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(4, 4), 3);
Assert.InRange(16 / (System.Math.PI * 25), CutoutRouter.SmallRatio, CutoutRouter.LargeRatio);
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Equal(2, poses.Count);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void LargeRatioKeepsAnNfpPoseWhenFillCannotProduceALattice()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
var insert = JobBuilder.Rectangle("insert", 4.5, 4.5, 3, RotationPolicy.Fixed(0));
Assert.True(20.25 / (System.Math.PI * 16) > CutoutRouter.LargeRatio);
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, insert, 3, 0.25));
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Single(poses);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void DuplicateRequirementIdsAreRejectedBeforePlacement()
{
var frame = Frame();
var insert = JobBuilder.Rectangle("same", 1, 1, 3);
Assert.Throws<ArgumentException>(() => CutoutRouter.Fill(frame, 0,
new[] { insert, insert }, 0.25));
}
[Fact]
public void OccupiedAwareLatticeFillsAfterAnEarlierCopyBlocksItsFirstPose()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var first = JobBuilder.Part("first", TestShapes.Rectangle(1, 1), 1);
var more = JobBuilder.Part("more", TestShapes.Rectangle(1, 1), 3);
var early = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25);
Assert.Equal(3, early.Count);
var blocked = early[0] with { PartId = first.Id };
var occupied = new[] { (JobPartGeometry.Read(first.Geometry), blocked) };
var later = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25,
CutoutLatticeFill.DefaultShiftSteps, occupied, default);
Assert.Equal(3, later.Count);
Assert.All(later, p => Assert.True(NestLayoutCheck.Clears(occupied[0].Item1, blocked,
JobPartGeometry.Read(more.Geometry), p, 0.25)));
}
[Fact]
public void TinyInsertDoesNotAllocateAnUnboundedLattice()
{
var frame = Frame();
var tiny = JobBuilder.Rectangle("tiny", 0.001, 0.001, 3, RotationPolicy.Fixed(0));
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, tiny, 3, 0));
var poses = CutoutRouter.Fill(frame, 0, new[] { tiny }, 0);
Assert.Equal(3, poses.Count);
}
[Fact]
public void CancellationIsNotNoFit()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.Throws<OperationCanceledException>(() => CutoutRouter.Fill(Frame(), 0,
new[] { JobBuilder.Rectangle("small", 1, 1, 3) }, 0.25, cts.Token));
}
private static void AssertPhysical(NestJobPart frame, NestJobPart[] inserts,
IReadOnlyList<NestJobPlacement> poses)
{
var stock = JobBuilder.Stock("sheet", 22, 22, 0.25);
var job = JobBuilder.Job(new[] { frame }.Concat(inserts).ToArray(), new[] { stock });
var frameBounds = JobPartGeometry.Read(frame.Geometry).Bounds;
var offsetX = 1 - frameBounds.Left;
var offsetY = 1 - frameBounds.Bottom;
var placements = new[] { new NestJobPlacement(frame.Id, 0, offsetX, offsetY, 0) }
.Concat(poses.Select(p => p with { X = p.X + offsetX, Y = p.Y + offsetY })).ToArray();
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, placements) },
new[] { new PartFulfillment(frame.Id, 1, 1, 0) }
.Concat(inserts.Select(part => new PartFulfillment(part.Id, part.Quantity,
poses.Count(p => p.PartId == part.Id), part.Quantity - poses.Count(p => p.PartId == part.Id))))
.ToArray(),
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
foreach (var pose in poses)
{
var geometry = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry);
Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, 0.25));
// Independent square-wall oracle: every corner of each rectangular insert clears the hole.
var bounds = geometry.Bounds;
foreach (var (x, y) in new[] { (bounds.Left, bounds.Bottom), (bounds.Right, bounds.Bottom),
(bounds.Right, bounds.Top), (bounds.Left, bounds.Top) })
{
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
if (frameBounds.Left >= 0)
{
Assert.InRange(rx, 5.25, 14.75);
Assert.InRange(ry, 5.25, 14.75);
}
else
{
var radius = System.Math.Abs(frameGeometry.Cutouts[0].BoundingBox.Right);
Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= radius - 0.25 + 0.00001);
}
}
}
// Independent rectangle-vs-rectangle distance, not the production Clears predicate.
for (var i = 0; i < poses.Count; i++)
for (var j = i + 1; j < poses.Count; j++)
{
var a = Bounds(poses[i]);
var b = Bounds(poses[j]);
var dx = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right));
var dy = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top));
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.25 - 0.00001,
$"independent material gap too small: {poses[i]} vs {poses[j]}");
}
(double Left, double Right, double Bottom, double Top) Bounds(NestJobPlacement pose)
{
var box = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry).Bounds;
var corners = new[] { (box.Left, box.Bottom), (box.Right, box.Bottom),
(box.Right, box.Top), (box.Left, box.Top) };
var x = corners.Select(c => pose.X + c.Item1 * System.Math.Cos(pose.Rotation)
- c.Item2 * System.Math.Sin(pose.Rotation)).ToArray();
var y = corners.Select(c => pose.Y + c.Item1 * System.Math.Sin(pose.Rotation)
+ c.Item2 * System.Math.Cos(pose.Rotation)).ToArray();
return (x.Min(), x.Max(), y.Min(), y.Max());
}
}
}
@@ -75,6 +75,73 @@ public class NestPipelineCommitTests
Assert.Equal(2, Assert.Single(applied).Parts.Count);
}
[Fact]
public void DrawingChangedAfterValidationIsRefusedBeforeCommitEvenWithConsent()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new PreMutationStub(), "stub",
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
drawing.Program = TestDrawingFactory.Rectangle(100, 100);
Assert.Throws<InvalidOperationException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.Equal(0, drawing.Quantity.Nested);
}
private sealed class PreMutationStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[0], new[] { (job.Parts[0].Id, 1.0, 1.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}
}
private sealed class CancelDuringSnapshot(double x, double y, CancellationTokenSource source)
: OpenNest.CNC.LinearMove(x, y)
{
public override OpenNest.CNC.CodeType Type
{
get
{
source.Cancel();
return base.Type;
}
}
}
[Fact]
public void CancellationDuringFreshnessScanDoesNotMutateNest()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new PreMutationStub(), "stub",
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
Assert.True(result.IsValid);
using var cts = new CancellationTokenSource();
var proposed = Assert.Single(Assert.Single(result.Plates).Parts);
proposed.Program.Codes[1] = new CancelDuringSnapshot(10, 0, cts);
Assert.Throws<OperationCanceledException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, token: cts.Token));
Assert.True(cts.IsCancellationRequested);
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.Equal(0, drawing.Quantity.Nested);
}
[Fact]
public void CancelledCommitDoesNotCreateAnyPlate()
{
@@ -258,6 +258,44 @@ public class NestPipelineTests
);
}
[Fact]
public void DrawingChangedDuringSolveCannotBindGeometryOtherThanTheValidatedSnapshot()
{
var item = Item("bracket", 1);
var result = NestPipeline.Run(new StubEngine(job =>
{
item.Drawing.Program = TestDrawingFactory.Rectangle(100, 100);
return OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0));
}), "Mutator", Request("Mutator", item));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("bracket"));
}
private sealed class TransientCloneMove(double x, double y) : LinearMove(x, y)
{
public override ICode Clone()
{
var before = EndPoint;
EndPoint = new Vector(100, 100);
try { return base.Clone(); }
finally { EndPoint = before; }
}
}
[Fact]
public void BindingTransientDrawingEditCannotReturnOrCommitUncheckedGeometry()
{
var item = Item("bracket", 1);
item.Drawing.Program.Codes[^1] = new TransientCloneMove(0, 0);
var result = NestPipeline.Run(new StubEngine(job =>
OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0))),
"Transient", Request("Transient", item));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("Bound part") && v.Contains("bracket"));
}
[Fact]
public void CancellationPropagatesWithoutAResult()
{
@@ -43,6 +43,10 @@ public sealed class CuttingPlanBatch
/// </summary>
public const int ExpansionsPerPart = 400;
// Extra bounded work on hard/refused plates; the full-cap search still gets its whole budget.
internal const int FirstPassBudget = 1000;
internal const int FirstPassEntries = 8;
private readonly Entry[] entries;
private readonly CuttingParameters ownedParameters;
@@ -142,7 +146,7 @@ public sealed class CuttingPlanBatch
if (entry.Reorder != null)
{
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
reorder = CuttingPlanService.Plan(entry.Reorder, token);
reorder = PlanReorder(entry.Reorder, token);
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
{
plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap);
@@ -156,6 +160,22 @@ public sealed class CuttingPlanBatch
return new(plans, ownedParameters);
}
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
int firstPassBudget = FirstPassBudget)
{
// A failed first pass is not a proof of impossibility: retain all original
// full-cap alternatives and the complete original budget for the retry.
if (snapshot.Failure == null && snapshot.Regeneration && snapshot.MaxEntries > FirstPassEntries
&& snapshot.ExpansionBudget > firstPassBudget)
{
var first = CuttingPlanService.Plan(snapshot.WithSearchLimits(firstPassBudget, FirstPassEntries), token);
if ((first.Status == CuttingPlanStatus.Ready && first.IndependentlyReplayed)
|| first.Status == CuttingPlanStatus.Cancelled)
return first;
}
return CuttingPlanService.Plan(snapshot, token);
}
private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token)
{
if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null)
@@ -216,6 +236,10 @@ public sealed class CuttingPlanPlateResult
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
}
/// <summary>A diagnostic tied to captured plate and source part positions, not display text.</summary>
public sealed record CuttingPlanDiagnosticRow(int PlateIndex, int PlateNumber, int? PartNumber,
int? OtherPartNumber, string Message);
/// <summary>
/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance.
/// </summary>
@@ -243,6 +267,35 @@ public sealed class CuttingPlanProposal
public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply;
/// <summary>All findings, including ones hidden by a blocked plate's abbreviated summary.</summary>
public IReadOnlyList<CuttingPlanDiagnosticRow> DiagnosticRows()
{
var rows = new List<CuttingPlanDiagnosticRow>();
for (var index = 0; index < Plates.Count; index++)
{
var plate = Plates[index];
if (plate.Overlap != null)
{
foreach (var pair in plate.Overlap.Pairs)
rows.Add(new(index, plate.PlateNumber, pair.PartAId + 1, pair.PartBId + 1,
$"Part {pair.PartAId + 1}{Name(pair.PartAName)} overlaps part "
+ $"{pair.PartBId + 1}{Name(pair.PartBName)}."));
foreach (var issue in plate.Overlap.Issues)
rows.Add(new(index, plate.PlateNumber, issue.PartAId + 1,
issue.PartBId is int other ? other + 1 : null,
$"Overlap check incomplete for part {issue.PartAId + 1}"
+ (issue.PartBId is int second ? $" and part {second + 1}" : string.Empty)
+ $": {issue.Message}"));
}
foreach (var finding in plate.Result.Findings)
rows.Add(new(index, plate.PlateNumber,
finding.SourceOrdinal is int part ? part + 1 : null,
finding.OtherSourceOrdinal is int other ? other + 1 : null,
DescribeFinding(finding)));
}
return rows;
}
/// <summary>
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
@@ -93,6 +93,13 @@ public sealed class CuttingPlanSnapshot
public int ExpansionBudget { get; }
internal CuttingPlanStatus? Failure { get; }
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
// A worker-only search variant over the same owned capture. Never recapture the live plate:
// both proposals retain the identical freshness record and confirmed settings.
internal CuttingPlanSnapshot WithSearchLimits(int expansionBudget, int maxEntries) =>
new(Placements, StartPoint, expansionBudget, Failure, Findings, Regeneration,
PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters,
Dependencies, BestEffort);
}
public sealed class FixedProgramPlacement
@@ -196,29 +196,39 @@ internal static class JointCuttingPlanSearch
var progressExpansions = Expansions;
var stack = new Stack<Frame>();
stack.Push(new(root));
while (stack.Count != 0)
try
{
token.ThrowIfCancellationRequested();
var frame = stack.Peek();
var node = frame.Node;
if (node.Order.Length == snapshot.Placements.Count)
while (stack.Count != 0)
{
attempt.Order = node.Order;
return attempt;
token.ThrowIfCancellationRequested();
var frame = stack.Peek();
var node = frame.Node;
if (node.Order.Length == snapshot.Placements.Count)
{
attempt.Order = node.Order;
return attempt;
}
if (attempt.Advance(node))
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
return attempt;
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly).GetEnumerator();
if (!frame.Children.MoveNext())
{
stack.Pop().Children.Dispose();
continue;
}
stack.Push(new(frame.Children.Current.Node));
}
if (attempt.Advance(node))
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
return attempt;
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly);
if (frame.Next == frame.Children.Length)
{
stack.Pop();
continue;
}
stack.Push(new(frame.Children[frame.Next++].Node));
return attempt;
}
finally
{
// Ready, stalled, cancelled and budget-exhausted searches can all leave
// suspended siblings. Release their captured prefixes on every exit.
foreach (var frame in stack)
frame.Children?.Dispose();
}
return attempt;
}
/// <summary>
@@ -259,8 +269,14 @@ internal static class JointCuttingPlanSearch
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
/// </summary>
private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
private IEnumerable<Edge> OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
{
// A prescribed next part (or an active part's next contour) has a single
// source. Expand already yields its contour/entry rank order, so emit and
// validate a sibling only when DFS reaches it. Free whole-part selection
// still needs every source's distances before it can rank them.
if (sequence != null || node.Active != null)
return Expand(node, sequence, attempt, preferredOnly);
var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
if (edges.Count <= 1)
return edges.ToArray();
@@ -334,8 +350,10 @@ internal static class JointCuttingPlanSearch
// First try one ranked hole chain per endpoint. A later-part failure then
// changes the endpoint before replaying all earlier hole combinations.
// The retained pass below still searches every entry and hole order.
if (preference != null)
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).ThenBy(c => c);
if (preferredOnly && preference != null)
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1);
contours = contours.Take(1);
foreach (var contour in contours)
{
token.ThrowIfCancellationRequested();
@@ -593,7 +611,6 @@ internal static class JointCuttingPlanSearch
private sealed class Frame(Node node)
{
internal Node Node { get; } = node;
internal Edge[] Children { get; set; }
internal int Next { get; set; }
internal IEnumerator<Edge> Children { get; set; }
}
}
@@ -22,20 +22,24 @@ namespace OpenNest.Engine.Jobs.Cutouts;
/// </summary>
/// <remarks>
/// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement.
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Not wired into
/// any engine or pipeline yet: placing parts in cutouts waits on containment-aware cutting order.
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Used by the
/// internal pipeline cutout preview, but not enabled for normal whole-job calls;
/// production placement waits on cutting/post safety.
/// </remarks>
internal static class CutoutLatticeFill
{
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
internal const int DefaultShiftSteps = 8;
private const double FlattenTolerance = 0.001;
internal const double FlattenTolerance = 0.001;
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
private const double Margin = FlattenTolerance + 0.001;
private const int Precision = NestTolerances.ClipperPrecision;
// Fill materializes an entire grid before quantity trimming. Bound work even for a
// tiny insert with demand of only three, rather than allocating millions of clones.
private const int MaxLatticePositions = 1000;
/// <summary>Returns up to <paramref name="maxQuantity"/> insert poses inside the cutout,
/// or none when no copy fits.</summary>
@@ -45,9 +49,17 @@ internal static class CutoutLatticeFill
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token)
=> Fill(frame, cutoutIndex, insert, maxQuantity, spacing, shiftSteps,
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token);
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps,
IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(insert);
ArgumentNullException.ThrowIfNull(occupied);
token.ThrowIfCancellationRequested();
ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps);
if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0)
return Array.Empty<NestJobPlacement>();
@@ -69,6 +81,10 @@ internal static class CutoutLatticeFill
var bounds = cutout.BoundingBox;
var insertBounds = insertGeometry.Bounds;
var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing;
var estimated = (System.Math.Ceiling(bounds.Length / step) + 4)
* (System.Math.Ceiling(bounds.Width / step) + 4);
if (!(step > 0) || !double.IsFinite(estimated) || estimated > MaxLatticePositions)
return Array.Empty<NestJobPlacement>();
var lattice = Lattice(insert, bounds, step, spacing, token);
if (lattice.Count == 0)
@@ -83,7 +99,8 @@ internal static class CutoutLatticeFill
{
token.ThrowIfCancellationRequested();
var (dx, dy) = Shift(step, shiftSteps, i, j);
var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy));
var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy)
&& ClearsOccupied(p, dx, dy));
if (Better(count, i, j, best))
best = (count, i, j);
}
@@ -91,13 +108,33 @@ internal static class CutoutLatticeFill
return Array.Empty<NestJobPlacement>();
var (sx, sy) = Shift(step, shiftSteps, best.I, best.J);
var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy))
var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy)
&& ClearsOccupied(p, sx, sy))
.Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8),
System.Math.Round(p.Y + sy, 8), p.Rotation))
.OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation)
.ToList();
return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity)
.Select((p, index) => p with { InstanceIndex = index }).ToArray();
bool ClearsOccupied(NestJobPlacement pose, double dx, double dy)
{
token.ThrowIfCancellationRequested();
if (occupied.Count == 0)
return true;
var moved = pose with
{
X = System.Math.Round(pose.X + dx, 8),
Y = System.Math.Round(pose.Y + dy, 8)
};
foreach (var other in occupied)
{
token.ThrowIfCancellationRequested();
if (!NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, moved, spacing))
return false;
}
return true;
}
}
/// <summary>Fill over the cutout's bounds grown by one step on every side, in frame
@@ -134,7 +171,7 @@ internal static class CutoutLatticeFill
/// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline
/// never meets the cutout boundary (t outside boundary + reflected outline).
/// </summary>
private static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
internal static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
{
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
foreach (var entity in entities)
@@ -203,7 +240,7 @@ internal static class CutoutLatticeFill
}
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
private static bool Inside(PathsD region, double x, double y)
internal static bool Inside(PathsD region, double x, double y)
{
var point = new PointD(x, y);
var inside = false;
@@ -218,7 +255,7 @@ internal static class CutoutLatticeFill
return inside;
}
private static PathD Positive(PathD path)
internal static PathD Positive(PathD path)
{
if (path.Count >= 3 && !Clipper.IsPositive(path))
path.Reverse();
@@ -0,0 +1,112 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>Test-only single-insert inner-fit proposal; never changes a job or a live plate.</summary>
internal static class CutoutNfpProposal
{
// Fixed chord tolerance in the shared round offset otherwise makes extremely large
// spacing values allocate an unbounded number of arc vertices. This conservative
// test-only kernel declines scales it cannot process safely.
private const double MaxCoordinate = 1e8;
private const double MaxSpacing = 100;
/// <summary>Returns one frame-local pose inside a closed cutout, or null if no pose is certified.
/// A null result is not a proof that the insert cannot fit elsewhere in this cutout.</summary>
internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert,
double spacing, CancellationToken token = default) =>
Find(frame, cutoutIndex, insert, spacing,
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token);
/// <summary>Find a pose clearing already accepted inserts as well as the enclosing frame.</summary>
internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert,
double spacing, IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied,
CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(insert);
ArgumentNullException.ThrowIfNull(occupied);
token.ThrowIfCancellationRequested();
if (!double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing)
return null;
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
var insertGeometry = JobPartGeometry.TryRead(insert.Geometry);
if (frameGeometry == null || insertGeometry == null
|| !WithinRange(frameGeometry.Bounds) || !WithinRange(insertGeometry.Bounds))
return null;
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
var cutout = frameGeometry.Cutouts[cutoutIndex];
if (!cutout.IsClosed() || !WithinRange(cutout.BoundingBox)
|| spacing * 2 >= System.Math.Min(cutout.BoundingBox.Length, cutout.BoundingBox.Width))
return null;
var hole = CutoutLatticeFill.Positive(ClipperBridge.ToPath(
ClipperBridge.Flatten(cutout, CutoutLatticeFill.FlattenTolerance, circumscribe: false),
new Vector()));
if (hole.Count < 3)
return null;
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
foreach (var rotation in insert.Rotation.EnumerateAngles())
{
token.ThrowIfCancellationRequested();
var region = CutoutLatticeFill.InnerFit(insertGeometry, rotation, hole, spacing);
token.ThrowIfCancellationRequested();
foreach (var (x, y) in Candidates(region))
{
token.ThrowIfCancellationRequested();
if (!CutoutLatticeFill.Inside(region, x, y))
continue;
var pose = new NestJobPlacement(insert.Id, 0, x, y, rotation);
if (!NestLayoutCheck.Clears(frameGeometry, framePose, insertGeometry, pose, spacing))
continue;
var clears = true;
foreach (var other in occupied)
{
token.ThrowIfCancellationRequested();
if (NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, pose, spacing))
continue;
clears = false;
break;
}
token.ThrowIfCancellationRequested();
if (clears)
return pose;
}
}
token.ThrowIfCancellationRequested();
return null;
}
private static bool WithinRange(Box box) =>
double.IsFinite(box.Left) && double.IsFinite(box.Right)
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
&& System.Math.Abs(box.Left) <= MaxCoordinate && System.Math.Abs(box.Right) <= MaxCoordinate
&& System.Math.Abs(box.Bottom) <= MaxCoordinate && System.Math.Abs(box.Top) <= MaxCoordinate;
/// <summary>Sample component interiors, then points just inside each boundary toward
/// its component's center. This bounded search is not a complete inner-fit solver.</summary>
private static IEnumerable<(double X, double Y)> Candidates(PathsD region)
{
foreach (var path in region.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
{
var bounds = Clipper.GetBounds(new PathsD { path });
var cx = (bounds.left + bounds.right) / 2;
var cy = (bounds.top + bounds.bottom) / 2;
yield return (cx, cy);
foreach (var point in path)
yield return (point.x * 0.99 + cx * 0.01, point.y * 0.99 + cy * 0.01);
// Bounded interior samples let a residual insert land beside a partial lattice;
// a center/vertex-only sample often lands directly on an earlier copy.
for (var y = 1; y <= 9; y++)
for (var x = 1; x <= 9; x++)
yield return (bounds.left + (bounds.right - bounds.left) * x / 10,
bounds.top + (bounds.bottom - bounds.top) * y / 10);
}
}
}
@@ -0,0 +1,242 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>Internal-only frame bundles. No normal pipeline caller enables this.</summary>
internal sealed class CutoutPipelinePrepass
{
// A job with thousands of repeats still offers the residual as ordinary demand;
// do not multiply expensive Clipper/Fill preparation by every physical instance.
private const int MaxBundlesPerJob = 32;
private sealed record Bundle(string FrameId, IReadOnlyList<NestJobPlacement> LocalInserts);
private readonly IReadOnlyDictionary<string, Bundle> bundles;
private CutoutPipelinePrepass(NestJob engineJob, IReadOnlyDictionary<string, Bundle> bundles)
{
EngineJob = engineJob;
this.bundles = bundles;
}
internal NestJob EngineJob { get; }
internal static CutoutPipelinePrepass? Prepare(NestJob original, CancellationToken token)
{
token.ThrowIfCancellationRequested();
if (original.Plates.Count == 0)
return null;
// One reservation vector works for every offered stock. Current jobs cannot
// represent stock-conditional insert quantities; use the largest spacing.
var spacing = original.Plates.Max(p => p.PartSpacing);
var remaining = original.Parts.ToDictionary(p => p.Id, p => p.Quantity);
var geometry = original.Parts.ToDictionary(p => p.Id, p => JobPartGeometry.TryRead(p.Geometry));
var bundles = new Dictionary<string, Bundle>(StringComparer.Ordinal);
var byFrame = new Dictionary<string, List<NestJobPart>>(StringComparer.Ordinal);
foreach (var frame in original.Parts)
{
token.ThrowIfCancellationRequested();
if (bundles.Count >= MaxBundlesPerJob)
break;
var profile = geometry[frame.Id];
if (profile == null || profile.Cutouts.Count == 0)
continue;
var proxies = new List<NestJobPart>();
byFrame[frame.Id] = proxies;
for (var instance = 0; instance < frame.Quantity; instance++)
{
token.ThrowIfCancellationRequested();
if (bundles.Count >= MaxBundlesPerJob)
break;
IReadOnlyList<NestJobPlacement>? chosen = null;
double frameAngle = 0;
// Outer-perimeter symmetry cannot deduplicate asymmetric cutouts.
foreach (var angle in frame.Rotation.EnumerateAngles(maxSamples: 16))
{
token.ThrowIfCancellationRequested();
if (!FitsOfferedStock(frame, angle, original.Plates))
continue;
var local = new List<NestJobPlacement>();
var available = new Dictionary<string, int>(remaining);
for (var hole = 0; hole < profile.Cutouts.Count; hole++)
{
token.ThrowIfCancellationRequested();
// Flat bundles: frames cannot simultaneously be inserts in another bundle.
var candidates = original.Parts
.Where(p => p.Id != frame.Id && available[p.Id] > 0
&& p.Priority == frame.Priority
&& geometry[p.Id] is { Cutouts.Count: 0 })
.Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id],
p.Priority, LocalRotation(p.Rotation, angle)))
.ToArray();
if (candidates.Length == 0)
break;
foreach (var pose in CutoutRouter.Fill(frame, hole, candidates, spacing, token))
{
token.ThrowIfCancellationRequested();
var source = geometry[pose.PartId]!;
var policy = original.Parts.Single(p => p.Id == pose.PartId).Rotation;
if (!policy.Allows(angle + pose.Rotation)
|| local.Any(other => !NestLayoutCheck.Clears(
geometry[other.PartId]!, other, source, pose, spacing)))
continue;
local.Add(pose);
available[pose.PartId]--;
}
}
if (local.Count <= (chosen?.Count ?? 0))
continue;
chosen = local.ToArray();
frameAngle = angle;
}
if (chosen == null || chosen.Count == 0)
break;
var proxyId = $"__cutout-proxy-{frame.Id}-{instance}";
if (remaining.ContainsKey(proxyId) || bundles.ContainsKey(proxyId))
throw new InvalidOperationException("Cutout proxy ID collides with a requirement.");
bundles.Add(proxyId, new Bundle(frame.Id, chosen));
proxies.Add(new NestJobPart(proxyId, frame.Geometry, 1, frame.Priority,
RotationPolicy.Fixed(frameAngle)));
remaining[frame.Id]--;
foreach (var pose in chosen)
remaining[pose.PartId]--;
}
}
if (bundles.Count == 0)
return null;
var transformed = new List<NestJobPart>();
foreach (var part in original.Parts)
{
if (byFrame.TryGetValue(part.Id, out var proxies))
transformed.AddRange(proxies);
if (remaining[part.Id] > 0)
transformed.Add(new NestJobPart(part.Id, part.Geometry, remaining[part.Id],
part.Priority, part.Rotation));
}
return new CutoutPipelinePrepass(new NestJob(transformed, original.Plates,
original.Options), bundles);
}
private static RotationPolicy LocalRotation(RotationPolicy original, double frameAngle) =>
original.Kind switch
{
RotationPolicyKind.Automatic => RotationPolicy.Automatic,
RotationPolicyKind.Fixed => RotationPolicy.Fixed(original.Start - frameAngle,
original.Allow180Equivalent),
_ => RotationPolicy.BoundedSweep(original.Start - frameAngle,
original.End - frameAngle, original.Step, original.Allow180Equivalent),
};
private static bool FitsOfferedStock(NestJobPart frame, double angle,
IReadOnlyList<NestPlateStock> stock)
{
var part = new Part(DrawingJobMapper.CreateDrawing(frame));
part.Rotate(angle);
var bounds = NestLayoutCheck.MaterialBounds(part);
return stock.Any(s => s.Quantity != 0 && s.Fits(bounds.Length, bounds.Width,
NestTolerances.WorkAreaSlack));
}
/// <summary>Reject untrusted accounting before expansion. A failed trial is never bindable,
/// including with allowInvalid. Only actually placed proxies consume reserved inserts.</summary>
internal bool TryExpand(NestJob original, NestJobResult result, CancellationToken token,
out NestJobResult? expanded,
out string failure)
{
expanded = null;
failure = "Invalid cutout engine accounting";
var parts = EngineJob.Parts.ToDictionary(p => p.Id);
var counts = parts.ToDictionary(p => p.Key, _ => 0);
var indices = parts.ToDictionary(p => p.Key, _ => new HashSet<int>());
var stockById = EngineJob.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal);
var used = stockById.ToDictionary(s => s.Key, _ => 0, StringComparer.Ordinal);
var expectedIndex = 0;
foreach (var sheet in result.Plates)
{
token.ThrowIfCancellationRequested();
if (sheet.PlateIndex != expectedIndex++ || sheet.Placements.Count == 0
|| !used.ContainsKey(sheet.StockId))
return false;
used[sheet.StockId]++;
foreach (var pose in sheet.Placements)
{
if (pose.PartId == null || !parts.ContainsKey(pose.PartId)
|| pose.InstanceIndex < 0 || !indices[pose.PartId].Add(pose.InstanceIndex)
|| ++counts[pose.PartId] > parts[pose.PartId].Quantity)
return false;
}
}
foreach (var row in indices)
{
token.ThrowIfCancellationRequested();
if (row.Value.Any(index => index >= counts[row.Key]))
return false;
}
if (result.Fulfillment.Count != parts.Count || result.StockUsage.Count != used.Count)
return false;
var seenParts = new HashSet<string>(StringComparer.Ordinal);
foreach (var row in result.Fulfillment)
{
token.ThrowIfCancellationRequested();
if (row.PartId == null || !parts.TryGetValue(row.PartId, out var part)
|| !seenParts.Add(row.PartId)
|| row.Requested != part.Quantity || row.Placed != counts[row.PartId]
|| row.Unplaced != part.Quantity - counts[row.PartId])
return false;
}
var seenStock = new HashSet<string>(StringComparer.Ordinal);
foreach (var row in result.StockUsage)
{
token.ThrowIfCancellationRequested();
if (row.StockId == null || !stockById.TryGetValue(row.StockId, out var stock)
|| !seenStock.Add(row.StockId)
|| row.Used != used[row.StockId]
|| row.Remaining != stock.Quantity - row.Used)
return false;
}
var complete = parts.Values.All(p => counts[p.Id] == p.Quantity);
if (result.Status != (complete ? NestJobStatus.Complete : NestJobStatus.Incomplete)
|| (complete && result.StopReason != NestJobStopReason.Completed)
|| (!complete && result.StopReason == NestJobStopReason.Completed))
return false;
var builder = new NestJobResultBuilder(original);
foreach (var sheet in result.Plates)
{
token.ThrowIfCancellationRequested();
var poses = new List<(string PartId, double X, double Y, double Rotation)>();
foreach (var pose in sheet.Placements)
{
if (!bundles.TryGetValue(pose.PartId, out var bundle))
{
poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation));
continue;
}
poses.Add((bundle.FrameId, pose.X, pose.Y, pose.Rotation));
var sine = System.Math.Sin(pose.Rotation);
var cosine = System.Math.Cos(pose.Rotation);
foreach (var local in bundle.LocalInserts)
poses.Add((local.PartId,
pose.X + local.X * cosine - local.Y * sine,
pose.Y + local.X * sine + local.Y * cosine,
pose.Rotation + local.Rotation));
}
// Canonical stock reference, not an engine-supplied object with altered settings.
var stock = original.Plates.Single(s => s.Id == sheet.StockId);
try
{
builder.AddSheet(stock, poses);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return false;
}
}
token.ThrowIfCancellationRequested();
expanded = builder.Build(result.StopReason);
return true;
}
}
@@ -0,0 +1,144 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>Internal frame-local cutout proposal. Never changes demand, stock or a live plate.</summary>
/// <remarks>Area ratio selects search order, not placement permission. The small-insert path
/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search;
/// the large-insert path tries NFP first. All accepted poses are clearance-checked.
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
internal static class CutoutRouter
{
// Calibrated as search-order hints, not hard geometry cutoffs. Round-hole neutral tests
// show a Fill win at 0.204, so the original proposed >0.20 NFP-only rule loses copies.
internal const double SmallRatio = 0.10;
internal const double LargeRatio = 0.35;
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing, CancellationToken token = default) =>
Fill(frame, cutoutIndex, inserts, spacing, null, token);
/// <summary>Optional internal diagnostic receives only poses from the chosen proposal,
/// after indexing; true marks a retained lattice seed, false a residual NFP pose.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing,
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(inserts);
token.ThrowIfCancellationRequested();
if (!double.IsFinite(spacing) || spacing < 0 || spacing > 100)
return Array.Empty<NestJobPlacement>();
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
if (frameGeometry == null)
return Array.Empty<NestJobPlacement>();
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
var hole = frameGeometry.Cutouts[cutoutIndex];
if (!hole.IsClosed() || !Supported(frameGeometry.Bounds) || !Supported(hole.BoundingBox))
return Array.Empty<NestJobPlacement>();
if (inserts.Any(p => p == null || !SupportedGeometry(p)))
return Array.Empty<NestJobPlacement>();
if (inserts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != inserts.Count
|| inserts.Any(p => p.Id == frame.Id))
throw new ArgumentException("Requirement IDs must be distinct from the frame and each other.",
nameof(inserts));
var accepted = new List<NestJobPlacement>();
var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
var holeArea = System.Math.Abs(hole.Area());
// Preserve caller requirement order: priorities are a whole-job concern, not inferred here.
foreach (var part in inserts)
{
token.ThrowIfCancellationRequested();
var geometry = JobPartGeometry.Read(part.Geometry);
var ratio = geometry.MaterialArea / holeArea;
// Unsupported or degenerate material cannot establish a useful routing ratio;
// NFP retains its own fail-closed checks and the bounded lattice is not tried.
var tryLattice = part.Quantity >= 3 && double.IsFinite(ratio) && ratio > 0;
var nfpFirst = !tryLattice || ratio >= LargeRatio;
var compareBoth = tryLattice && ratio >= SmallRatio && ratio < LargeRatio;
var nfp = nfpFirst ? Nfp(Array.Empty<NestJobPlacement>()) : null;
var lattice = tryLattice && (!nfpFirst || nfp!.Count < part.Quantity)
? CutoutLatticeFill.Fill(frame, cutoutIndex, part, part.Quantity, spacing,
CutoutLatticeFill.DefaultShiftSteps, occupied, token)
: Array.Empty<NestJobPlacement>();
IReadOnlyList<NestJobPlacement> chosen;
var retainedLattice = 0;
if (compareBoth || (nfpFirst && lattice.Count > 0))
{
nfp ??= Nfp(Array.Empty<NestJobPlacement>());
var fromLattice = Complete(lattice);
chosen = fromLattice.Count > nfp.Count ? fromLattice : nfp;
if (ReferenceEquals(chosen, fromLattice))
retainedLattice = lattice.Count;
}
else if (nfpFirst)
chosen = nfp!;
else
{
// A single Fill copy ties one NFP pose; prefer NFP's stable candidate
// order, but retain the lattice copy if bounded NFP found nothing.
var seed = lattice.Count > 1 ? lattice : Array.Empty<NestJobPlacement>();
chosen = Complete(seed);
if (chosen.Count == 0 && lattice.Count == 1)
chosen = lattice;
retainedLattice = ReferenceEquals(chosen, lattice) ? lattice.Count : seed.Count;
}
var localIndex = 0;
foreach (var pose in chosen)
{
token.ThrowIfCancellationRequested();
var indexed = pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) };
accepted.Add(indexed);
occupied.Add((geometry, indexed));
observeAccepted?.Invoke(indexed, localIndex++ < retainedLattice);
}
IReadOnlyList<NestJobPlacement> Complete(IReadOnlyList<NestJobPlacement> seed)
{
if (seed.Count >= part.Quantity)
return seed;
var result = new List<NestJobPlacement>(seed);
result.AddRange(Nfp(seed));
return result;
}
IReadOnlyList<NestJobPlacement> Nfp(IReadOnlyList<NestJobPlacement> seed)
{
var result = new List<NestJobPlacement>();
var local = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>(occupied);
local.AddRange(seed.Select(p => (geometry, p)));
while (seed.Count + result.Count < part.Quantity)
{
token.ThrowIfCancellationRequested();
var pose = CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, local, token);
if (pose == null)
break;
result.Add(pose);
local.Add((geometry, pose));
}
return result;
}
}
token.ThrowIfCancellationRequested();
return accepted.ToArray();
}
private static bool SupportedGeometry(NestJobPart part)
{
var geometry = JobPartGeometry.TryRead(part.Geometry);
return geometry != null && Supported(geometry.Bounds);
}
private static bool Supported(OpenNest.Geometry.Box box) =>
double.IsFinite(box.Left) && double.IsFinite(box.Right)
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
&& System.Math.Abs(box.Left) <= 1e8 && System.Math.Abs(box.Right) <= 1e8
&& System.Math.Abs(box.Bottom) <= 1e8 && System.Math.Abs(box.Top) <= 1e8;
}
+100 -4
View File
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Cutouts;
namespace OpenNest.Engine.Jobs;
@@ -36,7 +37,8 @@ public sealed class NestPipelineResult
IReadOnlyList<string> violations,
bool canKeep,
TimeSpan solveTime,
TimeSpan validationTime
TimeSpan validationTime,
IReadOnlyDictionary<string, Drawing> drawingsByPartId
)
{
EngineName = engineName;
@@ -47,6 +49,7 @@ public sealed class NestPipelineResult
CanKeep = canKeep;
SolveTime = solveTime;
ValidationTime = validationTime;
DrawingsByPartId = drawingsByPartId;
}
public string EngineName { get; }
@@ -62,6 +65,7 @@ public sealed class NestPipelineResult
public NestJobStopReason StopReason => Raw.StopReason;
public TimeSpan SolveTime { get; }
public TimeSpan ValidationTime { get; }
internal IReadOnlyDictionary<string, Drawing> DrawingsByPartId { get; }
}
/// <summary>
@@ -72,6 +76,15 @@ public sealed class NestPipelineResult
/// </summary>
public static class NestPipeline
{
private sealed class PreviewProgress(IProgress<NestJobProgress> inner) : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value)
{
if (value != null && value.Stage == NestJobStage.EvaluatingCandidate)
inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 });
}
}
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
/// Engine cancellation propagates unchanged.</summary>
@@ -93,6 +106,32 @@ public static class NestPipeline
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false);
/// <summary>Internal test-only integration trial. Normal Run never prepares cutouts;
/// this entry point must not be called by production front ends before cutting/post gates.</summary>
internal static NestPipelineResult RunCutoutPreview(
INestingEngine engine,
string engineName,
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: true);
internal static NestPipelineResult RunCutoutPreview(
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
) => RunCutoutPreview(NestingEngineRegistry.Create(request.EngineName),
request.EngineName, request, progress, token);
private static NestPipelineResult RunCore(
INestingEngine engine,
string engineName,
NestPipelineRequest request,
IProgress<NestJobProgress> progress,
CancellationToken token,
bool cutoutPreview
)
{
ArgumentNullException.ThrowIfNull(engine);
@@ -115,10 +154,15 @@ public static class NestPipeline
var job = new NestJob(parts, request.Stock, request.Options);
NestJobValidator.Validate(job);
var prepass = cutoutPreview ? CutoutPipelinePrepass.Prepare(job, token) : null;
var engineJob = prepass?.EngineJob ?? job;
if (prepass != null)
NestJobValidator.Validate(engineJob);
var clock = Stopwatch.StartNew();
var raw =
engine.Solve(job, progress, token)
engine.Solve(engineJob, prepass == null || progress == null
? progress : new PreviewProgress(progress), token)
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
var solveTime = clock.Elapsed;
token.ThrowIfCancellationRequested();
@@ -129,7 +173,33 @@ public static class NestPipeline
kv => kv.Value.Name ?? kv.Key,
StringComparer.Ordinal
);
var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep);
var checkedNames = prepass == null ? names : engineJob.Parts.ToDictionary(p => p.Id,
p => names.GetValueOrDefault(p.Id, p.Id), StringComparer.Ordinal);
var violations = NestLayoutCheck.Violations(engineJob, raw, checkedNames, out var canKeep).ToList();
if (prepass != null)
{
// A compound may be physically invalid even when its envelope is legal.
// Refuse every unverified trial, including with explicit invalid-result consent.
var failure = "Cutout engine output failed reconciliation";
if (violations.Count == 0 && prepass.TryExpand(job, raw, token, out var expanded, out failure))
{
raw = expanded;
violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep));
if (raw.Status != NestJobStatus.Complete)
violations.Add("Cutout engine did not complete the transformed job; unplaced frames' reserved inserts remain unplaced in the original job");
canKeep = physicalKeep && violations.Count == 0;
}
else
{
if (violations.Count == 0)
violations.Add(failure);
canKeep = false;
}
}
var freshness = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId);
violations.AddRange(freshness);
if (freshness.Count > 0)
canKeep = false;
var validationTime = clock.Elapsed;
if (canKeep && violations.Count == 0)
_ = NestJobCost.Evaluate(job, raw);
@@ -143,6 +213,31 @@ public static class NestPipeline
.ToList()
: new List<ProposedPlate>();
// Binding clones the caller's current Program; a concurrent edit during binding
// must not return parts whose bytes differ from the already validated snapshot.
if (canKeep)
{
var changes = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId).ToList();
changes.AddRange(NestPipelineDrawingFreshness.BoundChanges(job, raw, plates, drawingsByPartId));
if (changes.Count > 0)
{
violations.AddRange(changes);
plates.Clear();
canKeep = false;
}
}
token.ThrowIfCancellationRequested();
if (prepass != null && canKeep)
{
var count = 0;
for (var i = 0; i < raw.Plates.Count; i++)
{
count += raw.Plates[i].Placements.Count;
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
raw.Plates[i].StockId, i, i + 1, count));
}
}
token.ThrowIfCancellationRequested();
return new NestPipelineResult(
engineName,
@@ -152,7 +247,8 @@ public static class NestPipeline
violations,
canKeep,
solveTime,
validationTime
validationTime,
drawingsByPartId
);
}
}
@@ -20,6 +20,11 @@ public static class NestPipelineCommit
token.ThrowIfCancellationRequested();
if (!result.CanKeep || (!result.IsValid && !allowInvalid))
throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations.");
if (NestPipelineDrawingFreshness.Changes(result.Job, result.DrawingsByPartId).Count > 0
|| NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
result.Plates, result.DrawingsByPartId).Count > 0)
throw new InvalidOperationException("A drawing or proposed part changed after the nesting proposal was validated; run Auto Nest again.");
token.ThrowIfCancellationRequested();
var applied = new List<Plate>();
// Commit is synchronous on the caller's owning thread. Cancellation is checked
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
/// <summary>Refuse a proposal when a caller drawing no longer matches its checked snapshot.</summary>
internal static class NestPipelineDrawingFreshness
{
internal static IReadOnlyList<string> Changes(NestJob job,
IReadOnlyDictionary<string, Drawing> drawings)
{
var changed = new List<string>();
foreach (var requirement in job.Parts)
{
if (!drawings.TryGetValue(requirement.Id, out var drawing)
|| drawing?.Program == null)
{
changed.Add($"Drawing for '{requirement.Id}' changed after the nesting snapshot");
continue;
}
try
{
var current = PartGeometrySnapshot.FromProgram(drawing.Program);
if (Same(requirement.Geometry, current))
continue;
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
or InvalidOperationException)
{
// A now-unsupported program is not the geometry that was checked.
}
changed.Add($"Drawing '{drawing.Name ?? requirement.Id}' changed after the nesting snapshot");
}
return changed;
}
/// <summary>Check the actual cloned programs, not only the drawing before/after
/// binding. A transient edit during Clone may leave the drawing unchanged.</summary>
internal static IReadOnlyList<string> BoundChanges(NestJob job, NestJobResult raw,
IReadOnlyList<ProposedPlate> plates, IReadOnlyDictionary<string, Drawing> drawings)
{
var changed = new List<string>();
var requirements = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
if (plates.Count != raw.Plates.Count)
return new[] { "Bound plate count changed after validation" };
for (var sheetIndex = 0; sheetIndex < plates.Count; sheetIndex++)
{
var poses = raw.Plates[sheetIndex].Placements;
var bound = plates[sheetIndex].Parts;
if (bound.Count != poses.Count)
return new[] { "Bound part count changed after validation" };
for (var index = 0; index < poses.Count; index++)
{
var pose = poses[index];
var actual = bound[index];
if (pose.PartId == null || !requirements.TryGetValue(pose.PartId, out var requirement)
|| !drawings.TryGetValue(pose.PartId, out var drawing)
|| !ReferenceEquals(actual.BaseDrawing, drawing))
return new[] { "A bound part no longer matches its requirement" };
try
{
var expected = new Part(DrawingJobMapper.CreateDrawing(requirement));
expected.Rotate(pose.Rotation);
expected.Location = new Vector(pose.X, pose.Y);
if (Same(PartGeometrySnapshot.FromProgram(expected.Program),
PartGeometrySnapshot.FromProgram(actual.Program))
&& Bits(expected.Location.X) == Bits(actual.Location.X)
&& Bits(expected.Location.Y) == Bits(actual.Location.Y)
&& Bits(expected.Rotation) == Bits(actual.Rotation))
continue;
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
or InvalidOperationException)
{
// A changed/unsupported program cannot represent the checked pose.
}
changed.Add($"Bound part for '{drawing.Name ?? requirement.Id}' changed after validation");
}
}
return changed;
}
private static bool Same(PartGeometrySnapshot a, PartGeometrySnapshot b) =>
a.Mode == b.Mode && a.Motions.Count == b.Motions.Count
&& a.Motions.Zip(b.Motions).All(pair =>
pair.First.Type == pair.Second.Type
&& Bits(pair.First.X) == Bits(pair.Second.X)
&& Bits(pair.First.Y) == Bits(pair.Second.Y)
&& Bits(pair.First.CenterX) == Bits(pair.Second.CenterX)
&& Bits(pair.First.CenterY) == Bits(pair.Second.CenterY)
&& pair.First.Rotation == pair.Second.Rotation
&& pair.First.Layer == pair.Second.Layer
&& pair.First.Suppressed == pair.Second.Suppressed);
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
}
@@ -90,6 +90,10 @@ public class CuttingPlanBatchTests
var text = string.Join("\n", proposal.Describe("in"));
Assert.Contains("Plate 1: blocked: parts overlap or could not be checked for overlap.", text);
Assert.Contains("- Part 1 (first) overlaps part 2 (second).", text);
var row = Assert.Single(proposal.DiagnosticRows());
Assert.Equal((0, 1, 1, 2),
(row.PlateIndex, row.PlateNumber, row.PartNumber, row.OtherPartNumber));
Assert.Contains("overlaps part 2", row.Message);
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
@@ -144,6 +148,63 @@ public class CuttingPlanBatchTests
Assert.Null(proposal.BuildPreview(0));
}
[Fact]
public void PlanReorder_ShortPassReady_RetainsReplayedApplicableProposal()
{
var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1));
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
confirmedParameters: ExplicitContourTests.Parameters()));
var full = CuttingPlanService.Plan(snapshot);
var result = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None);
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
Assert.True(result.Expansions < full.Expansions,
$"Short pass {result.Expansions}, full {full.Expansions}");
Assert.InRange(result.Expansions, 1, CuttingPlanBatch.FirstPassBudget - 1);
Assert.Equal(2, result.ProposedOrder.Count);
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
}
[Fact]
public void PlanReorder_ShortPassExhausted_UsesFullCapAndOriginalBudget()
{
var plate = Plate(new Nest(), Grid(4));
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
confirmedParameters: ExplicitContourTests.Parameters()));
var full = CuttingPlanService.Plan(snapshot);
var recovered = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None, firstPassBudget: 1);
Assert.Equal(CuttingPlanStatus.Ready, full.Status);
Assert.Equal(CuttingPlanStatus.Ready, recovered.Status);
Assert.True(recovered.IndependentlyReplayed);
Assert.Equal(full.Expansions, recovered.Expansions);
Assert.Equal(full.RapidDistance, recovered.RapidDistance);
Assert.Equal(full.ProposedOrder.Select(p => p.SourceOrdinal), recovered.ProposedOrder.Select(p => p.SourceOrdinal));
for (var i = 0; i < full.ProposedOrder.Count; i++)
Assert.True(ProgramContent.Equal(full.ProposedOrder[i].CopyProgram(), recovered.ProposedOrder[i].CopyProgram()));
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
}
[Fact]
public void PlanReorder_CancelledDuringShortPass_DoesNotRetryOrMutate()
{
var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1));
using var cancellation = new CancellationTokenSource();
var observations = 0;
var request = new CuttingPlanRequest(plate.Parts, confirmedParameters: ExplicitContourTests.Parameters())
{ ExpansionObserver = _ => { observations++; cancellation.Cancel(); } };
var snapshot = CuttingPlanService.Capture(request);
var result = CuttingPlanBatch.PlanReorder(snapshot, cancellation.Token);
Assert.Equal(CuttingPlanStatus.Cancelled, result.Status);
Assert.Equal(1, observations);
Assert.Empty(result.ProposedOrder);
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
}
[Fact]
public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo()
{
@@ -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 _));
}
[Fact]
public void ReusedNativeCurves_KeepContactAndContainmentVerdictsAcrossCallsAndThreads()
{
var lineStart = new Vector(-2, 0);
var lineEnd = new Vector(2, 0);
var arcCenter = new Vector(0, 0.003);
var arcStart = new Vector(0.003, 0.003);
var circleCenter = new Vector(0, 0);
var circleStart = new Vector(0.003, 0);
var line = PostVerificationGeometry.Curve.Create(lineStart, lineEnd, null, false);
var arc = PostVerificationGeometry.Curve.Create(arcStart, new Vector(-0.003, 0.003), arcCenter, false);
var circle = PostVerificationGeometry.Curve.Create(circleStart, circleStart, circleCenter, false);
var pairs = new[] { (line, arc), (arc, line), (line, circle), (circle, line), (arc, circle), (circle, arc) };
var probes = new[] { Vector.Zero, new Vector(0.003, 0), new Vector(0, 0.006) };
string Observe(PostVerificationGeometry.Curve a, PostVerificationGeometry.Curve b)
{
var inside = string.Join(",", probes.Select(p => $"{a.Contains(p)}:{b.Contains(p)}"));
try
{
var contacts = a.Contacts(b, out var overlap);
return $"{inside}|{overlap}|" + string.Join(";", contacts.Select(p =>
$"{BitConverter.DoubleToInt64Bits(p.X)}:{BitConverter.DoubleToInt64Bits(p.Y)}"));
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
return $"{inside}|{ex.GetType().Name}:{ex.Message}";
}
}
var expected = pairs.Select(p => Observe(
PostVerificationGeometry.Curve.Create(p.Item1.Start, p.Item1.End,
ReferenceEquals(p.Item1, arc) ? arcCenter : ReferenceEquals(p.Item1, circle) ? circleCenter : null, false),
PostVerificationGeometry.Curve.Create(p.Item2.Start, p.Item2.End,
ReferenceEquals(p.Item2, arc) ? arcCenter : ReferenceEquals(p.Item2, circle) ? circleCenter : null, false))).ToArray();
for (var repeat = 0; repeat < 3; repeat++)
Assert.Equal(expected, pairs.Select(p => Observe(p.Item1, p.Item2)).ToArray());
Parallel.For(0, 8, _ => Assert.Equal(expected, pairs.Select(p => Observe(p.Item1, p.Item2)).ToArray()));
// A separate pair has not initialized either native wrapper before the readers race.
var coldLine = PostVerificationGeometry.Curve.Create(lineStart, lineEnd, null, false);
var coldCircle = PostVerificationGeometry.Curve.Create(circleStart, circleStart, circleCenter, false);
Parallel.For(0, 8, _ => Assert.Equal(expected[2], Observe(coldLine, coldCircle)));
}
private static Program RoundedRectangle(double width, double height, double radius, double angle, Vector at)
{
var cos = System.Math.Cos(angle);
@@ -34,6 +34,48 @@ public class ReorderSearchTests
Assert.Equal(parts, plate.Parts); // Planning alone never reorders the live plate.
}
[SkippableFact]
public void DenseGrid144_PerformanceProbe()
{
Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_CUTTING_PERF") == "1",
"Set OPENNEST_RUN_CUTTING_PERF=1 for the 144-part cutting-planner probe.");
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Size = new Size(100, 100);
foreach (var part in Grid(144, false))
plate.Parts.Add(part);
var source = plate.Parts.ToArray();
var timer = System.Diagnostics.Stopwatch.StartNew();
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
confirmedParameters: ExplicitContourTests.Parameters(), expansionBudget: 57600));
timer.Stop();
// Expanded owned execution includes called hole programs; Program.ToString() does not,
// and rounds coordinates. Hash exact scalar bits in execution order instead.
static string Bits(double value) => BitConverter.DoubleToInt64Bits(value).ToString("X16");
static string Point(Vector point) => $"{Bits(point.X)},{Bits(point.Y)}";
var payload = string.Join("\n", result.ProposedOrder.SelectMany(p =>
new[] { $"part:{p.SourceOrdinal}:{Point(p.Location)}:{Bits(p.Rotation)}" }.Concat(
p.Execution.Motions.Select(m =>
{
var geometry = m.Curve?.ToEntity() switch
{
Arc arc => $"arc:{Point(arc.Center)}:{Bits(arc.Radius)}:{Bits(arc.StartAngle)}:{Bits(arc.EndAngle)}:{arc.Rotation}",
Circle circle => $"circle:{Point(circle.Center)}:{Bits(circle.Radius)}:{circle.Rotation}",
Line => "line",
null => "none",
_ => throw new NotSupportedException("Unexpected execution curve."),
};
return $"{m.Layer}:{m.Rapid}:{(m.Start is { } start ? Point(start) : "null")}:{Point(m.End)}:{geometry}";
}))));
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(payload)));
Console.WriteLine($"Cutting144 elapsedMs={timer.Elapsed.TotalMilliseconds:F1} expansions={result.Expansions} status={result.Status} executionSha256={hash}");
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.True(result.IndependentlyReplayed);
Assert.Equal(source.OrderBy(Key), result.ProposedOrder.Select(p => p.SourcePart).OrderBy(Key));
Assert.Equal(source, plate.Parts);
}
[Fact]
public void FreeOrder_BlockedApproach_LearnsToCutThatPartFirst()
{
+25
View File
@@ -59,6 +59,31 @@ public class NestStorageSettingsTests : IDisposable
Assert.False(settings.IsDatabaseMode);
}
[Fact]
public void TryNormalizeServerUrl_AcceptsHttpsWithoutChangingMode()
{
var settings = new NestStorageSettings { Mode = NestStorageMode.File };
Assert.True(NestStorageSettings.TryNormalizeServerUrl(" https://shop.example:8090/ ", out var url));
settings.ServerUrl = url;
settings.Save(FilePath);
var loaded = NestStorageSettings.Load(FilePath);
Assert.Equal("https://shop.example:8090", loaded.ServerUrl);
Assert.Equal(NestStorageMode.File, loaded.Mode);
Assert.False(loaded.IsDatabaseMode);
}
[Theory]
[InlineData("")]
[InlineData("ftp://shop.example")]
[InlineData("not-a-url")]
public void TryNormalizeServerUrl_RejectsInvalidAddress(string input)
{
Assert.False(NestStorageSettings.TryNormalizeServerUrl(input, out var url));
Assert.Equal("", url);
}
public void Dispose()
{
if (Directory.Exists(_directory))
@@ -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);
}
}
@@ -59,10 +59,12 @@ public class CuttingPlanFormTests
Assert.False(Control<Button>(form, "applyButton").Enabled);
var summary = Control<TextBox>(form, "summaryBox").Text;
Assert.Contains("Plate 1: blocked", summary);
Assert.Contains("Part 1 (locked)", summary);
var warnings = Control<DataGridView>(form, "warningGrid");
Assert.Contains(warnings.Rows.Cast<DataGridViewRow>(), row =>
row.Cells[3].Value?.ToString()?.Contains("Part 1 (locked)") == true);
// A refused plate is never previewed; the editor already shows its part numbers.
Assert.False(Field<PlateView>(form, "preview").Visible);
Assert.StartsWith("No preview", Control<Label>(form, "previewLabel").Text);
Assert.Contains("no current, usable preview", Control<Label>(form, "previewLabel").Text);
Assert.Null(view.Plate.CuttingParameters);
});
@@ -146,6 +148,7 @@ public class CuttingPlanFormTests
Assert.Equal(CuttingCommitStatus.Stale, form.CommitResult!.Status);
Assert.Contains("changed after planning", Control<TextBox>(form, "summaryBox").Text);
Assert.False(Control<Button>(form, "applyButton").Enabled);
Assert.Empty(Control<DataGridView>(form, "warningGrid").Rows.Cast<DataGridViewRow>());
Assert.Equal(programs, view.Plate.Parts.Select(part => part.Program));
Assert.Null(view.Plate.CuttingParameters);
@@ -247,13 +250,23 @@ public class CuttingPlanFormTests
Assert.False(Control<Button>(form, "applyButton").Enabled);
Assert.True(Field<PlateView>(form, "preview").Visible);
Assert.Contains("UNVERIFIED", Control<Label>(form, "previewLabel").Text);
var grid = Control<DataGridView>(form, "warningGrid");
Assert.NotEmpty(grid.Rows.Cast<DataGridViewRow>());
Assert.Contains(grid.Rows.Cast<DataGridViewRow>(), row =>
row.Cells[3].Value?.ToString()?.Contains("Material boundaries") == true);
var partRow = grid.Rows.Cast<DataGridViewRow>().First(row => row.Cells[1].Value is int);
partRow.Selected = true;
Assert.Contains("affected part extent", Control<Label>(form, "previewLabel").Text);
Assert.True(Field<PlateView>(form, "preview").Visible);
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.Null(form.CommitResult);
Assert.False(part.HasManualLeadIns);
accept.Checked = true;
Assert.True(Control<Button>(form, "applyButton").Enabled);
Invoke(form, "PlanButton_Click", null, EventArgs.Empty);
Assert.Empty(grid.Rows.Cast<DataGridViewRow>()); // No stale warning targets while replanning.
WaitForPlan(form);
Assert.NotEmpty(grid.Rows.Cast<DataGridViewRow>());
Assert.False(accept.Checked);
Assert.False(Control<Button>(form, "applyButton").Enabled);
accept.Checked = true;
+48 -116
View File
@@ -1,169 +1,101 @@
using System.Collections.Generic;
using System;
using System.ComponentModel;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.Converters;
using OpenNest.Forms;
using OpenNest.Geometry;
using OpenNest.Sequencing;
namespace OpenNest.Actions
{
[DisplayName("Set Sequence")]
public class ActionSetSequence : Action
{
private readonly SequenceForm SequenceForm;
private readonly List<Pair> ShapePartPairs;
private readonly Pen pen;
private readonly Forms.SequenceForm sequenceForm;
private readonly Pen pen = new Pen(Color.Blue, 2.0f);
private LayoutPart hoveredPart;
private bool disconnecting;
private Shape ClosestShape;
private Part ClosestPart;
private int sequenceNumber = 1;
public int SequenceNumber
{
get { return sequenceNumber; }
set
{
if (
value <= SequenceForm.numericUpDown1.Maximum
&& value >= SequenceForm.numericUpDown1.Minimum
)
{
sequenceNumber = value;
SequenceForm.numericUpDown1.Value = sequenceNumber;
}
}
}
// Escape ends this tool; it must not resurrect its closed window.
public override bool ResumeOnEscape => false;
public ActionSetSequence(PlateView plateView)
: base(plateView)
{
SequenceForm = new Forms.SequenceForm();
SequenceForm.numericUpDown1.DataBindings.Add(
"Value",
this,
"SequenceNumber",
false,
DataSourceUpdateMode.OnPropertyChanged
);
SequenceForm.numericUpDown1.Maximum = plateView.Plate.Parts.Count;
SequenceForm.Owner = Application.OpenForms[0];
SequenceForm.Show();
plateView.Invalidate();
pen = new Pen(Color.Blue, 2.0f);
ShapePartPairs = new List<Pair>();
foreach (var part in plateView.Plate.Parts)
sequenceForm = new Forms.SequenceForm();
sequenceForm.numericUpDown1.Maximum = System.Math.Max(1, plateView.Plate.Parts.Count);
sequenceForm.DoneClicked += (_, _) => plateView.EndAction();
sequenceForm.FormClosed += (_, _) =>
{
var entities = ConvertProgram
.ToGeometry(part.Program)
.Where(e => e.Layer == SpecialLayers.Cut)
.ToList();
entities.ForEach(entity => entity.Offset(part.Location));
var shapes = ShapeBuilder.GetShapes(entities);
var shape = new Shape();
shape.Entities.AddRange(shapes);
ShapePartPairs.Add(new Pair() { Part = part, Shape = shape });
}
if (!disconnecting)
plateView.EndAction();
};
sequenceForm.Show(plateView.FindForm());
plateView.MouseMove += plateView_MouseMove;
plateView.MouseLeave += plateView_MouseLeave;
plateView.MouseClick += plateView_MouseClick;
plateView.Paint += plateView_Paint;
SequenceNumber = 1;
}
private void plateView_MouseClick(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Right)
CancelAction();
{
plateView.EndAction();
return;
}
if (e.Button != MouseButtons.Left)
return;
if (SequenceNumber > plateView.Plate.Parts.Count)
SequenceNumber = plateView.Plate.Parts.Count;
if (ClosestPart == null)
// Hit-test at the click, not at the previous mouse-move location.
var hit = plateView.GetPartAtControlPoint(e.Location);
var part = hit?.BasePart;
if (part == null || !ManualPartSequencing.Move(plateView.Plate, part, (int)sequenceForm.numericUpDown1.Value))
return;
plateView.Plate.Parts.Remove(ClosestPart);
plateView.Plate.Parts.Insert(SequenceNumber - 1, ClosestPart);
SequenceNumber++;
if (sequenceForm.numericUpDown1.Value < sequenceForm.numericUpDown1.Maximum)
sequenceForm.numericUpDown1.Value++;
plateView.Invalidate();
}
private void plateView_MouseMove(object sender, MouseEventArgs e)
{
var pair = GetClosestShape();
ClosestShape = pair.HasValue ? pair.Value.Shape : null;
ClosestPart = pair.HasValue ? pair.Value.Part : null;
var hit = plateView.GetPartAtControlPoint(e.Location);
if (ReferenceEquals(hit, hoveredPart))
return;
hoveredPart = hit;
plateView.Invalidate();
}
private void plateView_MouseLeave(object sender, EventArgs e)
{
hoveredPart = null;
plateView.Invalidate();
}
private void plateView_Paint(object sender, PaintEventArgs e)
{
if (ClosestShape == null)
return;
var path = ClosestShape.GetGraphicsPath();
path.Transform(plateView.Matrix);
e.Graphics.DrawPath(pen, path);
path.Dispose();
}
private Pair? GetClosestShape()
{
Pair? closestShape = null;
double distance = double.MaxValue;
for (int i = 0; i < ShapePartPairs.Count; i++)
{
var shape = ShapePartPairs[i];
var closestPt = shape.Shape.ClosestPointTo(plateView.CurrentPoint);
var distance2 = closestPt.DistanceTo(plateView.CurrentPoint);
if (distance2 < distance)
{
closestShape = shape;
distance = distance2;
}
}
return closestShape;
var path = hoveredPart?.SelectionPath;
if (path != null)
e.Graphics.DrawPath(pen, path);
}
public override void DisconnectEvents()
{
disconnecting = true;
plateView.Paint -= plateView_Paint;
plateView.MouseMove -= plateView_MouseMove;
plateView.MouseLeave -= plateView_MouseLeave;
plateView.MouseClick -= plateView_MouseClick;
hoveredPart = null;
sequenceForm.Close();
sequenceForm.Dispose();
pen.Dispose();
plateView.Invalidate();
SequenceForm.Close();
}
public override void CancelAction()
{
SequenceNumber = 1;
SequenceForm.numericUpDown1.Value = SequenceNumber + 1;
}
public override void CancelAction() { }
public override bool IsBusy()
{
return false;
}
private struct Pair
{
public Part Part;
public Shape Shape;
}
public override bool IsBusy() => false;
}
}
+147 -11
View File
@@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
@@ -25,6 +25,8 @@ public partial class CuttingPlanForm : Form
private readonly Func<bool> isOperationBusy;
private readonly string unit;
private readonly PlateView preview;
private readonly DataGridView warningGrid;
private bool populatingWarnings;
private readonly SynchronizationContext uiContext;
private readonly string previewText;
private CuttingParameters parameters;
@@ -57,11 +59,54 @@ public partial class CuttingPlanForm : Form
this.isOperationBusy = isOperationBusy;
unit = UnitsHelper.GetShortString(nest.Units);
InitializeComponent();
// Best-effort acceptance must be able to display every warning, even on large jobs.
StartPosition = FormStartPosition.Manual;
warningGrid = new DataGridView
{
Name = "warningGrid",
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
MultiSelect = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
AutoGenerateColumns = false,
RowHeadersVisible = false,
BackgroundColor = SystemColors.Window,
};
warningGrid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "Plate",
Width = 58,
SortMode = DataGridViewColumnSortMode.NotSortable
});
warningGrid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "Part",
Width = 58,
SortMode = DataGridViewColumnSortMode.NotSortable
});
warningGrid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "With",
Width = 58,
SortMode = DataGridViewColumnSortMode.NotSortable
});
warningGrid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = "Warning / finding",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
SortMode = DataGridViewColumnSortMode.NotSortable
});
warningGrid.SelectionChanged += WarningGrid_SelectionChanged;
var findingsLayout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
findingsLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 30));
findingsLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 70));
summaryBox.Dock = DockStyle.Fill;
findingsLayout.Controls.Add(summaryBox, 0, 0);
findingsLayout.Controls.Add(warningGrid, 0, 1);
split.Panel2.Controls.Add(findingsLayout);
// Keep the batch outcome visible; every individual diagnostic lives in the grid.
summaryBox.MaxLength = int.MaxValue;
// Captured once: when Application.DoEvents ends the outermost message loop, WinForms
// uninstalls its ambient context, so progress and results must not depend on whichever
// context is current when planning starts.
uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
preview = new PlateView
@@ -118,6 +163,12 @@ public partial class CuttingPlanForm : Form
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
// Use the database browser's owner-monitor sizing, without maximizing a modal window.
var area = Screen.FromControl(Owner ?? this).WorkingArea;
var width = System.Math.Min(area.Width, System.Math.Max(MinimumSize.Width, area.Width * 9 / 10));
var height = System.Math.Min(area.Height, System.Math.Max(MinimumSize.Height, area.Height * 9 / 10));
Bounds = new Rectangle(area.Left + (area.Width - width) / 2,
area.Top + (area.Height - height) / 2, width, height);
// Min sizes and the splitter only fit once the container has its real size.
split.Panel1MinSize = 200;
split.Panel2MinSize = 220;
@@ -242,7 +293,26 @@ public partial class CuttingPlanForm : Form
private void ShowProposal(CuttingPlanProposal result)
{
proposal = result;
summaryBox.Text = string.Join(Environment.NewLine, result.Describe(unit));
summaryBox.Text = string.Join(Environment.NewLine, result.Describe(unit)
.Where(line => !line.TrimStart().StartsWith("- ", StringComparison.Ordinal)
&& !line.TrimStart().StartsWith("... and ", StringComparison.Ordinal)));
var warningRows = result.DiagnosticRows();
populatingWarnings = true;
try
{
warningGrid.Rows.Clear();
foreach (var row in warningRows)
{
var display = warningGrid.Rows.Add(row.PlateNumber, row.PartNumber, row.OtherPartNumber,
row.Message);
warningGrid.Rows[display].Tag = row;
}
warningGrid.ClearSelection();
}
finally
{
populatingWarnings = false;
}
acceptWarningsCheckBox.Checked = false;
acceptWarningsCheckBox.Visible = result.RequiresWarningAcceptance;
statusLabel.Text = result.CanApply ? "Review the plan, then apply it."
@@ -250,23 +320,89 @@ public partial class CuttingPlanForm : Form
: "Nothing can be applied.";
applyButton.Enabled = result.CanApply;
var index = Array.IndexOf(plates, activePlate);
var plate = index < 0 ? null : result.BuildPreview(index);
ShowPlate(index);
}
private void ShowPlate(int index)
{
preview.Visible = false;
if (proposal == null || index < 0 || index >= proposal.Plates.Count)
return;
var plate = proposal.BuildPreview(index);
if (plate == null)
{
if (index >= 0)
previewLabel.Text = "No preview: this plate's plan is not ready. Part numbers in the summary "
+ "match the editor.";
previewLabel.Text = $"Plate {plateNumbers[index]}: no current, usable preview. "
+ "Part numbers refer to the editor; no location can be certified here.";
return;
}
previewLabel.Text = result.Plates[index].IsReady ? previewText : "UNVERIFIED — " + previewText;
previewLabel.Text = proposal.Plates[index].IsReady
? $"Plate {plateNumbers[index]} — preview, numbered in proposed cutting order:"
: $"UNVERIFIED — Plate {plateNumbers[index]} — preview, numbered in proposed cutting order:";
preview.Plate = plate;
preview.Visible = true;
preview.ZoomToFit();
}
private void WarningGrid_SelectionChanged(object sender, EventArgs e)
{
if (populatingWarnings || proposal == null || warningGrid.SelectedRows.Count == 0)
return;
// SelectionChanged precedes CurrentCell's update; SelectedRows is the new selection.
if (warningGrid.SelectedRows[0].Tag is not CuttingPlanDiagnosticRow row)
return;
ShowPlate(row.PlateIndex);
if (!preview.Visible || row.PartNumber is not int partNumber)
{
if (preview.Visible)
previewLabel.Text += " No part location was provided for this warning.";
return;
}
var plan = proposal.Plates[row.PlateIndex].Result;
var matched = plan.ProposedOrder.Select((part, position) => (part, position))
.FirstOrDefault(item => item.part.SourceOrdinal == partNumber - 1);
if (matched.part == null)
{
previewLabel.Text += " This source part has no proposed preview location.";
return;
}
var bounds = preview.Plate.Parts[matched.position].BoundingBox;
if (row.OtherPartNumber is int otherNumber)
{
var other = plan.ProposedOrder.Select((part, position) => (part, position))
.FirstOrDefault(item => item.part.SourceOrdinal == otherNumber - 1);
if (other.part != null)
{
var second = preview.Plate.Parts[other.position].BoundingBox;
var left = System.Math.Min(bounds.Left, second.Left);
var bottom = System.Math.Min(bounds.Bottom, second.Bottom);
bounds = new OpenNest.Geometry.Box(left, bottom,
System.Math.Max(bounds.Right, second.Right) - left,
System.Math.Max(bounds.Top, second.Top) - bottom);
}
}
if (double.IsFinite(bounds.X) && double.IsFinite(bounds.Y)
&& double.IsFinite(bounds.Length) && double.IsFinite(bounds.Width)
&& bounds.Length > 0 && bounds.Width > 0)
{
var pad = System.Math.Max(0.5, System.Math.Max(bounds.Length, bounds.Width) * 0.12);
preview.ZoomToArea(bounds.X - pad, bounds.Y - pad,
bounds.Length + 2 * pad, bounds.Width + 2 * pad);
}
previewLabel.Text += " Showing affected part extent, not an exact warning point.";
}
private void ClearProposal()
{
proposal = null;
populatingWarnings = true;
try
{
warningGrid.Rows.Clear();
}
finally
{
populatingWarnings = false;
}
acceptWarningsCheckBox.Checked = false;
acceptWarningsCheckBox.Visible = false;
applyButton.Enabled = false;
+6
View File
@@ -646,6 +646,7 @@ namespace OpenNest.Forms
var serverUrl = NestStorage.Settings.ServerUrl;
databaseOpenInProgress = true;
storageModeMenu.Enabled = false;
mnuToolsOptions.Enabled = false;
var file = await repository.GetFileAsync(dlg.SelectedId);
if (file == null)
throw new FileNotFoundException("This nest no longer exists on the server.");
@@ -663,7 +664,10 @@ namespace OpenNest.Forms
{
databaseOpenInProgress = false;
if (!IsDisposed)
{
storageModeMenu.Enabled = !databaseSaveInProgress;
mnuToolsOptions.Enabled = !databaseSaveInProgress;
}
}
}
@@ -707,6 +711,7 @@ namespace OpenNest.Forms
{
databaseSaveInProgress = true;
storageModeMenu.Enabled = false;
mnuToolsOptions.Enabled = false;
EnableCheck();
try
{
@@ -729,6 +734,7 @@ namespace OpenNest.Forms
if (!IsDisposed)
{
storageModeMenu.Enabled = !databaseOpenInProgress;
mnuToolsOptions.Enabled = !databaseOpenInProgress;
EnableCheck();
}
}
+42 -3
View File
@@ -41,9 +41,13 @@
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
this.colorSchemeLabel = new System.Windows.Forms.Label();
this.colorSchemeCombo = new System.Windows.Forms.ComboBox();
this.serverGroupBox = new System.Windows.Forms.GroupBox();
this.serverUrlBox = new System.Windows.Forms.TextBox();
this.setServerButton = new System.Windows.Forms.Button();
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
this.tableLayoutPanel1.SuspendLayout();
this.bottomPanel1.SuspendLayout();
this.serverGroupBox.SuspendLayout();
this.SuspendLayout();
//
// checkBox1
@@ -135,7 +139,7 @@
this.bottomPanel1.Controls.Add(this.cancelButton);
this.bottomPanel1.Controls.Add(this.saveButton);
this.bottomPanel1.Dock = System.Windows.Forms.DockStyle.Bottom;
this.bottomPanel1.Location = new System.Drawing.Point(0, 368);
this.bottomPanel1.Location = new System.Drawing.Point(0, 443);
this.bottomPanel1.Name = "bottomPanel1";
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
this.bottomPanel1.TabIndex = 1;
@@ -158,7 +162,7 @@
// strategyGroupBox
//
this.strategyGroupBox.Controls.Add(this.strategyGrid);
this.strategyGroupBox.Location = new System.Drawing.Point(12, 178);
this.strategyGroupBox.Location = new System.Drawing.Point(12, 253);
this.strategyGroupBox.Name = "strategyGroupBox";
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
this.strategyGroupBox.TabIndex = 2;
@@ -184,13 +188,43 @@
this.colorSchemeCombo.Size = new System.Drawing.Size(130, 24);
this.colorSchemeCombo.TabIndex = 11;
//
// serverGroupBox
//
this.serverGroupBox.Controls.Add(this.serverUrlBox);
this.serverGroupBox.Controls.Add(this.setServerButton);
this.serverGroupBox.Location = new System.Drawing.Point(12, 178);
this.serverGroupBox.Name = "serverGroupBox";
this.serverGroupBox.Size = new System.Drawing.Size(684, 69);
this.serverGroupBox.TabIndex = 1;
this.serverGroupBox.TabStop = false;
this.serverGroupBox.Text = "Nest server URL (File / Database mode is selected under File > Storage Mode)";
//
// serverUrlBox
//
this.serverUrlBox.Location = new System.Drawing.Point(12, 29);
this.serverUrlBox.Name = "serverUrlBox";
this.serverUrlBox.PlaceholderText = "http://server:8090";
this.serverUrlBox.Size = new System.Drawing.Size(485, 22);
this.serverUrlBox.TabIndex = 0;
//
// setServerButton
//
this.setServerButton.Location = new System.Drawing.Point(504, 26);
this.setServerButton.Name = "setServerButton";
this.setServerButton.Size = new System.Drawing.Size(168, 28);
this.setServerButton.TabIndex = 1;
this.setServerButton.Text = "Set / Change Server";
this.setServerButton.UseVisualStyleBackColor = true;
this.setServerButton.Click += new System.EventHandler(this.SetServer_Click);
//
// OptionsForm
//
this.AcceptButton = this.saveButton;
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
this.CancelButton = this.cancelButton;
this.ClientSize = new System.Drawing.Size(708, 418);
this.ClientSize = new System.Drawing.Size(708, 493);
this.Controls.Add(this.strategyGroupBox);
this.Controls.Add(this.serverGroupBox);
this.Controls.Add(this.tableLayoutPanel1);
this.Controls.Add(this.bottomPanel1);
this.Font = new System.Drawing.Font("Microsoft Sans Serif", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
@@ -206,6 +240,8 @@
this.tableLayoutPanel1.ResumeLayout(false);
this.tableLayoutPanel1.PerformLayout();
this.bottomPanel1.ResumeLayout(false);
this.serverGroupBox.ResumeLayout(false);
this.serverGroupBox.PerformLayout();
this.ResumeLayout(false);
}
@@ -224,5 +260,8 @@
private System.Windows.Forms.GroupBox strategyGroupBox;
private System.Windows.Forms.Label colorSchemeLabel;
private System.Windows.Forms.ComboBox colorSchemeCombo;
private System.Windows.Forms.GroupBox serverGroupBox;
private System.Windows.Forms.TextBox serverUrlBox;
private System.Windows.Forms.Button setServerButton;
}
}
+25
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Engine.Strategies;
using OpenNest.Properties;
@@ -72,6 +73,7 @@ namespace OpenNest.Forms
private void LoadSettings()
{
serverUrlBox.Text = NestStorage.Settings.ServerUrl;
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
@@ -142,5 +144,28 @@ namespace OpenNest.Forms
{
SaveSettings();
}
private void SetServer_Click(object sender, EventArgs e)
{
if (!NestStorageSettings.TryNormalizeServerUrl(serverUrlBox.Text, out var url))
{
MessageBox.Show(this, "Enter a valid http or https server URL.",
"Server URL", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
try
{
NestStorage.Save(NestStorage.Settings.Mode, url);
serverUrlBox.Text = NestStorage.Settings.ServerUrl;
MessageBox.Show(this, "Server URL saved. Storage mode has not changed.",
"Server URL", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show(this, $"Could not save the server URL: {ex.Message}",
"Server URL", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
}
+17 -5
View File
@@ -31,6 +31,7 @@
tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel();
label1 = new System.Windows.Forms.Label();
numericUpDown1 = new OpenNest.Controls.NumericUpDown();
doneButton = new System.Windows.Forms.Button();
tableLayoutPanel1.SuspendLayout();
((System.ComponentModel.ISupportInitialize)numericUpDown1).BeginInit();
SuspendLayout();
@@ -42,13 +43,15 @@
tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
tableLayoutPanel1.Controls.Add(label1, 0, 0);
tableLayoutPanel1.Controls.Add(numericUpDown1, 1, 0);
tableLayoutPanel1.Controls.Add(doneButton, 1, 1);
tableLayoutPanel1.Dock = System.Windows.Forms.DockStyle.Fill;
tableLayoutPanel1.Location = new System.Drawing.Point(0, 0);
tableLayoutPanel1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
tableLayoutPanel1.Name = "tableLayoutPanel1";
tableLayoutPanel1.RowCount = 1;
tableLayoutPanel1.RowCount = 2;
tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Absolute, 35F));
tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
tableLayoutPanel1.Size = new System.Drawing.Size(266, 35);
tableLayoutPanel1.Size = new System.Drawing.Size(266, 74);
tableLayoutPanel1.TabIndex = 0;
//
// label1
@@ -74,20 +77,28 @@
numericUpDown1.TabIndex = 1;
numericUpDown1.Value = new decimal(new int[] { 1, 0, 0, 0 });
numericUpDown1.Leave += numericUpDown1_Leave;
// doneButton
//
doneButton.Anchor = System.Windows.Forms.AnchorStyles.Right;
doneButton.Location = new System.Drawing.Point(182, 40);
doneButton.Name = "doneButton";
doneButton.Size = new System.Drawing.Size(80, 28);
doneButton.TabIndex = 2;
doneButton.Text = "Done";
doneButton.UseVisualStyleBackColor = true;
doneButton.Click += doneButton_Click;
// SequenceForm
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(266, 35);
ControlBox = false;
ClientSize = new System.Drawing.Size(266, 74);
Controls.Add(tableLayoutPanel1);
FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow;
Location = new System.Drawing.Point(100, 100);
Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
MaximizeBox = false;
MinimizeBox = false;
MinimumSize = new System.Drawing.Size(268, 74);
MinimumSize = new System.Drawing.Size(268, 113);
Name = "SequenceForm";
ShowIcon = false;
ShowInTaskbar = false;
@@ -105,5 +116,6 @@
private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1;
private System.Windows.Forms.Label label1;
public Controls.NumericUpDown numericUpDown1;
private System.Windows.Forms.Button doneButton;
}
}
+8 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Windows.Forms;
namespace OpenNest.Forms
@@ -10,6 +10,13 @@ namespace OpenNest.Forms
InitializeComponent();
}
public event EventHandler DoneClicked;
private void doneButton_Click(object sender, EventArgs e)
{
DoneClicked?.Invoke(this, EventArgs.Empty);
}
private void numericUpDown1_Leave(object sender, EventArgs e)
{
numericUpDown1.Validate();
+2
View File
@@ -36,6 +36,8 @@ For a 120-by-81-inch sheet whose parts extend through 80 inches, the nominal ske
## 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.
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.
+38 -7
View File
@@ -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
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
counts rejected candidates and frontier ranking as well as accepted moves, before
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
unranked fallback or a proof of geometric impossibility.
travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names
are not tie breakers.
When the next whole part is fixed by the tour or a part is already active, the
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
@@ -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
fixes: replanning runs the same strict checks; an unverified fallback requires
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
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
@@ -313,9 +334,19 @@ plans every plate that has parts. Both open one dialog built on
`NoSolutionWithinBudget` is retried once with the current part order, and the summary
says the order was kept. Both are allowed 400 expansions per part (at least the
default 20000), because both still plan contour order and entries for every part.
- The dialog uses an owner-monitor-sized window (approximately 90% of the working area), like
the database browser. The right panel keeps the batch outcome and plate headings above a
read-only, unsorted warning grid with Plate, source Part, With, and full finding text. Every
overlap issue/pair and every planner finding has its own selectable row, including findings
hidden by the compact blocked-plate summary. Selecting a row switches to that plate's
detached preview and zooms to the affected part extent (or both parts for a pair), mapping
source positions to the proposed cutting order. This is not an exact contact marker: most
findings do not carry a geometric witness. If a plate cannot produce a current, supported
preview, no geometry is cloned or focused; the row still identifies its source part in the
editor. Replanning, cancellation and stale Apply clear old warning targets.
- The summary lists every plate: ready plates with part counts and rapid travel, others
with their status and findings. Unverified proposals show every overlap and route
warning in the scrollable summary before the acceptance checkbox is used; they do
with their status and findings. Unverified proposals retain every overlap and route
warning in the selectable grid before the acceptance checkbox is used; they do
not truncate later parts' warnings. Finding part numbers are the plate's current order, as the
editor numbers them. The preview shows the active plate detached from the nest (quantity
zero, so drawing quantities do not change) in the proposed order with the proposed
+4
View File
@@ -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
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
+37 -2
View File
@@ -70,8 +70,9 @@ remain separate sequencer/verification work.
## Filling cutouts (not yet in production)
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
the cutout's contour, and the sequencer does not enforce that order.
engine, so every engine benefits. Only an internal test-only pipeline preview calls it: a part
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
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
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
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.