mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-13 13:41:08 -05:00
CutoutPackerProposal runs the Irregular engine's FrontierPacker on one hole, frame-local, with the hole as its boundary and already seated inserts as obstacles, for all six strategy variants; every pose is certified with NestLayoutCheck.Clears against the frame, the occupants and the other poses, and the variant seating the most copies wins, ties going to the tighter layout. CutoutRouter keeps that proposal over the lattice-and-NFP one when it seats more copies, or the same number more compactly, so the pre-pass gets it on every non-round hole. Two copies of a bar in a frame window, which the lattice never tried and the sampled NFP search scattered across the window (one at the inner fit's centre), now stand together against one wall. The test rebuilds that frame and bar from embedded G-code; the residual-accounting pipeline tests use a hole that holds one insert so their second copy stays an ordinary requirement. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
335 lines
13 KiB
C#
335 lines
13 KiB
C#
using System.Globalization;
|
|
using System.Text.RegularExpressions;
|
|
using OpenNest.CNC;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Engine.Jobs.Cutouts;
|
|
using OpenNest.Engine.Tests.NestingEngines;
|
|
using OpenNest.Geometry;
|
|
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
|
|
|
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
|
|
|
/// <summary>The frontier packer as a cutout proposal: inserts settle against the cutout wall
|
|
/// and each other instead of landing at the first sampled point that clears.</summary>
|
|
[Collection("Cutout pipeline registry")]
|
|
public class CutoutPackerProposalTests
|
|
{
|
|
private const double Spacing = 0.3;
|
|
|
|
/// <summary>A 13.148 x 1.25 bar with a shallow notch in one long edge and eight bolt holes.</summary>
|
|
private const string BarCode = """
|
|
G91
|
|
G00X0Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X0.5Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X1.875Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X0.5Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X4.625Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X0.5Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X1.875Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X0.5Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X-11.8865Y-1.0625
|
|
G01X0Y0.75
|
|
G02X0.5Y0.5I0.5J0
|
|
G01X3.824Y0
|
|
G02X0.25Y-0.25I0J-0.25
|
|
G01X0Y-0.125
|
|
G03X0.25Y-0.25I0.25J-0
|
|
G01X3.5Y0
|
|
G03X0.25Y0.25I0J0.25
|
|
G01X0Y0.125
|
|
G02X0.25Y0.25I0.25J0
|
|
G01X3.824Y0
|
|
G02X0.5Y-0.5I0J-0.5
|
|
G01X0Y-0.75
|
|
G01X-13.148Y0
|
|
""";
|
|
|
|
/// <summary>One 12.25-wide window whose near end carries a notched tab, open to the given
|
|
/// wall height above the tab; the frame below stacks four such windows.</summary>
|
|
private static string Window(double wallHeight) => $"""
|
|
G01X0.125Y0
|
|
G01X0Y0.841458
|
|
G02X0.5Y0.5I0.5J0
|
|
G01X0Y-0.3125
|
|
G01X1.125Y0
|
|
G01X0Y0.3125
|
|
G01X1.25Y0
|
|
G01X0Y-0.3125
|
|
G01X1.125Y0
|
|
G01X0Y0.3125
|
|
G01X4Y0
|
|
G01X0Y-0.3125
|
|
G01X1.125Y0
|
|
G01X0Y0.3125
|
|
G01X1.25Y0
|
|
G01X0Y-0.3125
|
|
G01X1.125Y0
|
|
G01X0Y0.3125
|
|
G02X0.5Y-0.5I0J-0.5
|
|
G01X0Y-0.841458
|
|
G01X0.125Y0
|
|
G01X0Y{wallHeight.ToString(CultureInfo.InvariantCulture)}
|
|
G03X-0.125Y0.125I-0.125J0
|
|
G01X-12Y0
|
|
G03X-0.125Y-0.125I0J-0.125
|
|
G01X0Y-{wallHeight.ToString(CultureInfo.InvariantCulture)}
|
|
""";
|
|
|
|
/// <summary>A 14.82 x 69.72 frame with a plain 12.25 x 13.4 window and three tabbed windows
|
|
/// of rising height; only the last is tall enough to stand the bar in.</summary>
|
|
private static readonly string FrameCode = $"""
|
|
G91
|
|
G00X0Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X0.5Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X1.875Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X0.5Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X-3.9375Y2.033896
|
|
G01X12.25Y0
|
|
G01X0Y13.297
|
|
G03X-0.125Y0.125I-0.125J0
|
|
G01X-12Y0
|
|
G03X-0.125Y-0.125I0J-0.125
|
|
G01X0Y-13.297
|
|
G00X8.5625Y-2.033896
|
|
G03X0Y0I-0.125J0
|
|
G00X0.5Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X1.875Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X0.5Y0
|
|
G03X0Y0I-0.125J0
|
|
G00X-11.4375Y16.955896
|
|
{Window(14.375)}
|
|
G00X0Y16
|
|
{Window(14.375)}
|
|
G00X0Y16
|
|
{Window(19.453)}
|
|
G00X-1.283896Y-47.797
|
|
G01X0Y68.25
|
|
G02X0.125Y0.125I0.125J0
|
|
G01X14.567791Y0
|
|
G02X0.125Y-0.125I0J-0.125
|
|
G01X0Y-68.25
|
|
G02X-0.125Y-0.125I-0.125J0
|
|
G01X-0.2615Y0
|
|
G02X-0.125Y0.125I0J0.125
|
|
G01X0Y0.051
|
|
G01X-0.323396Y0
|
|
G01X0Y-0.897396
|
|
G02X-0.5Y-0.5I-0.5J-0
|
|
G01X-12.148Y0
|
|
G02X-0.5Y0.5I0J0.5
|
|
G01X0Y0.897396
|
|
G01X-0.323396Y0
|
|
G01X0Y-0.051
|
|
G02X-0.125Y-0.125I-0.125J0
|
|
G01X-0.2615Y0
|
|
G02X-0.125Y0.125I0J0.125
|
|
""";
|
|
|
|
/// <summary>Height of the tab on a tabbed window's near end.</summary>
|
|
private const double TabHeight = 0.841458 + 0.5;
|
|
|
|
private static Program Bar() => Incremental(BarCode);
|
|
private static Program Frame() => Incremental(FrameCode);
|
|
|
|
[Fact]
|
|
public void FrameHasFourWindowsAndTheBarIsSolid()
|
|
{
|
|
var frame = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Frame()));
|
|
var bar = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Bar()));
|
|
Assert.Equal(4, frame.Cutouts.Count(c => System.Math.Abs(c.Area()) > 100));
|
|
Assert.Equal(14.818, frame.Bounds.Length, 3);
|
|
Assert.Equal(69.721, frame.Bounds.Width, 3);
|
|
Assert.Equal(13.148, bar.Bounds.Length, 3);
|
|
Assert.Equal(1.25, bar.Bounds.Width, 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void TwoBarsStackAgainstAWindowWallInsteadOfScatteringAcrossIt()
|
|
{
|
|
var frame = JobBuilder.Part("PT10", Frame(), 1);
|
|
var bar = JobBuilder.Part("PT11", Bar(), 2);
|
|
var geometry = JobPartGeometry.Read(frame.Geometry);
|
|
var seated = new List<(int Hole, IReadOnlyList<NestJobPlacement> Poses)>();
|
|
for (var hole = 0; hole < geometry.Cutouts.Count; hole++)
|
|
{
|
|
if (System.Math.Abs(geometry.Cutouts[hole].Area()) < 100)
|
|
continue;
|
|
var poses = CutoutRouter.Fill(frame, hole, new[] { bar }, Spacing);
|
|
if (poses.Count == 2)
|
|
seated.Add((hole, poses));
|
|
}
|
|
var (window, stacked) = Assert.Single(seated);
|
|
AssertClear(frame, bar, stacked, Spacing);
|
|
AssertStacked(geometry.Cutouts[window], JobPartGeometry.Read(bar.Geometry), stacked);
|
|
}
|
|
|
|
[Fact]
|
|
public void PackerAloneSeatsBothBarsAgainstTheWall()
|
|
{
|
|
var frame = JobBuilder.Part("PT10", Frame(), 1);
|
|
var bar = JobBuilder.Part("PT11", Bar(), 2);
|
|
var geometry = JobPartGeometry.Read(frame.Geometry);
|
|
var tallest = Enumerable.Range(0, geometry.Cutouts.Count)
|
|
.OrderByDescending(i => System.Math.Abs(geometry.Cutouts[i].Area())).First();
|
|
var poses = CutoutPackerProposal.Fill(frame, tallest, new[] { bar }, Spacing);
|
|
Assert.Equal(2, poses.Count);
|
|
Assert.Equal(new[] { 0, 1 }, poses.Select(p => p.InstanceIndex));
|
|
AssertClear(frame, bar, poses, Spacing);
|
|
AssertStacked(geometry.Cutouts[tallest], JobPartGeometry.Read(bar.Geometry), poses);
|
|
}
|
|
|
|
[Fact]
|
|
public void PackerKeepsClearOfInsertsAlreadySeated()
|
|
{
|
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
|
var insert = JobBuilder.Rectangle("insert", 3, 2, 6);
|
|
var geometry = JobPartGeometry.Read(insert.Geometry);
|
|
var occupant = new NestJobPlacement("occupant", 0, -1.5, -1, 0);
|
|
var poses = CutoutPackerProposal.Fill(frame, 0, new[] { insert }, 0.25,
|
|
new[] { (geometry, occupant) }, null, default);
|
|
Assert.NotEmpty(poses);
|
|
foreach (var pose in poses)
|
|
Assert.True(NestLayoutCheck.Clears(geometry, occupant, geometry, pose, 0.25), $"{pose} meets the occupant");
|
|
AssertClear(frame, insert, poses, 0.25);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Irregular")]
|
|
[InlineData("Default")]
|
|
public void AutoNestSeatsBothBarsStackedInOneWindow(string engine)
|
|
{
|
|
var frameDrawing = new Drawing("PT10", Frame());
|
|
var barDrawing = new Drawing("PT11", Bar());
|
|
var request = new NestPipelineRequest(engine,
|
|
new[] { new NestItem { Drawing = barDrawing, Quantity = 2 }, new NestItem { Drawing = frameDrawing, Quantity = 2 } },
|
|
new[] { new NestPlateStock("sheet", new Size(60, 120), 1, Spacing) },
|
|
new NestJobOptions(maxPlates: 1));
|
|
var result = NestPipeline.Run(request);
|
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
var plate = Assert.Single(result.Plates);
|
|
var bars = plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, barDrawing)).ToArray();
|
|
var frames = plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, frameDrawing)).ToArray();
|
|
Assert.Equal(2, bars.Length);
|
|
Assert.Equal(2, frames.Length);
|
|
var frameGeometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Frame()));
|
|
var barGeometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Bar()));
|
|
// Both bars lie in one window of one frame; express them in that frame's coordinates.
|
|
var seated = new List<(Part Frame, Shape Window, IReadOnlyList<NestJobPlacement> Poses)>();
|
|
foreach (var host in frames)
|
|
{
|
|
var local = bars.Select(bar => Local(host, bar)).ToArray();
|
|
foreach (var window in frameGeometry.Cutouts.Where(c => System.Math.Abs(c.Area()) > 100))
|
|
if (local.All(pose => Inside(window, Extent(barGeometry, pose))))
|
|
seated.Add((host, window, local));
|
|
}
|
|
var (_, hole, poses) = Assert.Single(seated);
|
|
AssertStacked(hole, barGeometry, poses);
|
|
}
|
|
|
|
private static NestJobPlacement Local(Part frame, Part part)
|
|
{
|
|
var dx = part.Location.X - frame.Location.X;
|
|
var dy = part.Location.Y - frame.Location.Y;
|
|
var cosine = System.Math.Cos(-frame.Rotation);
|
|
var sine = System.Math.Sin(-frame.Rotation);
|
|
return new NestJobPlacement("PT11", 0, dx * cosine - dy * sine, dx * sine + dy * cosine,
|
|
part.Rotation - frame.Rotation);
|
|
}
|
|
|
|
private static bool Inside(Shape window, Box box)
|
|
{
|
|
var bounds = window.BoundingBox;
|
|
return box.Left > bounds.Left && box.Right < bounds.Right && box.Bottom > bounds.Bottom && box.Top < bounds.Top;
|
|
}
|
|
|
|
/// <summary>Both bars stand in the window against one wall and against each other, not in
|
|
/// the middle: each touches the wall or the other bar across the window, and the near tab
|
|
/// or the far wall along it, within the spacing plus a small tolerance.</summary>
|
|
private static void AssertStacked(Shape window, JobPartGeometry bar, IReadOnlyList<NestJobPlacement> poses)
|
|
{
|
|
const double slack = 0.05;
|
|
var hole = window.BoundingBox;
|
|
var boxes = poses.Select(p => Extent(bar, p)).ToArray();
|
|
Assert.Equal(2, boxes.Length);
|
|
foreach (var box in boxes)
|
|
{
|
|
Assert.True(Inside(window, box), $"{box} is not inside the window {hole}");
|
|
var other = boxes.Single(b => !ReferenceEquals(b, box));
|
|
var toWall = System.Math.Min(box.Left - hole.Left, hole.Right - box.Right);
|
|
var toOther = System.Math.Max(other.Left - box.Right, box.Left - other.Right);
|
|
Assert.True(toWall <= Spacing + slack || toOther <= Spacing + slack,
|
|
$"{box} floats {toWall:F3} from the walls and {toOther:F3} from the other bar");
|
|
var toEnd = System.Math.Min(box.Bottom - hole.Bottom - TabHeight, hole.Top - box.Top);
|
|
Assert.True(toEnd <= Spacing + slack, $"{box} floats {toEnd:F3} from the window ends");
|
|
}
|
|
var gap = System.Math.Max(boxes[1].Left - boxes[0].Right, boxes[0].Left - boxes[1].Right);
|
|
Assert.InRange(gap, Spacing - 1e-6, Spacing + slack);
|
|
var center = (boxes.Min(b => b.Left) + boxes.Max(b => b.Right)) / 2;
|
|
Assert.True(System.Math.Abs(center - (hole.Left + hole.Right) / 2) > 2,
|
|
"the pair sits in the middle of the window");
|
|
}
|
|
|
|
private static Box Extent(JobPartGeometry geometry, NestJobPlacement pose)
|
|
{
|
|
var shape = (Shape)geometry.Perimeter.Clone();
|
|
shape.Rotate(pose.Rotation);
|
|
var box = shape.BoundingBox;
|
|
return new Box(box.Left + pose.X, box.Bottom + pose.Y, box.Length, box.Width);
|
|
}
|
|
|
|
private static void AssertClear(NestJobPart frame, NestJobPart insert, IReadOnlyList<NestJobPlacement> poses,
|
|
double spacing)
|
|
{
|
|
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
|
|
var geometry = JobPartGeometry.Read(insert.Geometry);
|
|
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
|
foreach (var pose in poses)
|
|
Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, spacing), $"{pose} meets the frame");
|
|
for (var i = 0; i < poses.Count; i++)
|
|
for (var j = i + 1; j < poses.Count; j++)
|
|
Assert.True(NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing),
|
|
$"{poses[i]} meets {poses[j]}");
|
|
}
|
|
|
|
/// <summary>Incremental (G91) G-code: rapids, lines and arcs with centers relative to the start.</summary>
|
|
private static Program Incremental(string code)
|
|
{
|
|
var program = new Program();
|
|
double x = 0, y = 0;
|
|
foreach (var line in code.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
{
|
|
var words = Regex.Matches(line, @"([A-Z])(-?\d*\.?\d+)")
|
|
.ToDictionary(m => m.Groups[1].Value[0], m => double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture));
|
|
var g = (int)words['G'];
|
|
if (g == 91)
|
|
continue;
|
|
var end = new Vector(x + words.GetValueOrDefault('X'), y + words.GetValueOrDefault('Y'));
|
|
var center = new Vector(x + words.GetValueOrDefault('I'), y + words.GetValueOrDefault('J'));
|
|
program.Codes.Add(g switch
|
|
{
|
|
0 => new RapidMove(end.X, end.Y),
|
|
1 => new LinearMove(end.X, end.Y),
|
|
2 => new ArcMove(end, center, RotationType.CW),
|
|
_ => new ArcMove(end, center, RotationType.CCW),
|
|
});
|
|
(x, y) = (end.X, end.Y);
|
|
}
|
|
return program;
|
|
}
|
|
}
|