diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutLatticeFillTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutLatticeFillTests.cs
new file mode 100644
index 0000000..3d37bec
--- /dev/null
+++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutLatticeFillTests.cs
@@ -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);
+ }
+
+ /// Places the ring 1" in from the sheet corner, moves the copies with it and runs
+ /// the production layout check over the whole sheet.
+ private static void AssertValidLayout(NestJobPart frame, NestJobPart insert, IReadOnlyList 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);
+ }
+}
diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs
new file mode 100644
index 0000000..66e119d
--- /dev/null
+++ b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs
@@ -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;
+
+///
+/// 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
+/// against the frame and the other copies.
+///
+///
+/// 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.
+///
+internal static class CutoutLatticeFill
+{
+ /// Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.
+ internal const int DefaultShiftSteps = 8;
+
+ private const double FlattenTolerance = 0.001;
+
+ /// Extra growth beyond the spacing, covering flattening and Clipper rounding.
+ private const double Margin = FlattenTolerance + 0.001;
+
+ private const int Precision = NestTolerances.ClipperPrecision;
+
+ /// Returns up to insert poses inside the cutout,
+ /// or none when no copy fits.
+ internal static IReadOnlyList 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 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();
+ var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
+ var insertGeometry = JobPartGeometry.TryRead(insert.Geometry);
+ if (frameGeometry == null || insertGeometry == null)
+ return Array.Empty();
+ ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
+ var cutout = frameGeometry.Cutouts[cutoutIndex];
+ if (!cutout.IsClosed())
+ return Array.Empty();
+
+ // 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();
+ 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();
+ var regions = new Dictionary();
+ 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();
+
+ 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();
+ }
+
+ /// 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.
+ private static List 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();
+ }
+ finally
+ {
+ BestFitCache.Invalidate(drawing);
+ }
+ }
+
+ ///
+ /// 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).
+ ///
+ 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);
+ }
+
+ /// 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.
+ private static IReadOnlyList Certify(string frameId, JobPartGeometry frame,
+ JobPartGeometry insertGeometry, List poses, double spacing, CancellationToken token)
+ {
+ var framePose = new NestJobPlacement(frameId, 0, 0, 0, 0);
+ var clear = new List();
+ 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();
+ }
+ 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));
+
+ /// More copies win; ties keep the smaller offset, then the lower i, then j.
+ 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;
+ }
+
+ /// Strictly inside an even-odd region; boundary points count as outside.
+ 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;
+ }
+}
diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md
index 5680d2c..86eda94 100644
--- a/docs/nesting-engines.md
+++ b/docs/nesting-engines.md
@@ -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