fix(ui): settle shift-click cloned parts with repeated directional pushes

This commit is contained in:
aj committed 2026-10-08 18:57:39 -04:00
1 parent c1c4b18f17
commit 8628c69098
4 files changed
+127 -7

No files matched your search

+57
View File
@@ -290,6 +290,63 @@ namespace OpenNest.Engine.Fill
return 0;
}
/// <summary>
/// Settles a copied placement against the plate in both axis orders, choosing
/// the group closest to the quadrant's work-area corner. A coarse box pass
/// runs only when no moving box starts inside an existing part's box.
/// </summary>
public static void SettlePlacement(
List<Part> movingParts,
Plate plate,
PushDirection horizontal,
PushDirection vertical,
int maxIterations = 20
)
{
if (movingParts.Count == 0)
return;
var workArea = plate.WorkArea();
var skipBoxes = movingParts.Any(moving =>
plate.Parts.Any(obstacle => moving.BoundingBox.Intersects(obstacle.BoundingBox))
);
var bestScore = double.MaxValue;
Vector[] best = null;
foreach (var first in new[] { horizontal, vertical })
{
var second = first == horizontal ? vertical : horizontal;
var trial = movingParts.Select(p => (Part)p.Clone()).ToList();
if (!skipBoxes)
{
PushBoundingBox(trial, plate, first);
PushBoundingBox(trial, plate, second);
}
for (var i = 0; i < maxIterations; i++)
{
var moved = Push(trial, plate, first) + Push(trial, plate, second);
if (moved < 0.01)
break;
}
var bounds = trial.GetBoundingBox();
var dx = horizontal == PushDirection.Left
? bounds.Left - workArea.Left : workArea.Right - bounds.Right;
var dy = vertical == PushDirection.Down
? bounds.Bottom - workArea.Bottom : workArea.Top - bounds.Top;
var score = dx * dx + dy * dy;
if (score < bestScore)
{
bestScore = score;
best = trial.Select(p => p.Location).ToArray();
}
}
for (var i = 0; i < movingParts.Count; i++)
movingParts[i].Location = best[i];
}
/// <summary>
/// Repeatedly pushes parts left then down until total movement per
/// iteration falls below the given threshold.
+66
View File
@@ -527,6 +527,72 @@ namespace OpenNest.Tests.Fill
Assert.True(part.BoundingBox.Bottom < 1);
}
[Fact]
public void SettlePlacement_RepeatsAfterVerticalMovementOpensHorizontalPath()
{
var plate = new Plate(100, 100);
plate.Parts.Add(MakeRectPart(20, 20, 20, 30));
var moving = MakeRectPart(60, 35, 10, 10);
Compactor.SettlePlacement(new List<Part> { moving }, plate,
PushDirection.Left, PushDirection.Down);
Assert.Equal(0, moving.BoundingBox.Left, 6);
Assert.Equal(0, moving.BoundingBox.Bottom, 6);
Assert.False(moving.Intersects(plate.Parts[0], out _));
}
[Fact]
public void SettlePlacement_MovesCopiedGroupTogether()
{
var plate = new Plate(100, 100);
var left = MakeRectPart(40, 40, 5, 5);
var right = MakeRectPart(50, 40, 5, 5);
var originalGap = right.Location.X - left.Location.X;
Compactor.SettlePlacement(new List<Part> { left, right }, plate,
PushDirection.Left, PushDirection.Down);
Assert.Equal(0, left.BoundingBox.Left, 6);
Assert.Equal(0, left.BoundingBox.Bottom, 6);
Assert.Equal(originalGap, right.Location.X - left.Location.X, 6);
}
[Fact]
public void SettlePlacement_GeometryRepeatsWhenInitialBoxPassIsSkipped()
{
var plate = new Plate(100, 100);
plate.Parts.Add(MakeRectPart(20, 20, 20, 30));
// Its bounding box covers the start, but its material is above it.
plate.Parts.Add(MakeTrianglePart(new Vector(0, 100),
new Vector(100, 100), new Vector(100, 40)));
var moving = MakeRectPart(60, 35, 10, 10);
Compactor.SettlePlacement(new List<Part> { moving }, plate,
PushDirection.Left, PushDirection.Down);
Assert.Equal(0, moving.BoundingBox.Left, 6);
Assert.Equal(0, moving.BoundingBox.Bottom, 6);
Assert.All(plate.Parts, obstacle => Assert.False(moving.Intersects(obstacle, out _)));
}
[Fact]
public void SettlePlacement_SkipsBoxPassWhenAlreadyInsideObstacleBounds()
{
// Triangle material sits below the diagonal; the free pocket at (25,25)
// is within its bounding box. A box-only slide would falsely block it.
var plate = new Plate(100, 100);
plate.Parts.Add(MakeTrianglePart(new Vector(0, 0),
new Vector(40, 0), new Vector(0, 40)));
var moving = MakeRectPart(25, 25, 5, 5);
var start = moving.Location;
Compactor.SettlePlacement(new List<Part> { moving }, plate,
PushDirection.Left, PushDirection.Down, maxIterations: 0);
Assert.Equal(start, moving.Location);
}
[Fact]
public void PushBoundingBox_Left_MovesPartTowardEdge()
{
+2 -6
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Windows.Forms;
@@ -178,11 +178,7 @@ namespace OpenNest.Actions
break;
}
Compactor.PushBoundingBox(movingParts, plateView.Plate, hDir);
Compactor.PushBoundingBox(movingParts, plateView.Plate, vDir);
Compactor.Push(movingParts, plateView.Plate, hDir);
Compactor.Push(movingParts, plateView.Plate, vDir);
Compactor.SettlePlacement(movingParts, plateView.Plate, hDir, vDir);
parts.ForEach(p => p.IsDirty = true);
plateView.Invalidate();
+2 -1
View File
@@ -15,8 +15,9 @@ Open/incomplete chains and ambiguous contacts conservatively block. This is not
- 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.
- 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.
## Regression coverage
- 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 it tries coarse horizontal/vertical and vertical/horizontal orders. Each candidate then alternates geometry pushes until movement is negligible (at most 20 iterations), and the group nearest the quadrant's work-area corner wins. The coarse pass remains useful for avoiding sawtooth/rung traps when the starting boxes do not overlap.
## Regression coverage
`SlideContactTests` exercises cardinal line, translated line, reused edge-array, arbitrary-vector, native-entity, and both CPU batch paths. Cases include winding reversal, nonzero origins, rotated hooks, holes, separating circles, positive-distance grazing followed by a blocker, full-circle arc seams, concave/straight junctions, thin rings, and circle/line interior contact.
`CompactorTests` covers the reported sequence (push left with spacing, then right/up/down), genuine zero-distance blocking, zero/nonzero-spacing later hooks, and inside-hole pushes through both direct and plate entry points. Physical spacing is measured from raw outlines rather than the inflated contours used by the solver.