mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-14 10:15:07 -05:00
Merge remote-tracking branch 'origin/master' into claude/avalonia-port-next-tasks-f1f6b6
This commit is contained in:
commit
aea5e9f617
3 files changed
+149
-17
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user