mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-14 10:11:57 -04:00
feat(cutouts): propose certified single inserts with inner-fit geometry
This commit is contained in:
1 parent
60fb009329
commit
575412a464
3 files changed
+244
-4
No files matched your search
@@ -0,0 +1,154 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Cutouts;
|
||||
using OpenNest.Engine.Tests.NestingEngines;
|
||||
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||
|
||||
public class CutoutNfpProposalTests
|
||||
{
|
||||
private const double Spacing = 0.25;
|
||||
|
||||
[Fact]
|
||||
public void LargeInsertFitsRoundCutoutAndRemainsValidAfterFrameRotation()
|
||||
{
|
||||
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1, RotationPolicy.Fixed(System.Math.PI / 2));
|
||||
var insert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(0));
|
||||
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
|
||||
Assert.NotNull(pose);
|
||||
Assert.Equal(insert.Id, pose.PartId);
|
||||
Assert.Equal(0, pose.InstanceIndex);
|
||||
Assert.True(insert.Rotation.Allows(pose.Rotation));
|
||||
AssertSquareInRoundHole(pose, 6, Spacing);
|
||||
|
||||
var localFrame = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
||||
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), localFrame,
|
||||
JobPartGeometry.Read(insert.Geometry), pose, Spacing));
|
||||
var framePose = new NestJobPlacement(frame.Id, 0, 11, 11, System.Math.PI / 2);
|
||||
var movedInsert = pose with
|
||||
{
|
||||
X = framePose.X - pose.Y,
|
||||
Y = framePose.Y + pose.X,
|
||||
Rotation = pose.Rotation + framePose.Rotation,
|
||||
};
|
||||
// This insert is fixed at zero for the local proposal; the rotated variant needs
|
||||
// a correspondingly legal global policy before it can be handed to a job.
|
||||
var rotatedInsert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(System.Math.PI / 2));
|
||||
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), framePose,
|
||||
JobPartGeometry.Read(insert.Geometry), movedInsert, Spacing));
|
||||
AssertSquareInRoundHole(movedInsert, 6, Spacing, framePose.X, framePose.Y);
|
||||
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
|
||||
var job = JobBuilder.Job(new[] { frame, rotatedInsert }, new[] { stock });
|
||||
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||
new[] { new NestJobPlateResult(0, stock, new[] { framePose, movedInsert }) },
|
||||
new[] { new PartFulfillment(frame.Id, 1, 1, 0), new PartFulfillment(insert.Id, 1, 1, 0) },
|
||||
new[] { new StockUsage(stock.Id, 1, null) });
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeInsertFitsSquareCutoutWithoutUsingFillLattice()
|
||||
{
|
||||
var program = TestShapes.Rectangle(20, 20);
|
||||
program.MoveTo(5, 5);
|
||||
program.LineTo(5, 15);
|
||||
program.LineTo(15, 15);
|
||||
program.LineTo(15, 5);
|
||||
program.LineTo(5, 5);
|
||||
var frame = JobBuilder.Part("square-frame", program, 1);
|
||||
var insert = JobBuilder.Rectangle("large", 9, 9, 1, RotationPolicy.Fixed(0));
|
||||
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
|
||||
Assert.NotNull(pose);
|
||||
Assert.All(new[] { (0.0, 0.0), (9.0, 0.0), (9.0, 9.0), (0.0, 9.0) }, corner =>
|
||||
{
|
||||
Assert.InRange(pose.X + corner.Item1, 5 + Spacing, 15 - Spacing);
|
||||
Assert.InRange(pose.Y + corner.Item2, 5 + Spacing, 15 - Spacing);
|
||||
});
|
||||
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry),
|
||||
new NestJobPlacement(frame.Id, 0, 0, 0, 0), JobPartGeometry.Read(insert.Geometry), pose, Spacing));
|
||||
Assert.Null(CutoutNfpProposal.Find(frame, 0, JobBuilder.Rectangle("oversized", 9.6, 9.6, 1),
|
||||
Spacing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObliqueFixedInsertRotationIsUsedInInnerFit()
|
||||
{
|
||||
var program = TestShapes.Rectangle(20, 20);
|
||||
program.MoveTo(5, 5);
|
||||
program.LineTo(5, 15);
|
||||
program.LineTo(15, 15);
|
||||
program.LineTo(15, 5);
|
||||
program.LineTo(5, 5);
|
||||
var frame = JobBuilder.Part("frame", program, 1);
|
||||
var insert = JobBuilder.Rectangle("insert", 3, 2, 1, RotationPolicy.Fixed(System.Math.PI / 4));
|
||||
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
|
||||
Assert.NotNull(pose);
|
||||
Assert.True(insert.Rotation.Allows(pose.Rotation));
|
||||
foreach (var (x, y) in new[] { (0.0, 0.0), (3.0, 0.0), (3.0, 2.0), (0.0, 2.0) })
|
||||
{
|
||||
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
|
||||
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
|
||||
Assert.InRange(rx, 5 + Spacing, 15 - Spacing);
|
||||
Assert.InRange(ry, 5 + Spacing, 15 - Spacing);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(9.51)]
|
||||
[InlineData(10.0)]
|
||||
[InlineData(9.6)]
|
||||
public void OversizedInsertIsNeverProposed(double side)
|
||||
{
|
||||
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||
var insert = JobBuilder.Rectangle("insert", side, side, 1);
|
||||
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, Spacing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsupportedCoordinateScaleFailsClosed()
|
||||
{
|
||||
const double scale = 1e15;
|
||||
var program = TestShapes.Polyline((-scale, -scale), (scale, -scale),
|
||||
(scale, scale), (-scale, scale));
|
||||
program.MoveTo(-scale / 2, -scale / 2);
|
||||
program.LineTo(-scale / 2, scale / 2);
|
||||
program.LineTo(scale / 2, scale / 2);
|
||||
program.LineTo(scale / 2, -scale / 2);
|
||||
program.LineTo(-scale / 2, -scale / 2);
|
||||
Assert.Null(CutoutNfpProposal.Find(JobBuilder.Part("huge", program, 1), 0,
|
||||
JobBuilder.Rectangle("insert", 6, 6, 1), Spacing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImpossibleHugeSpacingFailsBeforeAnyOffset()
|
||||
{
|
||||
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
|
||||
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, 1e12));
|
||||
}
|
||||
|
||||
private static void AssertSquareInRoundHole(NestJobPlacement pose, double side, double spacing,
|
||||
double centerX = 0, double centerY = 0)
|
||||
{
|
||||
var sin = System.Math.Sin(pose.Rotation);
|
||||
var cos = System.Math.Cos(pose.Rotation);
|
||||
foreach (var (x, y) in new[] { (0.0, 0.0), (side, 0.0), (side, side), (0.0, side) })
|
||||
{
|
||||
var dx = pose.X + x * cos - y * sin - centerX;
|
||||
var dy = pose.Y + x * sin + y * cos - centerY;
|
||||
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) <= 5 - spacing + 0.00001,
|
||||
$"corner ({dx}, {dy}) does not clear the circular wall");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationIsNotReportedAsNoFit()
|
||||
{
|
||||
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
cancelled.Cancel();
|
||||
Assert.Throws<OperationCanceledException>(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing,
|
||||
cancelled.Token));
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ internal static class CutoutLatticeFill
|
||||
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
|
||||
internal const int DefaultShiftSteps = 8;
|
||||
|
||||
private const double FlattenTolerance = 0.001;
|
||||
internal const double FlattenTolerance = 0.001;
|
||||
|
||||
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
|
||||
private const double Margin = FlattenTolerance + 0.001;
|
||||
@@ -134,7 +134,7 @@ internal static class CutoutLatticeFill
|
||||
/// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline
|
||||
/// never meets the cutout boundary (t outside boundary + reflected outline).
|
||||
/// </summary>
|
||||
private static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
|
||||
internal static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
|
||||
{
|
||||
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
|
||||
foreach (var entity in entities)
|
||||
@@ -203,7 +203,7 @@ internal static class CutoutLatticeFill
|
||||
}
|
||||
|
||||
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
|
||||
private static bool Inside(PathsD region, double x, double y)
|
||||
internal static bool Inside(PathsD region, double x, double y)
|
||||
{
|
||||
var point = new PointD(x, y);
|
||||
var inside = false;
|
||||
@@ -218,7 +218,7 @@ internal static class CutoutLatticeFill
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static PathD Positive(PathD path)
|
||||
internal static PathD Positive(PathD path)
|
||||
{
|
||||
if (path.Count >= 3 && !Clipper.IsPositive(path))
|
||||
path.Reverse();
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
|
||||
/// <summary>Test-only single-insert inner-fit proposal; never changes a job or a live plate.</summary>
|
||||
internal static class CutoutNfpProposal
|
||||
{
|
||||
// Fixed chord tolerance in the shared round offset otherwise makes extremely large
|
||||
// spacing values allocate an unbounded number of arc vertices. This conservative
|
||||
// test-only kernel declines scales it cannot process safely.
|
||||
private const double MaxCoordinate = 1e8;
|
||||
private const double MaxSpacing = 100;
|
||||
|
||||
/// <summary>Returns one frame-local pose inside a closed cutout, or null if no pose is certified.
|
||||
/// A null result is not a proof that the insert cannot fit elsewhere in this cutout.</summary>
|
||||
internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert,
|
||||
double spacing, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
ArgumentNullException.ThrowIfNull(insert);
|
||||
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))
|
||||
return pose;
|
||||
}
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool WithinRange(Box box) =>
|
||||
double.IsFinite(box.Left) && double.IsFinite(box.Right)
|
||||
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
|
||||
&& System.Math.Abs(box.Left) <= MaxCoordinate && System.Math.Abs(box.Right) <= MaxCoordinate
|
||||
&& System.Math.Abs(box.Bottom) <= MaxCoordinate && System.Math.Abs(box.Top) <= MaxCoordinate;
|
||||
|
||||
/// <summary>Sample component interiors, then points just inside each boundary toward
|
||||
/// its component's center. This bounded search is not a complete inner-fit solver.</summary>
|
||||
private static IEnumerable<(double X, double Y)> Candidates(PathsD region)
|
||||
{
|
||||
foreach (var path in region.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
|
||||
{
|
||||
var bounds = Clipper.GetBounds(new PathsD { path });
|
||||
var cx = (bounds.left + bounds.right) / 2;
|
||||
var cy = (bounds.top + bounds.bottom) / 2;
|
||||
yield return (cx, cy);
|
||||
foreach (var point in path)
|
||||
yield return (point.x * 0.99 + cx * 0.01, point.y * 0.99 + cy * 0.01);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user