diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index b4106eeb..606400f9 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -106,8 +106,13 @@ internal static class PostVerificationGeometry Center = center; Radius = radius; Sweep = sweep; + nativeEntity = new Lazy(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 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 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. diff --git a/OpenNest.Core/Sequencing/ManualPartSequencing.cs b/OpenNest.Core/Sequencing/ManualPartSequencing.cs new file mode 100644 index 00000000..8bef8db3 --- /dev/null +++ b/OpenNest.Core/Sequencing/ManualPartSequencing.cs @@ -0,0 +1,29 @@ +using System; +using System.Linq; + +namespace OpenNest.Sequencing +{ + /// Moves one existing part to a one-based cutting sequence position. + 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; + } + } +} diff --git a/OpenNest.Data/NestStorageSettings.cs b/OpenNest.Data/NestStorageSettings.cs index f092c5da..f15cf193 100644 --- a/OpenNest.Data/NestStorageSettings.cs +++ b/OpenNest.Data/NestStorageSettings.cs @@ -32,6 +32,18 @@ public sealed class NestStorageSettings public bool IsDatabaseMode => Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl); + /// Validate and normalize a nest server base URL without changing storage mode. + 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; + } + /// %APPDATA%\OpenNest\storage.json. public static string DefaultPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutNfpProposalTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutNfpProposalTests.cs new file mode 100644 index 00000000..1ddf5cfb --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutNfpProposalTests.cs @@ -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(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing, + cancelled.Token)); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs new file mode 100644 index 00000000..9a273e84 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs @@ -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 solve) : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? 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() : + 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(), Array.Empty())), "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(() => + 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(() => + 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 + { + public List 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? 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>(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(() => 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(), + 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? 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); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs new file mode 100644 index 00000000..1f37fe13 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs @@ -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(() => 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(() => CutoutRouter.Fill(Frame(), 0, + new[] { JobBuilder.Rectangle("small", 1, 1, 3) }, 0.25, cts.Token)); + } + + private static void AssertPhysical(NestJobPart frame, NestJobPart[] inserts, + IReadOnlyList 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()); + } + } +} diff --git a/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs b/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs index 2cbbd5fb..071ef469 100644 --- a/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs @@ -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(() => + 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? 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(() => + 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() { diff --git a/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs b/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs index fab6fafe..b3ff92fd 100644 --- a/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs @@ -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() { diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs index a1702975..a7b2353c 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs @@ -43,6 +43,10 @@ public sealed class CuttingPlanBatch /// 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); } +/// A diagnostic tied to captured plate and source part positions, not display text. +public sealed record CuttingPlanDiagnosticRow(int PlateIndex, int PlateNumber, int? PartNumber, + int? OtherPartNumber, string Message); + /// /// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance. /// @@ -243,6 +267,35 @@ public sealed class CuttingPlanProposal public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply; + /// All findings, including ones hidden by a blocked plate's abbreviated summary. + public IReadOnlyList DiagnosticRows() + { + var rows = new List(); + 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; + } + /// /// Installs every plate's replayed proposal through , on the /// thread that owns the plates. Only after every plate is applied does each one keep an owned copy diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs index 59092ce3..388266ce 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -93,6 +93,13 @@ public sealed class CuttingPlanSnapshot public int ExpansionBudget { get; } internal CuttingPlanStatus? Failure { get; } internal IReadOnlyList 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 diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 9a02ad2e..466e3700 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -196,29 +196,39 @@ internal static class JointCuttingPlanSearch var progressExpansions = Expansions; var stack = new Stack(); 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; } /// @@ -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. /// - private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly) + private IEnumerable 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 Children { get; set; } } } diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs index 44929067..c9a0c1be 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs @@ -22,20 +22,24 @@ namespace OpenNest.Engine.Jobs.Cutouts; /// /// /// 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. /// internal static class CutoutLatticeFill { /// Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets. internal const int DefaultShiftSteps = 8; - private const double FlattenTolerance = 0.001; + internal const double FlattenTolerance = 0.001; /// Extra growth beyond the spacing, covering flattening and Clipper rounding. 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; /// Returns up to insert poses inside the cutout, /// or none when no copy fits. @@ -45,9 +49,17 @@ internal static class CutoutLatticeFill internal static IReadOnlyList 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 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(); @@ -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(); 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(); 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; + } } /// 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). /// - 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 } /// Strictly inside an even-odd region; boundary points count as outside. - 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(); diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs new file mode 100644 index 00000000..9871e7c6 --- /dev/null +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs @@ -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; + +/// Test-only single-insert inner-fit proposal; never changes a job or a live plate. +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; + + /// 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. + 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); + + /// Find a pose clearing already accepted inserts as well as the enclosing frame. + 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; + + /// Sample component interiors, then points just inside each boundary toward + /// its component's center. This bounded search is not a complete inner-fit solver. + 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); + } + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs new file mode 100644 index 00000000..f90fc48c --- /dev/null +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs @@ -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; + +/// Internal-only frame bundles. No normal pipeline caller enables this. +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 LocalInserts); + private readonly IReadOnlyDictionary bundles; + + private CutoutPipelinePrepass(NestJob engineJob, IReadOnlyDictionary 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(StringComparer.Ordinal); + var byFrame = new Dictionary>(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(); + byFrame[frame.Id] = proxies; + for (var instance = 0; instance < frame.Quantity; instance++) + { + token.ThrowIfCancellationRequested(); + if (bundles.Count >= MaxBundlesPerJob) + break; + IReadOnlyList? 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(); + var available = new Dictionary(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(); + 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 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)); + } + + /// Reject untrusted accounting before expansion. A failed trial is never bindable, + /// including with allowInvalid. Only actually placed proxies consume reserved inserts. + 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()); + 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(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(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; + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs new file mode 100644 index 00000000..53caeaa7 --- /dev/null +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs @@ -0,0 +1,144 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace OpenNest.Engine.Jobs.Cutouts; + +/// Internal frame-local cutout proposal. Never changes demand, stock or a live plate. +/// 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. +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 Fill(NestJobPart frame, int cutoutIndex, + IReadOnlyList inserts, double spacing, CancellationToken token = default) => + Fill(frame, cutoutIndex, inserts, spacing, null, token); + + /// Optional internal diagnostic receives only poses from the chosen proposal, + /// after indexing; true marks a retained lattice seed, false a residual NFP pose. + internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, + IReadOnlyList inserts, double spacing, + Action? observeAccepted, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(frame); + ArgumentNullException.ThrowIfNull(inserts); + token.ThrowIfCancellationRequested(); + if (!double.IsFinite(spacing) || spacing < 0 || spacing > 100) + return Array.Empty(); + var frameGeometry = JobPartGeometry.TryRead(frame.Geometry); + if (frameGeometry == null) + return Array.Empty(); + 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(); + if (inserts.Any(p => p == null || !SupportedGeometry(p))) + return Array.Empty(); + 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(); + 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()) : null; + var lattice = tryLattice && (!nfpFirst || nfp!.Count < part.Quantity) + ? CutoutLatticeFill.Fill(frame, cutoutIndex, part, part.Quantity, spacing, + CutoutLatticeFill.DefaultShiftSteps, occupied, token) + : Array.Empty(); + IReadOnlyList chosen; + var retainedLattice = 0; + if (compareBoth || (nfpFirst && lattice.Count > 0)) + { + nfp ??= Nfp(Array.Empty()); + 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(); + 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 Complete(IReadOnlyList seed) + { + if (seed.Count >= part.Quantity) + return seed; + var result = new List(seed); + result.AddRange(Nfp(seed)); + return result; + } + + IReadOnlyList Nfp(IReadOnlyList seed) + { + var result = new List(); + 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; +} diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs index 7753c46f..4fd61183 100644 --- a/OpenNest.Engine/Jobs/NestPipeline.cs +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -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 violations, bool canKeep, TimeSpan solveTime, - TimeSpan validationTime + TimeSpan validationTime, + IReadOnlyDictionary 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 DrawingsByPartId { get; } } /// @@ -72,6 +76,15 @@ public sealed class NestPipelineResult /// public static class NestPipeline { + private sealed class PreviewProgress(IProgress inner) : IProgress + { + public void Report(NestJobProgress value) + { + if (value != null && value.Stage == NestJobStage.EvaluatingCandidate) + inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 }); + } + } + /// Resolves through /// ; unknown names throw . /// Engine cancellation propagates unchanged. @@ -93,6 +106,32 @@ public static class NestPipeline NestPipelineRequest request, IProgress progress = null, CancellationToken token = default + ) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false); + + /// 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. + internal static NestPipelineResult RunCutoutPreview( + INestingEngine engine, + string engineName, + NestPipelineRequest request, + IProgress progress = null, + CancellationToken token = default + ) => RunCore(engine, engineName, request, progress, token, cutoutPreview: true); + + internal static NestPipelineResult RunCutoutPreview( + NestPipelineRequest request, + IProgress 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 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(); + // 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 ); } } diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs index cd59bb32..cf46ac7a 100644 --- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs +++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs @@ -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(); // Commit is synchronous on the caller's owning thread. Cancellation is checked diff --git a/OpenNest.Engine/Jobs/NestPipelineDrawingFreshness.cs b/OpenNest.Engine/Jobs/NestPipelineDrawingFreshness.cs new file mode 100644 index 00000000..9f81005e --- /dev/null +++ b/OpenNest.Engine/Jobs/NestPipelineDrawingFreshness.cs @@ -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; + +/// Refuse a proposal when a caller drawing no longer matches its checked snapshot. +internal static class NestPipelineDrawingFreshness +{ + internal static IReadOnlyList Changes(NestJob job, + IReadOnlyDictionary drawings) + { + var changed = new List(); + 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; + } + + /// Check the actual cloned programs, not only the drawing before/after + /// binding. A transient edit during Clone may leave the drawing unchanged. + internal static IReadOnlyList BoundChanges(NestJob job, NestJobResult raw, + IReadOnlyList plates, IReadOnlyDictionary drawings) + { + var changed = new List(); + 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); +} diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs index d889c4c3..2a990de8 100644 --- a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs +++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs @@ -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() { diff --git a/OpenNest.Tests/CuttingPlanning/LazyCuttingSearchTests.cs b/OpenNest.Tests/CuttingPlanning/LazyCuttingSearchTests.cs new file mode 100644 index 00000000..10c97b9f --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/LazyCuttingSearchTests.cs @@ -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)), + ]; +} diff --git a/OpenNest.Tests/CuttingPlanning/NativeContactRegressionTests.cs b/OpenNest.Tests/CuttingPlanning/NativeContactRegressionTests.cs index dd81ee25..02bbb685 100644 --- a/OpenNest.Tests/CuttingPlanning/NativeContactRegressionTests.cs +++ b/OpenNest.Tests/CuttingPlanning/NativeContactRegressionTests.cs @@ -119,6 +119,50 @@ public class NativeContactRegressionTests Assert.Throws(() => 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); diff --git a/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs b/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs index 634ee081..0dfecefd 100644 --- a/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs +++ b/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs @@ -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() { diff --git a/OpenNest.Tests/Data/NestStorageTests.cs b/OpenNest.Tests/Data/NestStorageTests.cs index 1a41586d..b7a33f90 100644 --- a/OpenNest.Tests/Data/NestStorageTests.cs +++ b/OpenNest.Tests/Data/NestStorageTests.cs @@ -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)) diff --git a/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs b/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs new file mode 100644 index 00000000..c62a77eb --- /dev/null +++ b/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs @@ -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); + } +} diff --git a/OpenNest.Tests/Sequencing/ManualPartSequencingTests.cs b/OpenNest.Tests/Sequencing/ManualPartSequencingTests.cs new file mode 100644 index 00000000..71158de8 --- /dev/null +++ b/OpenNest.Tests/Sequencing/ManualPartSequencingTests.cs @@ -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); + } +} diff --git a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs index 8e300b02..057e7b3d 100644 --- a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs @@ -59,10 +59,12 @@ public class CuttingPlanFormTests Assert.False(Control