From 3d6be3900ee401eaa37bdd26dae02b551864cc65 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Mar 2026 09:41:09 -0400 Subject: [PATCH] feat(engine): generalize Compactor.Push to support arbitrary angles and BB-only mode Add Vector-based overloads to SpatialQuery (ray casting, edge distance, directional gap, perpendicular overlap) and PartGeometry (directional line filtering) to support pushing parts along any angle, not just cardinal directions. Add Compactor.PushBoundingBox for fast coarse positioning using only bounding box gaps. ActionClone shift+click now uses a two-phase strategy: BB push first to skip past irregular geometry snags, then geometry push to settle against actual contours. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Geometry/SpatialQuery.cs | 163 +++++++++++++++++++++++++ OpenNest.Core/PartGeometry.cs | 67 ++++++++++ OpenNest.Engine/Compactor.cs | 147 ++++++++++++++++++++++ OpenNest/Actions/ActionClone.cs | 38 +++--- 4 files changed, 396 insertions(+), 19 deletions(-) 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));