From 07f30c1236aeb514de8b9631af3a93abb90dac6d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Nov 2026 18:25:24 -0500 Subject: [PATCH] fix(engine): settle best-fit pairs sideways so they close to the part spacing The pair search slid part 2 in from offsets on a 0.25 step grid, so a pair that locks in a corner (two interlocking channels) stopped up to a step short of the tightest fit. Each resting position is now also slid sideways, both ways, and the new resting positions join the candidates. Co-Authored-By: Claude Fable 5.2 --- .../BestFit/RotationSlideStrategy.cs | 122 +++++++++++++++--- OpenNest.Tests/BestFit/BestFitSettleTests.cs | 43 ++++++ docs/geometry/directional-slides.md | 1 + 3 files changed, 149 insertions(+), 17 deletions(-) create mode 100644 OpenNest.Tests/BestFit/BestFitSettleTests.cs diff --git a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs index e3a9c9ea..ab5dfa27 100644 --- a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs +++ b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs @@ -1,10 +1,14 @@ using System.Collections.Generic; using OpenNest.Geometry; +using OpenNest.Math; namespace OpenNest.Engine.BestFit { public class RotationSlideStrategy : IBestFitStrategy { + /// Settled positions closer than this to an earlier one are duplicates. + private const double PositionResolution = 1e-4; + private readonly IDistanceComputer _distanceComputer; private static readonly (double DirX, double DirY)[] PushDirections = @@ -61,7 +65,8 @@ namespace OpenNest.Engine.BestFit offsets ); - var testNumber = 0; + // Where each slide came to rest, as an offset of the part 2 template. + var rests = new List(); for (var i = 0; i < offsets.Length; i++) { @@ -69,28 +74,111 @@ namespace OpenNest.Engine.BestFit if (slideDist >= double.MaxValue || slideDist < 0) continue; - var finalPosition = new Vector( - part2Template.Location.X + offsets[i].Dx + offsets[i].DirX * slideDist, - part2Template.Location.Y + offsets[i].Dy + offsets[i].DirY * slideDist - ); - - candidates.Add( - new PairCandidate - { - Drawing = drawing, - Part1Rotation = 0, - Part2Rotation = Part2Rotation, - Part2Offset = finalPosition, - StrategyIndex = StrategyIndex, - TestNumber = testNumber++, - Spacing = spacing, - } + rests.Add( + new SlideOffset( + offsets[i].Dx + offsets[i].DirX * slideDist, + offsets[i].Dy + offsets[i].DirY * slideDist, + offsets[i].DirX, + offsets[i].DirY + ) ); } + var testNumber = 0; + + foreach (var rest in rests) + candidates.Add(CreateCandidate(drawing, part2Template, rest, spacing, testNumber++)); + + foreach (var settled in Settle(part1Entities, part2Entities, rests)) + candidates.Add(CreateCandidate(drawing, part2Template, settled, spacing, testNumber++)); + return candidates; } + /// + /// Slides every resting position sideways, both ways, until part 2 meets part 1 again. + /// The first slide starts on the step grid, so it can stop up to a step short of a + /// corner that locks the pair tighter (two interlocking channels, say); the sideways + /// slide closes that gap exactly. Positions that cannot move, never meet part 1 again + /// or repeat an earlier one are dropped. + /// + private List Settle( + List part1Entities, + List part2Entities, + List rests + ) + { + var settled = new List(); + + if (rests.Count == 0) + return settled; + + var sideways = new SlideOffset[rests.Count * 2]; + + for (var i = 0; i < rests.Count; i++) + { + var rest = rests[i]; + // Perpendicular to the first slide: (x, y) -> (-y, x) and its opposite. + sideways[i * 2] = new SlideOffset(rest.Dx, rest.Dy, -rest.DirY, rest.DirX); + sideways[i * 2 + 1] = new SlideOffset(rest.Dx, rest.Dy, rest.DirY, -rest.DirX); + } + + var distances = _distanceComputer.ComputeDistances( + part1Entities, + part2Entities, + sideways + ); + + var seen = new HashSet<(long X, long Y)>(); + + foreach (var rest in rests) + seen.Add(PositionKey(rest.Dx, rest.Dy)); + + for (var i = 0; i < sideways.Length; i++) + { + var slideDist = distances[i]; + if (slideDist >= double.MaxValue || slideDist <= Tolerance.Epsilon) + continue; + + var dx = sideways[i].Dx + sideways[i].DirX * slideDist; + var dy = sideways[i].Dy + sideways[i].DirY * slideDist; + + if (seen.Add(PositionKey(dx, dy))) + settled.Add(new SlideOffset(dx, dy, sideways[i].DirX, sideways[i].DirY)); + } + + return settled; + } + + private static (long X, long Y) PositionKey(double dx, double dy) => + ( + (long)System.Math.Round(dx / PositionResolution), + (long)System.Math.Round(dy / PositionResolution) + ); + + private PairCandidate CreateCandidate( + Drawing drawing, + Part part2Template, + SlideOffset rest, + double spacing, + int testNumber + ) + { + return new PairCandidate + { + Drawing = drawing, + Part1Rotation = 0, + Part2Rotation = Part2Rotation, + Part2Offset = new Vector( + part2Template.Location.X + rest.Dx, + part2Template.Location.Y + rest.Dy + ), + StrategyIndex = StrategyIndex, + TestNumber = testNumber, + Spacing = spacing, + }; + } + private static SlideOffset[] BuildOffsets( Box bbox1, Box bbox2, diff --git a/OpenNest.Tests/BestFit/BestFitSettleTests.cs b/OpenNest.Tests/BestFit/BestFitSettleTests.cs new file mode 100644 index 00000000..b5fbffac --- /dev/null +++ b/OpenNest.Tests/BestFit/BestFitSettleTests.cs @@ -0,0 +1,43 @@ +using OpenNest.CNC; +using OpenNest.Engine.BestFit; + +namespace OpenNest.Tests.BestFit; + +/// +/// The pair search slides part 2 in from offsets on a step grid, so a pair that locks in a +/// corner needs a second, sideways slide to close the gap the grid leaves. +/// +public class BestFitSettleTests +{ + private const double Spacing = 0.25; + private const double LegWidth = 1.3; + + /// 10 x 3 channel: a 10 x 1 bar with a 1.3-wide, 2-tall leg at each end. + private static Drawing Channel() + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(10, 0); + program.LineTo(10, 3); + program.LineTo(10 - LegWidth, 3); + program.LineTo(10 - LegWidth, 1); + program.LineTo(LegWidth, 1); + program.LineTo(LegWidth, 3); + program.LineTo(0, 3); + program.LineTo(0, 0); + return new Drawing("channel", program); + } + + [Fact] + public void InterlockedChannels_CloseTheGapTheStepGridLeaves() + { + // Two channels facing each other, one shifted by a leg width plus the spacing, so each + // has a leg inside the other. That shift is not a multiple of the step. + var results = new BestFitFinder(60, 120).FindBestFits(Channel(), Spacing, stepSize: 0.25); + + var best = results.First(r => r.Keep); + + Assert.Equal(10 + LegWidth + Spacing, best.BoundingWidth, 3); + Assert.Equal(3 + 1 + Spacing, best.BoundingHeight, 3); + } +} diff --git a/docs/geometry/directional-slides.md b/docs/geometry/directional-slides.md index ca4d3ac3..c4155a38 100644 --- a/docs/geometry/directional-slides.md +++ b/docs/geometry/directional-slides.md @@ -14,6 +14,7 @@ Open/incomplete chains and ambiguous contacts conservatively block. Loops are re - With **Calculated distance** checked in Tools > Set Offset Increment, PlateView's arrow keys use `SelectionManager.StepSelected` → `Compactor.Step`. The step is the selection's linear-fill copy distance (`FillLinear.CopyDistance`) at the plate's part spacing, so a shape that nests into its own copy steps less than its size. That copy distance is the same in both directions along an axis. The move is not a slide: like a fixed nudge it always moves the full step, ignoring the work-area edge, clamps and other parts, because interlocked rows (staggered triangles, say) cannot slide straight past their neighbors to the next row. It can land off the plate or on a part; the layout check reports that as it does for any nudge. - Linear fill inherits the shared native-entity query unchanged. Extents fill passes complete boundary loops instead of direction-filtered fragments. - CPU best-fit batches prepare contact topology once and use all vertices plus curve/line interior and curve/curve tangency events. The old leading-half vertex filter cannot establish the next blocker after a skipped touch. +- The best-fit pair search (`RotationSlideStrategy`) starts each slide from an offset on its step grid, then slides every resting position sideways, both ways, and keeps the new resting positions as extra candidates. A pair that locks in a corner (two interlocking channels) therefore closes to the part spacing on both axes instead of stopping up to a step short. This relies on leaving an existing contact being legal. - GPU kernels retain nearest-hit reduction and return unsnapped contact witnesses. The shared CPU classifier accepts a blocking witness or replays the full query after a nonblocking witness, preserving tied/later blockers. Both batch APIs honor active buffer lengths and refresh mutated/reused segment arrays. The GPU distance adapter sends only exact cardinal directions to the axis-only slide interface; arbitrary directions and native curves use the shared CPU path. - Shift-click while cloning parts settles the copied group toward the plate quadrant. If its starting bounds overlap any placed part's bounds, it skips the coarse bounding-box pass and uses geometry directly; otherwise each axis order starts with a coarse box pass. Each candidate then alternates geometry pushes until movement is negligible (at most 20 iterations). The coarse pass remains useful for avoiding sawtooth/rung traps when the starting boxes do not overlap. A box push also stops at an obstacle whose box is beside the path but nearer than the part spacing, so the coarse pass cannot park the copy inside a neighbor's spacing. A push ignores a part the copy starts on, so a candidate can end clear of that part yet inside its spacing: a candidate that keeps the part spacing (measured on `NativeContours`, as the spacing review measures it) always beats one that does not, and a named order that ends too close falls back to the other order. When no order keeps the spacing the copy still lands too close; this is not overlap repair.