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