Merge remote-tracking branch 'origin/master' into claude/avalonia-port-next-tasks-f1f6b6

This commit is contained in:
aj committed 2026-11-13 20:25:34 -05:00
commit aea5e9f617
3 files changed
+149 -17

No files matched your search

+105 -17
View File
@@ -1,10 +1,14 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.BestFit
{
public class RotationSlideStrategy : IBestFitStrategy
{
/// <summary>Settled positions closer than this to an earlier one are duplicates.</summary>
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<SlideOffset>();
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;
}
/// <summary>
/// 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.
/// </summary>
private List<SlideOffset> Settle(
List<Entity> part1Entities,
List<Entity> part2Entities,
List<SlideOffset> rests
)
{
var settled = new List<SlideOffset>();
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,
@@ -0,0 +1,43 @@
using OpenNest.CNC;
using OpenNest.Engine.BestFit;
namespace OpenNest.Tests.BestFit;
/// <summary>
/// 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.
/// </summary>
public class BestFitSettleTests
{
private const double Spacing = 0.25;
private const double LegWidth = 1.3;
/// <summary>10 x 3 channel: a 10 x 1 bar with a 1.3-wide, 2-tall leg at each end.</summary>
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);
}
}
+1
View File
@@ -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.