feat(cutouts): fill a cutout with a shifted Fill lattice

CutoutLatticeFill fills one closed cutout of a frame part with copies of one part, the
first piece of the cutouts-first pass. It runs Default Fill over the cutout's bounds
plus one part step on every side, shifts the lattice over a (2n+1)^2 grid of offsets
up to half a step each way, and at each offset keeps the copies whose reference point
lies in the part's inner-fit region of the inscribed cutout (part circumscribed and
grown by the spacing). The offset keeping the most copies wins; every returned pose
is then certified with NestLayoutCheck.Clears against the frame and the other copies.

20" ring, 10" round cutout, 1" squares, 0.25" spacing: 37 copies, against 25 for a
block sized to the inscribed rectangle and 32 for the unshifted lattice.

Not wired into any engine or pipeline: parts inside cutouts wait on containment-aware
cutting order. Fill can still return different, equally scored lattices between calls
for some parts, so identical results are not yet claimed.
This commit is contained in:
aj committed 2026-10-04 19:56:05 -04:00
1 parent 4842b80ceb
commit 15cca0efd5
3 files changed
+322

No files matched your search

@@ -0,0 +1,79 @@
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 CutoutLatticeFillTests
{
private const double Spacing = 0.25;
// A 20" ring around a 10" round cutout. At 0.25" spacing the usable circle is 9.5" across;
// the largest square inside it is 6.72", which holds a 5 x 5 grid of 1" squares.
private const int InscribedRectangleCount = 25;
private static NestJobPart Ring() => JobBuilder.Part("ring", TestShapes.Ring(20, 10), 1);
private static NestJobPart Squares(double side, int quantity) =>
JobBuilder.Part("square", TestShapes.Rectangle(side, side), quantity);
[Fact]
public void ShiftedLatticeFillsMoreOfARoundCutoutThanItsInscribedRectangle()
{
var poses = CutoutLatticeFill.Fill(Ring(), 0, Squares(1, 100), 100, Spacing);
Assert.True(poses.Count > InscribedRectangleCount, $"placed {poses.Count}");
AssertValidLayout(Ring(), Squares(1, poses.Count), poses);
}
[Fact]
public void ShiftingTheLatticeKeepsMoreCopiesThanTheUnshiftedLattice()
{
var shifted = CutoutLatticeFill.Fill(Ring(), 0, Squares(1, 100), 100, Spacing);
var unshifted = CutoutLatticeFill.Fill(Ring(), 0, Squares(1, 100), 100, Spacing, 0, default);
Assert.True(shifted.Count > unshifted.Count, $"shifted {shifted.Count}, unshifted {unshifted.Count}");
}
[Theory]
[InlineData(7.0)] // diagonal 9.90: wider than the cutout less its wall spacing
[InlineData(6.75)] // corners 4.773 from the centre; the usable radius is 4.75
public void InsertThatCannotClearTheCutoutWallIsNeverPlaced(double side)
{
Assert.Empty(CutoutLatticeFill.Fill(Ring(), 0, Squares(side, 4), 4, Spacing));
}
[Fact]
public void QuantityCapsTheCopiesReturned()
{
var poses = CutoutLatticeFill.Fill(Ring(), 0, Squares(1, 5), 5, Spacing);
Assert.Equal(5, poses.Count);
AssertValidLayout(Ring(), Squares(1, 5), poses);
}
/// <summary>Places the ring 1" in from the sheet corner, moves the copies with it and runs
/// the production layout check over the whole sheet.</summary>
private static void AssertValidLayout(NestJobPart frame, NestJobPart insert, IReadOnlyList<NestJobPlacement> poses)
{
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
var job = JobBuilder.Job(new[] { frame, insert }, new[] { stock });
var bounds = JobPartGeometry.Read(frame.Geometry).Bounds;
var x = 1 - bounds.Left;
var y = 1 - bounds.Bottom;
var placements = new[] { new NestJobPlacement(frame.Id, 0, x, y, 0) }
.Concat(poses.Select((p, i) => new NestJobPlacement(insert.Id, i, p.X + x, p.Y + y, p.Rotation)))
.ToArray();
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, placements) },
new[]
{
new PartFulfillment(frame.Id, 1, 1, 0),
new PartFulfillment(insert.Id, insert.Quantity, poses.Count, insert.Quantity - poses.Count),
},
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
}
}
@@ -0,0 +1,227 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>
/// Fills one closed cutout of a frame part with copies of one insert part. A Fill lattice is
/// built over the cutout's bounds plus one part step on every side and shifted across a grid of
/// offsets of up to half a step each way. At each offset the copies whose spacing-grown outline
/// lies inside the cutout (a point test against the insert's inner-fit region of the cutout)
/// are counted; the offset keeping the most copies wins. Every kept pose is then certified with
/// <see cref="NestLayoutCheck.Clears"/> against the frame and the other copies.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
private const double Margin = FlattenTolerance + 0.001;
private const int Precision = NestTolerances.ClipperPrecision;
/// <summary>Returns up to <paramref name="maxQuantity"/> insert poses inside the cutout,
/// or none when no copy fits.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
NestJobPart insert, int maxQuantity, double spacing, CancellationToken token = default) =>
Fill(frame, cutoutIndex, insert, maxQuantity, spacing, DefaultShiftSteps, token);
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(insert);
ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps);
if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0)
return Array.Empty<NestJobPlacement>();
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
var insertGeometry = JobPartGeometry.TryRead(insert.Geometry);
if (frameGeometry == null || insertGeometry == null)
return Array.Empty<NestJobPlacement>();
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
var cutout = frameGeometry.Cutouts[cutoutIndex];
if (!cutout.IsClosed())
return Array.Empty<NestJobPlacement>();
// Inscribed: the flattened cutout never extends past the real one.
var hole = Positive(ClipperBridge.ToPath(ClipperBridge.Flatten(cutout, FlattenTolerance, circumscribe: false),
new Vector()));
if (hole.Count < 3)
return Array.Empty<NestJobPlacement>();
var bounds = cutout.BoundingBox;
var insertBounds = insertGeometry.Bounds;
var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing;
var lattice = Lattice(insert, bounds, step, spacing, token);
if (lattice.Count == 0)
return Array.Empty<NestJobPlacement>();
var regions = new Dictionary<double, PathsD>();
foreach (var rotation in lattice.Select(p => p.Rotation).Distinct())
regions[rotation] = InnerFit(insertGeometry, rotation, hole, spacing);
var best = (Count: 0, I: 0, J: 0);
for (var i = -shiftSteps; i <= shiftSteps; i++)
for (var j = -shiftSteps; j <= shiftSteps; j++)
{
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));
if (Better(count, i, j, best))
best = (count, i, j);
}
if (best.Count == 0)
return Array.Empty<NestJobPlacement>();
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))
.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();
}
/// <summary>Default Fill over the cutout's bounds grown by one step on every side, in frame
/// coordinates. Fill places whole parts only, so the margin keeps every offset covered.</summary>
private static List<NestJobPlacement> Lattice(NestJobPart insert, Box bounds, double step, double spacing,
CancellationToken token)
{
var drawing = DrawingJobMapper.CreateDrawing(insert);
try
{
var parts = PrivatePlateFill.Run(drawing, insert.Rotation, spacing,
bounds.Length + 2 * step, bounds.Width + 2 * step, 0, token);
token.ThrowIfCancellationRequested();
var left = bounds.Left - step;
var bottom = bounds.Bottom - step;
return parts.Select(p => new NestJobPlacement(insert.Id, 0, p.Location.X + left, p.Location.Y + bottom,
System.Math.Round(Angle.NormalizeRad(p.Rotation), 10)))
.Where(p => double.IsFinite(p.X) && double.IsFinite(p.Y) && insert.Rotation.Allows(p.Rotation))
.ToList();
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return new List<NestJobPlacement>();
}
finally
{
BestFitCache.Invalidate(drawing);
}
}
/// <summary>
/// Reference points t at which the insert, rotated and grown by the spacing, lies inside the
/// 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)
{
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
foreach (var entity in entities)
entity.Rotate(rotation);
var perimeter = new ShapeProfile(entities).Perimeter;
var outline = Positive(ClipperBridge.ToPath(ClipperBridge.Flatten(perimeter, FlattenTolerance, circumscribe: true),
new Vector()));
var grown = Clipper.InflatePaths(new PathsD { outline }, spacing + Margin, JoinType.Round, EndType.Polygon,
2.0, Precision, FlattenTolerance)
.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).FirstOrDefault();
if (grown == null || grown.Count < 3)
return new PathsD();
var reflected = new PathD(grown.Select(p => new PointD(-p.x, -p.y)));
// Clipper.MinkowskiSum rounds to two decimals; pass the job precision explicitly.
var forbidden = Clipper.Union(Minkowski.Sum(reflected, hole, true, Precision),
new PathsD { Clipper.TranslatePath(reflected, hole[0].x, hole[0].y) }, FillRule.NonZero, Precision);
var anchor = grown[0];
return Clipper.Difference(new PathsD { Clipper.TranslatePath(hole, -anchor.x, -anchor.y) }, forbidden,
FillRule.NonZero, Precision);
}
/// <summary>Drops copies that fail the production clearance check against the frame; any
/// failing pair of copies rejects the whole fill, since the lattice itself is then unsound.</summary>
private static IReadOnlyList<NestJobPlacement> Certify(string frameId, JobPartGeometry frame,
JobPartGeometry insertGeometry, List<NestJobPlacement> poses, double spacing, CancellationToken token)
{
var framePose = new NestJobPlacement(frameId, 0, 0, 0, 0);
var clear = new List<NestJobPlacement>();
foreach (var pose in poses)
{
token.ThrowIfCancellationRequested();
if (NestLayoutCheck.Clears(frame, framePose, insertGeometry, pose, spacing))
clear.Add(pose);
}
// Rotation is about the reference point, so every copy's material lies within this
// distance of its pose whatever its rotation.
var b = insertGeometry.Bounds;
var reach = new[] { (b.Left, b.Bottom), (b.Right, b.Bottom), (b.Right, b.Top), (b.Left, b.Top) }
.Max(c => System.Math.Sqrt(c.Item1 * c.Item1 + c.Item2 * c.Item2));
var apart = 2 * reach + spacing;
for (var i = 0; i < clear.Count; i++)
for (var j = i + 1; j < clear.Count; j++)
{
token.ThrowIfCancellationRequested();
if (System.Math.Abs(clear[i].X - clear[j].X) > apart || System.Math.Abs(clear[i].Y - clear[j].Y) > apart)
continue;
if (!NestLayoutCheck.Clears(insertGeometry, clear[i], insertGeometry, clear[j], spacing))
return Array.Empty<NestJobPlacement>();
}
return clear;
}
private static (double Dx, double Dy) Shift(double step, int steps, int i, int j) =>
steps == 0 ? (0, 0) : (step * i / (2.0 * steps), step * j / (2.0 * steps));
/// <summary>More copies win; ties keep the smaller offset, then the lower i, then j.</summary>
private static bool Better(int count, int i, int j, (int Count, int I, int J) best)
{
if (count != best.Count)
return count > best.Count;
var distance = System.Math.Abs(i) + System.Math.Abs(j);
var bestDistance = System.Math.Abs(best.I) + System.Math.Abs(best.J);
if (distance != bestDistance)
return distance < bestDistance;
return i != best.I ? i < best.I : j < best.J;
}
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
private static bool Inside(PathsD region, double x, double y)
{
var point = new PointD(x, y);
var inside = false;
foreach (var path in region)
{
var result = Clipper.PointInPolygon(point, path, Precision);
if (result == PointInPolygonResult.IsOn)
return false;
if (result == PointInPolygonResult.IsInside)
inside = !inside;
}
return inside;
}
private static PathD Positive(PathD path)
{
if (path.Count >= 3 && !Clipper.IsPositive(path))
path.Reverse();
return path;
}
}
+16
View File
@@ -60,6 +60,22 @@ or invalid Fill proposal leaves singles and pairs available. Large enclosed-pock
pending the hole-geometry integration; containment cutting order and shop-use safety acceptance
remain separate sequencer/verification work.
## Filling cutouts (not yet in production)
Placing parts inside another part's enclosed cutout is being built as a step that runs before any
engine, so every engine benefits. Nothing calls it yet: a part inside a cutout must be cut before
the cutout's contour, and the sequencer does not enforce that order.
`CutoutLatticeFill` (`OpenNest.Engine/Jobs/Cutouts/`) fills one closed cutout with copies of one
part. It runs Default Fill over the cutout's bounds plus one part step on every side, then shifts
that lattice across a grid of offsets of up to half a step each way. At each offset it keeps the
copies whose spacing-grown outline lies inside the cutout, using the part's inner-fit region of
the inscribed, flattened cutout, and the offset keeping the most copies wins. Every returned pose
is then checked against the frame and the other copies with `NestLayoutCheck.Clears`, the test the
layout check uses. The method suits many small copies in a large cutout; a few large inserts are
meant for no-fit-polygon placement. Fill can return different, equally scored lattices on repeated
calls for some parts, so results are not yet guaranteed identical between runs.
## Renamed engines
Earlier releases shipped these as plug-ins under other names. The registry maps the old names so