diff --git a/OpenNest.Engine.Tests/Jobs/FixedStrategyNestingEngineTests.cs b/OpenNest.Engine.Tests/Jobs/FixedStrategyNestingEngineTests.cs index 831f6de5..af637e66 100644 --- a/OpenNest.Engine.Tests/Jobs/FixedStrategyNestingEngineTests.cs +++ b/OpenNest.Engine.Tests/Jobs/FixedStrategyNestingEngineTests.cs @@ -1,4 +1,6 @@ +using OpenNest.CNC; using OpenNest.Engine.Jobs; +using OpenNest.Engine.Tests.NestingEngines; using OpenNest.Geometry; using Xunit; @@ -55,4 +57,64 @@ public class FixedStrategyNestingEngineTests Assert.Throws(() => new FixedStrategyNestingEngine(null)); Assert.Throws(() => new FixedStrategyNestingEngine(" ")); } + + private static Program LShape() + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(6, 0); + program.LineTo(6, 4); + program.LineTo(3, 4); + program.LineTo(3, 2); + program.LineTo(0, 2); + program.LineTo(0, 0); + return program; + } + + public static TheoryData LockedRotationCases => + new() + { + { "Fill", "rect-6x4", 0, 20 }, + { "Strip", "rect-6x4", 0, 20 }, + { "Strip", "rect-20x3", 0, 10 }, + { "Strip", "rect-3x20", 0, 10 }, + { "Strip", "rect-6x4", System.Math.PI / 2, 20 }, + { "Strip", "rect-6x4", 0.3, 10 }, + { "Strip", "lshape", 0.3, 10 }, + { "Vertical Remnant", "lshape", 0.3, 10 }, + { "Horizontal Remnant", "lshape", 0.3, 10 }, + }; + + [Theory] + [MemberData(nameof(LockedRotationCases))] + public void LockedRotation_CompletesAtTheLockedAngle( + string strategy, + string shape, + double angle, + int quantity + ) + { + var program = shape switch + { + "rect-6x4" => TestDrawingFactory.Rectangle(6, 4), + "rect-20x3" => TestDrawingFactory.Rectangle(20, 3), + "rect-3x20" => TestDrawingFactory.Rectangle(3, 20), + _ => LShape(), + }; + var part = new NestJobPart( + shape, + PartGeometrySnapshot.FromProgram(program), + quantity, + rotation: RotationPolicy.Fixed(angle) + ); + var stock = new NestPlateStock("stock", new Size(30, 50), 3, 1, new Spacing(1, 1, 1, 1)); + var job = new NestJob(new[] { part }, new[] { stock }, new NestJobOptions(strategy)); + + var result = new FixedStrategyNestingEngine(strategy).Solve(job); + + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(quantity, result.Plates.Sum(p => p.Placements.Count)); + // Checks NestLayoutCheck and that every pose is an angle the policy allows. + LayoutAssert.Valid(job, result); + } } diff --git a/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs b/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs index 421fcfc9..040c82c7 100644 --- a/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs +++ b/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs @@ -255,6 +255,30 @@ public class PlateNesterParityTests Assert.All(placements, p => Assert.True(AnglesEqual(p.Rotation, 0))); } + [Fact] + public void StripRestrictedRotation_NeverTouchesFiller() + { + // Same safety rule for Strip, whose shrink fills use two classification angles and + // ignore the requirement's rotation bounds. The throwing factory proves the filler is + // never constructed; completion at the locked angle proves OrderedPlateNester handled it. + var part = new NestJobPart( + "fixed", + PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)), + 2, + rotation: RotationPolicy.Fixed(0) + ); + var nester = new StripPlateNester(_ => + throw new InvalidOperationException("filler must not run for restricted rotation") + ); + + var result = Solve(nester, Job(new[] { part }, strategy: "Strip")); + + Assert.Equal(NestJobStatus.Complete, result.Status); + var placements = result.Plates.SelectMany(p => p.Placements).ToList(); + Assert.Equal(2, placements.Count); + Assert.All(placements, p => Assert.True(AnglesEqual(p.Rotation, 0))); + } + [Fact] public void RepeatedNames_KeepIndependentIdentity() { diff --git a/OpenNest.Engine/Jobs/Placement/StripPlateNester.cs b/OpenNest.Engine/Jobs/Placement/StripPlateNester.cs index 356911c9..5b2aaf2b 100644 --- a/OpenNest.Engine/Jobs/Placement/StripPlateNester.cs +++ b/OpenNest.Engine/Jobs/Placement/StripPlateNester.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading; using OpenNest.Engine.Jobs.Adapters; @@ -20,6 +21,7 @@ namespace OpenNest.Engine.Jobs.Placement; public sealed class StripPlateNester : IPlateNester { private readonly Func fillerFactory; + private readonly OrderedPlateNester restrictedRotationNester = new(); private readonly CandidatePlacementContext context = new(); public StripPlateNester() @@ -41,6 +43,12 @@ public sealed class StripPlateNester : IPlateNester ArgumentNullException.ThrowIfNull(request); token.ThrowIfCancellationRequested(); + // The strip fills try two classification angles and ignore the requirement's rotation + // bounds, so they can return poses its RotationPolicy forbids. As in + // DefaultPlateNester, restricted requirements go to the policy-aware ordered nester. + if (request.Parts.Any(part => part.Rotation.Kind != RotationPolicyKind.Automatic)) + return restrictedRotationNester.Place(request, progress, token); + var plate = DrawingJobMapper.CreatePlate(request.Stock); var items = context.CreateItems(request.Parts); diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 286d86a7..8b888ca0 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -16,7 +16,7 @@ in `OpenNest.Engine/NestingEngines//`, its tests in `OpenNest.Engine.Tests | Circles | Round parts: blanks, washers, flanges, gears by their tip circle | Each part packed as its minimum enclosing circle at centers tangent to two of walls, placed discs and clamp keep-outs, with hexagonal and square lattice seeds; bids only when every part is round; stock chosen as Rectangles does, plus whole-job runs preferring each stock and a tail re-plan | | Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan | | FlatBar | Flat bar: parts about as wide as the bar | Each stock read as a bar and cut in one row: the CutList cut-list packer and a best-fill search on part lengths less their self-nesting gain, then a slide-left pass that nests shaped ends and refills freed length; stock chosen bar by bar by whole-plan cost | -| Fill, Strip, Vertical Remnant, Horizontal Remnant | Single-strategy fills | The fixed placement strategies behind interactive fill; Fill is the multi-phase lattice fill (linear, pairs, rectangle best-fit, remainder) | +| Fill, Strip, Vertical Remnant, Horizontal Remnant | Single-strategy fills | The fixed placement strategies behind interactive fill; Fill is the multi-phase lattice fill (linear, pairs, rectangle best-fit, remainder). In whole jobs, all four place a plate with any fixed or bounded part rotation by the rotation-aware ordered placer instead | Neither Irregular nor Rectangles wins every job, even within its own lane, so Default runs both rather than choosing by part shape; Rectangles adds little time and is also the fallback when an