From 8628c690988b0db80049d7cdb3d0ab7044efa8d6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 8 Oct 2026 18:57:39 -0400 Subject: [PATCH] fix(ui): settle shift-click cloned parts with repeated directional pushes --- OpenNest.Engine/Fill/Compactor.cs | 57 +++++++++++++++++++++++ OpenNest.Tests/Fill/CompactorTests.cs | 66 +++++++++++++++++++++++++++ OpenNest/Actions/ActionClone.cs | 8 +--- docs/geometry/directional-slides.md | 3 +- 4 files changed, 127 insertions(+), 7 deletions(-) diff --git a/OpenNest.Engine/Fill/Compactor.cs b/OpenNest.Engine/Fill/Compactor.cs index 8593fb6..dc3b019 100644 --- a/OpenNest.Engine/Fill/Compactor.cs +++ b/OpenNest.Engine/Fill/Compactor.cs @@ -290,6 +290,63 @@ namespace OpenNest.Engine.Fill return 0; } + /// + /// 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. + /// + public static void SettlePlacement( + List 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]; + } + /// /// Repeatedly pushes parts left then down until total movement per /// iteration falls below the given threshold. diff --git a/OpenNest.Tests/Fill/CompactorTests.cs b/OpenNest.Tests/Fill/CompactorTests.cs index 1128c77..591a14e 100644 --- a/OpenNest.Tests/Fill/CompactorTests.cs +++ b/OpenNest.Tests/Fill/CompactorTests.cs @@ -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 { 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 { 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 { 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 { moving }, plate, + PushDirection.Left, PushDirection.Down, maxIterations: 0); + + Assert.Equal(start, moving.Location); + } + [Fact] public void PushBoundingBox_Left_MovesPartTowardEdge() { diff --git a/OpenNest/Actions/ActionClone.cs b/OpenNest/Actions/ActionClone.cs index baabd13..c57cec9 100644 --- a/OpenNest/Actions/ActionClone.cs +++ b/OpenNest/Actions/ActionClone.cs @@ -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(); diff --git a/docs/geometry/directional-slides.md b/docs/geometry/directional-slides.md index 551ef94..9cc63c0 100644 --- a/docs/geometry/directional-slides.md +++ b/docs/geometry/directional-slides.md @@ -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.