From 0b7697e9c0b2577d8e7af4136b08a36eec7dca3b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 21 Mar 2026 12:57:33 -0400 Subject: [PATCH] feat: add VerticalRemnantEngine and HorizontalRemnantEngine Two new engine classes subclassing DefaultNestEngine that override CreateComparer, PreferredDirection, and BuildAngles to optimize for preserving side remnants. Both registered in NestEngineRegistry and covered by 6 integration tests. Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Engine/HorizontalRemnantEngine.cs | 42 ++++++++++ OpenNest.Engine/NestEngineRegistry.cs | 8 ++ OpenNest.Engine/VerticalRemnantEngine.cs | 42 ++++++++++ OpenNest.Tests/RemnantEngineTests.cs | 92 ++++++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 OpenNest.Engine/HorizontalRemnantEngine.cs create mode 100644 OpenNest.Engine/VerticalRemnantEngine.cs create mode 100644 OpenNest.Tests/RemnantEngineTests.cs diff --git a/OpenNest.Engine/HorizontalRemnantEngine.cs b/OpenNest.Engine/HorizontalRemnantEngine.cs new file mode 100644 index 0000000..1cadd19 --- /dev/null +++ b/OpenNest.Engine/HorizontalRemnantEngine.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using OpenNest.Engine; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + /// + /// Optimizes for the largest top-side horizontal drop. + /// Scores by count first, then minimizes Y-extent. + /// Prefers vertical nest direction and angles that keep parts narrow in Y. + /// + public class HorizontalRemnantEngine : DefaultNestEngine + { + public HorizontalRemnantEngine(Plate plate) : base(plate) { } + + public override string Name => "Horizontal Remnant"; + + public override string Description => "Optimizes for largest top-side horizontal drop"; + + protected override IFillComparer CreateComparer() => new HorizontalRemnantComparer(); + + public override NestDirection? PreferredDirection => NestDirection.Vertical; + + public override List BuildAngles(NestItem item, double bestRotation, Box workArea) + { + var baseAngles = new List { bestRotation, bestRotation + Angle.HalfPI }; + baseAngles.Sort((a, b) => RotatedHeight(item, a).CompareTo(RotatedHeight(item, b))); + return baseAngles; + } + + private static double RotatedHeight(NestItem item, double angle) + { + var bb = item.Drawing.Program.BoundingBox(); + var cos = System.Math.Abs(System.Math.Cos(angle)); + var sin = System.Math.Abs(System.Math.Sin(angle)); + return bb.Length * cos + bb.Width * sin; + } + } +} diff --git a/OpenNest.Engine/NestEngineRegistry.cs b/OpenNest.Engine/NestEngineRegistry.cs index 2a7e9da..8363646 100644 --- a/OpenNest.Engine/NestEngineRegistry.cs +++ b/OpenNest.Engine/NestEngineRegistry.cs @@ -24,6 +24,14 @@ namespace OpenNest Register("NFP", "NFP-based mixed-part nesting with simulated annealing", plate => new NfpNestEngine(plate)); + + Register("Vertical Remnant", + "Optimizes for largest right-side vertical drop", + plate => new VerticalRemnantEngine(plate)); + + Register("Horizontal Remnant", + "Optimizes for largest top-side horizontal drop", + plate => new HorizontalRemnantEngine(plate)); } public static IReadOnlyList AvailableEngines => engines; diff --git a/OpenNest.Engine/VerticalRemnantEngine.cs b/OpenNest.Engine/VerticalRemnantEngine.cs new file mode 100644 index 0000000..1be136b --- /dev/null +++ b/OpenNest.Engine/VerticalRemnantEngine.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using OpenNest.Engine; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + /// + /// Optimizes for the largest right-side vertical drop. + /// Scores by count first, then minimizes X-extent. + /// Prefers horizontal nest direction and angles that keep parts narrow in X. + /// + public class VerticalRemnantEngine : DefaultNestEngine + { + public VerticalRemnantEngine(Plate plate) : base(plate) { } + + public override string Name => "Vertical Remnant"; + + public override string Description => "Optimizes for largest right-side vertical drop"; + + protected override IFillComparer CreateComparer() => new VerticalRemnantComparer(); + + public override NestDirection? PreferredDirection => NestDirection.Horizontal; + + public override List BuildAngles(NestItem item, double bestRotation, Box workArea) + { + var baseAngles = new List { bestRotation, bestRotation + Angle.HalfPI }; + baseAngles.Sort((a, b) => RotatedWidth(item, a).CompareTo(RotatedWidth(item, b))); + return baseAngles; + } + + private static double RotatedWidth(NestItem item, double angle) + { + var bb = item.Drawing.Program.BoundingBox(); + var cos = System.Math.Abs(System.Math.Cos(angle)); + var sin = System.Math.Abs(System.Math.Sin(angle)); + return bb.Width * cos + bb.Length * sin; + } + } +} diff --git a/OpenNest.Tests/RemnantEngineTests.cs b/OpenNest.Tests/RemnantEngineTests.cs new file mode 100644 index 0000000..208a6a2 --- /dev/null +++ b/OpenNest.Tests/RemnantEngineTests.cs @@ -0,0 +1,92 @@ +using OpenNest.Engine; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class RemnantEngineTests +{ + private static Drawing MakeRectDrawing(double w, double h, string name = "rect") + { + var pgm = new OpenNest.CNC.Program(); + pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0))); + return new Drawing(name, pgm); + } + + [Fact] + public void VerticalRemnantEngine_UsesVerticalRemnantComparer() + { + var plate = new Plate(60, 120); + var engine = new VerticalRemnantEngine(plate); + Assert.Equal("Vertical Remnant", engine.Name); + Assert.Equal(NestDirection.Horizontal, engine.PreferredDirection); + } + + [Fact] + public void HorizontalRemnantEngine_UsesHorizontalRemnantComparer() + { + var plate = new Plate(60, 120); + var engine = new HorizontalRemnantEngine(plate); + Assert.Equal("Horizontal Remnant", engine.Name); + Assert.Equal(NestDirection.Vertical, engine.PreferredDirection); + } + + [Fact] + public void VerticalRemnantEngine_Fill_ProducesResults() + { + var plate = new Plate(60, 120); + var engine = new VerticalRemnantEngine(plate); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + + var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None); + + Assert.True(parts.Count > 0, "VerticalRemnantEngine should fill parts"); + } + + [Fact] + public void HorizontalRemnantEngine_Fill_ProducesResults() + { + var plate = new Plate(60, 120); + var engine = new HorizontalRemnantEngine(plate); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + + var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None); + + Assert.True(parts.Count > 0, "HorizontalRemnantEngine should fill parts"); + } + + [Fact] + public void Registry_ContainsBothRemnantEngines() + { + var names = NestEngineRegistry.AvailableEngines.Select(e => e.Name).ToList(); + Assert.Contains("Vertical Remnant", names); + Assert.Contains("Horizontal Remnant", names); + } + + [Fact] + public void VerticalRemnantEngine_ProducesTighterXExtent_ThanDefault() + { + var plate = new Plate(60, 120); + var drawing = MakeRectDrawing(20, 10); + var item = new NestItem { Drawing = drawing }; + + var defaultEngine = new DefaultNestEngine(plate); + var remnantEngine = new VerticalRemnantEngine(plate); + + var defaultParts = defaultEngine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None); + var remnantParts = remnantEngine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None); + + Assert.True(defaultParts.Count > 0); + Assert.True(remnantParts.Count > 0); + + var defaultXExtent = defaultParts.Max(p => p.BoundingBox.Right) - defaultParts.Min(p => p.BoundingBox.Left); + var remnantXExtent = remnantParts.Max(p => p.BoundingBox.Right) - remnantParts.Min(p => p.BoundingBox.Left); + + Assert.True(remnantXExtent <= defaultXExtent + 0.01, + $"Remnant X-extent ({remnantXExtent:F1}) should be <= default ({defaultXExtent:F1})"); + } +}