diff --git a/OpenNest.Core/Geometry/SpatialQuery.cs b/OpenNest.Core/Geometry/SpatialQuery.cs
index dca54cf..39a226b 100644
--- a/OpenNest.Core/Geometry/SpatialQuery.cs
+++ b/OpenNest.Core/Geometry/SpatialQuery.cs
@@ -71,6 +71,40 @@ namespace OpenNest.Geometry
}
}
+ ///
+ /// Generalized ray-edge distance along an arbitrary unit direction vector.
+ /// Returns double.MaxValue if the ray does not hit the segment.
+ ///
+ [System.Runtime.CompilerServices.MethodImpl(
+ System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
+ private static double RayEdgeDistance(
+ double vx, double vy,
+ double p1x, double p1y, double p2x, double p2y,
+ double dirX, double dirY)
+ {
+ var ex = p2x - p1x;
+ var ey = p2y - p1y;
+
+ var det = ex * dirY - ey * dirX;
+ if (System.Math.Abs(det) < Tolerance.Epsilon)
+ return double.MaxValue;
+
+ var dvx = p1x - vx;
+ var dvy = p1y - vy;
+
+ var t = (ex * dvy - ey * dvx) / det;
+ if (t < -Tolerance.Epsilon)
+ return double.MaxValue;
+
+ var s = (dirX * dvy - dirY * dvx) / det;
+ if (s < -Tolerance.Epsilon || s > 1.0 + Tolerance.Epsilon)
+ return double.MaxValue;
+
+ if (t > Tolerance.Epsilon) return t;
+ if (t >= -Tolerance.Epsilon) return 0;
+ return double.MaxValue;
+ }
+
///
/// Computes the minimum translation distance along a push direction before
/// any edge of movingLines contacts any edge of stationaryLines.
@@ -361,6 +395,135 @@ namespace OpenNest.Geometry
}
}
+ #region Generalized direction (Vector) overloads
+
+ ///
+ /// Computes how far a box can travel along the given unit direction
+ /// before exiting the boundary box.
+ ///
+ public static double EdgeDistance(Box box, Box boundary, Vector direction)
+ {
+ var dist = double.MaxValue;
+
+ if (direction.X < -Tolerance.Epsilon)
+ {
+ var d = (box.Left - boundary.Left) / -direction.X;
+ if (d < dist) dist = d;
+ }
+ else if (direction.X > Tolerance.Epsilon)
+ {
+ var d = (boundary.Right - box.Right) / direction.X;
+ if (d < dist) dist = d;
+ }
+
+ if (direction.Y < -Tolerance.Epsilon)
+ {
+ var d = (box.Bottom - boundary.Bottom) / -direction.Y;
+ if (d < dist) dist = d;
+ }
+ else if (direction.Y > Tolerance.Epsilon)
+ {
+ var d = (boundary.Top - box.Top) / direction.Y;
+ if (d < dist) dist = d;
+ }
+
+ return dist < 0 ? 0 : dist;
+ }
+
+ ///
+ /// Computes the directional gap between two boxes along an arbitrary unit direction.
+ /// Positive means 'to' is ahead of 'from' in the push direction.
+ ///
+ public static double DirectionalGap(Box from, Box to, Vector direction)
+ {
+ var fromMax = BoxProjectionMax(from, direction.X, direction.Y);
+ var toMin = BoxProjectionMin(to, direction.X, direction.Y);
+ return toMin - fromMax;
+ }
+
+ ///
+ /// Returns true if two boxes overlap when projected onto the axis
+ /// perpendicular to the given unit direction.
+ ///
+ public static bool PerpendicularOverlap(Box a, Box b, Vector direction)
+ {
+ var px = -direction.Y;
+ var py = direction.X;
+
+ var aMin = BoxProjectionMin(a, px, py);
+ var aMax = BoxProjectionMax(a, px, py);
+ var bMin = BoxProjectionMin(b, px, py);
+ var bMax = BoxProjectionMax(b, px, py);
+
+ return aMin <= bMax + Tolerance.Epsilon && bMin <= aMax + Tolerance.Epsilon;
+ }
+
+ ///
+ /// Computes the minimum translation distance along an arbitrary unit direction
+ /// before any edge of movingLines contacts any edge of stationaryLines.
+ ///
+ public static double DirectionalDistance(List movingLines, List stationaryLines, Vector direction)
+ {
+ var minDist = double.MaxValue;
+ var dirX = direction.X;
+ var dirY = direction.Y;
+
+ var movingVertices = new HashSet();
+ for (var i = 0; i < movingLines.Count; i++)
+ {
+ movingVertices.Add(movingLines[i].pt1);
+ movingVertices.Add(movingLines[i].pt2);
+ }
+
+ foreach (var mv in movingVertices)
+ {
+ for (var i = 0; i < stationaryLines.Count; i++)
+ {
+ var e = stationaryLines[i];
+ var d = RayEdgeDistance(mv.X, mv.Y, e.pt1.X, e.pt1.Y, e.pt2.X, e.pt2.Y, dirX, dirY);
+ if (d < minDist) minDist = d;
+ }
+ }
+
+ var oppX = -dirX;
+ var oppY = -dirY;
+
+ var stationaryVertices = new HashSet();
+ for (var i = 0; i < stationaryLines.Count; i++)
+ {
+ stationaryVertices.Add(stationaryLines[i].pt1);
+ stationaryVertices.Add(stationaryLines[i].pt2);
+ }
+
+ foreach (var sv in stationaryVertices)
+ {
+ for (var i = 0; i < movingLines.Count; i++)
+ {
+ var e = movingLines[i];
+ var d = RayEdgeDistance(sv.X, sv.Y, e.pt1.X, e.pt1.Y, e.pt2.X, e.pt2.Y, oppX, oppY);
+ if (d < minDist) minDist = d;
+ }
+ }
+
+ return minDist;
+ }
+
+ private static double BoxProjectionMin(Box box, double dx, double dy)
+ {
+ var x = dx >= 0 ? box.Left : box.Right;
+ var y = dy >= 0 ? box.Bottom : box.Top;
+ return x * dx + y * dy;
+ }
+
+ private static double BoxProjectionMax(Box box, double dx, double dy)
+ {
+ var x = dx >= 0 ? box.Right : box.Left;
+ var y = dy >= 0 ? box.Top : box.Bottom;
+ return x * dx + y * dy;
+ }
+
+ #endregion
+
public static double ClosestDistanceLeft(Box box, List boxes)
{
var closestDistance = double.MaxValue;
diff --git a/OpenNest.Core/PartGeometry.cs b/OpenNest.Core/PartGeometry.cs
index be5c3d7..3b9234f 100644
--- a/OpenNest.Core/PartGeometry.cs
+++ b/OpenNest.Core/PartGeometry.cs
@@ -85,6 +85,73 @@ namespace OpenNest
return lines;
}
+ public static List GetPartLines(Part part, Vector facingDirection, double chordTolerance = 0.001)
+ {
+ var entities = ConvertProgram.ToGeometry(part.Program);
+ var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
+ var lines = new List();
+
+ foreach (var shape in shapes)
+ {
+ var polygon = shape.ToPolygonWithTolerance(chordTolerance);
+ polygon.Offset(part.Location);
+ lines.AddRange(GetDirectionalLines(polygon, facingDirection));
+ }
+
+ return lines;
+ }
+
+ public static List GetOffsetPartLines(Part part, double spacing, Vector facingDirection, double chordTolerance = 0.001)
+ {
+ var entities = ConvertProgram.ToGeometry(part.Program);
+ var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
+ var lines = new List();
+
+ foreach (var shape in shapes)
+ {
+ var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
+
+ if (offsetEntity == null)
+ continue;
+
+ var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
+ polygon.RemoveSelfIntersections();
+ polygon.Offset(part.Location);
+ lines.AddRange(GetDirectionalLines(polygon, facingDirection));
+ }
+
+ return lines;
+ }
+
+ ///
+ /// Returns only polygon edges whose outward normal faces the specified direction vector.
+ ///
+ private static List GetDirectionalLines(Polygon polygon, Vector direction)
+ {
+ if (polygon.Vertices.Count < 3)
+ return polygon.ToLines();
+
+ var sign = polygon.RotationDirection() == RotationType.CCW ? 1.0 : -1.0;
+ var lines = new List();
+ var last = polygon.Vertices[0];
+
+ for (var i = 1; i < polygon.Vertices.Count; i++)
+ {
+ var current = polygon.Vertices[i];
+ var edx = current.X - last.X;
+ var edy = current.Y - last.Y;
+
+ var keep = sign * (edy * direction.X - edx * direction.Y) > 0;
+
+ if (keep)
+ lines.Add(new Line(last, current));
+
+ last = current;
+ }
+
+ return lines;
+ }
+
///
/// Returns only polygon edges whose outward normal faces the specified direction.
///
diff --git a/OpenNest.Engine/Compactor.cs b/OpenNest.Engine/Compactor.cs
index 747e20f..90dd994 100644
--- a/OpenNest.Engine/Compactor.cs
+++ b/OpenNest.Engine/Compactor.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
@@ -84,6 +85,85 @@ namespace OpenNest
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
}
+ ///
+ /// Pushes movingParts along an arbitrary angle (radians, 0 = right, π/2 = up).
+ ///
+ public static double Push(List movingParts, Plate plate, double angle)
+ {
+ var obstacleParts = plate.Parts
+ .Where(p => !movingParts.Contains(p))
+ .ToList();
+
+ return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, angle);
+ }
+
+ ///
+ /// Pushes movingParts along an arbitrary angle (radians, 0 = right, π/2 = up).
+ ///
+ public static double Push(List movingParts, List obstacleParts,
+ Box workArea, double partSpacing, double angle)
+ {
+ var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
+ var opposite = -direction;
+
+ var obstacleBoxes = new Box[obstacleParts.Count];
+ var obstacleLines = new List[obstacleParts.Count];
+
+ for (var i = 0; i < obstacleParts.Count; i++)
+ obstacleBoxes[i] = obstacleParts[i].BoundingBox;
+
+ var halfSpacing = partSpacing / 2;
+ var distance = double.MaxValue;
+
+ foreach (var moving in movingParts)
+ {
+ var edgeDist = SpatialQuery.EdgeDistance(moving.BoundingBox, workArea, direction);
+ if (edgeDist <= 0)
+ distance = 0;
+ else if (edgeDist < distance)
+ distance = edgeDist;
+
+ var movingBox = moving.BoundingBox;
+ List movingLines = null;
+
+ for (var i = 0; i < obstacleBoxes.Length; i++)
+ {
+ var reverseGap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], opposite);
+ if (reverseGap > 0)
+ continue;
+
+ var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
+ if (gap >= distance)
+ continue;
+
+ if (!SpatialQuery.PerpendicularOverlap(movingBox, obstacleBoxes[i], direction))
+ continue;
+
+ movingLines ??= halfSpacing > 0
+ ? PartGeometry.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
+ : PartGeometry.GetPartLines(moving, direction, ChordTolerance);
+
+ obstacleLines[i] ??= halfSpacing > 0
+ ? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance)
+ : PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance);
+
+ var d = SpatialQuery.DirectionalDistance(movingLines, obstacleLines[i], direction);
+ if (d < distance)
+ distance = d;
+ }
+ }
+
+ if (distance < double.MaxValue && distance > 0)
+ {
+ var offset = direction * distance;
+ foreach (var moving in movingParts)
+ moving.Offset(offset);
+ return distance;
+ }
+
+ return 0;
+ }
+
public static double Push(List movingParts, List obstacleParts,
Box workArea, double partSpacing, PushDirection direction)
{
@@ -158,6 +238,73 @@ namespace OpenNest
return 0;
}
+ ///
+ /// Pushes movingParts using bounding-box distances only (no geometry lines).
+ /// Much faster but less precise — use as a coarse positioning pass before
+ /// a full geometry Push.
+ ///
+ public static double PushBoundingBox(List movingParts, Plate plate, PushDirection direction)
+ {
+ var obstacleParts = plate.Parts
+ .Where(p => !movingParts.Contains(p))
+ .ToList();
+
+ return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
+ }
+
+ public static double PushBoundingBox(List movingParts, List obstacleParts,
+ Box workArea, double partSpacing, PushDirection direction)
+ {
+ var obstacleBoxes = new Box[obstacleParts.Count];
+ for (var i = 0; i < obstacleParts.Count; i++)
+ obstacleBoxes[i] = obstacleParts[i].BoundingBox;
+
+ var opposite = SpatialQuery.OppositeDirection(direction);
+ var isHorizontal = SpatialQuery.IsHorizontalDirection(direction);
+ var distance = double.MaxValue;
+
+ foreach (var moving in movingParts)
+ {
+ var edgeDist = SpatialQuery.EdgeDistance(moving.BoundingBox, workArea, direction);
+ if (edgeDist <= 0)
+ distance = 0;
+ else if (edgeDist < distance)
+ distance = edgeDist;
+
+ var movingBox = moving.BoundingBox;
+
+ for (var i = 0; i < obstacleBoxes.Length; i++)
+ {
+ var reverseGap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], opposite);
+ if (reverseGap > 0)
+ continue;
+
+ var perpOverlap = isHorizontal
+ ? movingBox.IsHorizontalTo(obstacleBoxes[i], out _)
+ : movingBox.IsVerticalTo(obstacleBoxes[i], out _);
+
+ if (!perpOverlap)
+ continue;
+
+ var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
+ var d = gap - partSpacing;
+ if (d < 0) d = 0;
+ if (d < distance)
+ distance = d;
+ }
+ }
+
+ if (distance < double.MaxValue && distance > 0)
+ {
+ var offset = SpatialQuery.DirectionToOffset(direction, distance);
+ foreach (var moving in movingParts)
+ moving.Offset(offset);
+ return distance;
+ }
+
+ return 0;
+ }
+
///
/// Compacts parts individually toward the bottom-left of the work area.
/// Each part is pushed against all others as obstacles, closing geometry-based gaps.
diff --git a/OpenNest/Actions/ActionClone.cs b/OpenNest/Actions/ActionClone.cs
index 793c6eb..669ccf5 100644
--- a/OpenNest/Actions/ActionClone.cs
+++ b/OpenNest/Actions/ActionClone.cs
@@ -141,28 +141,28 @@ namespace OpenNest.Actions
{
if ((Control.ModifierKeys & Keys.Shift) == Keys.Shift)
{
+ var movingParts = parts.Select(p => p.BasePart).ToList();
+
+ PushDirection hDir, vDir;
switch (plateView.Plate.Quadrant)
{
- case 1:
- plateView.PushSelected(PushDirection.Left);
- plateView.PushSelected(PushDirection.Down);
- break;
-
- case 2:
- plateView.PushSelected(PushDirection.Right);
- plateView.PushSelected(PushDirection.Down);
- break;
-
- case 3:
- plateView.PushSelected(PushDirection.Right);
- plateView.PushSelected(PushDirection.Up);
- break;
-
- case 4:
- plateView.PushSelected(PushDirection.Left);
- plateView.PushSelected(PushDirection.Up);
- break;
+ case 1: hDir = PushDirection.Left; vDir = PushDirection.Down; break;
+ case 2: hDir = PushDirection.Right; vDir = PushDirection.Down; break;
+ case 3: hDir = PushDirection.Right; vDir = PushDirection.Up; break;
+ case 4: hDir = PushDirection.Left; vDir = PushDirection.Up; break;
+ default: hDir = PushDirection.Left; vDir = PushDirection.Down; break;
}
+
+ // Phase 1: BB-only push to get past irregular geometry quickly.
+ Compactor.PushBoundingBox(movingParts, plateView.Plate, hDir);
+ Compactor.PushBoundingBox(movingParts, plateView.Plate, vDir);
+
+ // Phase 2: Geometry push to settle against actual contours.
+ Compactor.Push(movingParts, plateView.Plate, hDir);
+ Compactor.Push(movingParts, plateView.Plate, vDir);
+
+ parts.ForEach(p => p.IsDirty = true);
+ plateView.Invalidate();
}
parts.ForEach(p => plateView.Plate.Parts.Add(p.BasePart.Clone() as Part));