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) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 09:41:09 -04:00
parent 285e7082fb
commit 3d6be3900e
4 changed files with 396 additions and 19 deletions

View File

@@ -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);
}
/// <summary>
/// Pushes movingParts along an arbitrary angle (radians, 0 = right, π/2 = up).
/// </summary>
public static double Push(List<Part> movingParts, Plate plate, double angle)
{
var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p))
.ToList();
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, angle);
}
/// <summary>
/// Pushes movingParts along an arbitrary angle (radians, 0 = right, π/2 = up).
/// </summary>
public static double Push(List<Part> movingParts, List<Part> 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<Line>[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<Line> 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<Part> movingParts, List<Part> obstacleParts,
Box workArea, double partSpacing, PushDirection direction)
{
@@ -158,6 +238,73 @@ namespace OpenNest
return 0;
}
/// <summary>
/// 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.
/// </summary>
public static double PushBoundingBox(List<Part> 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<Part> movingParts, List<Part> 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;
}
/// <summary>
/// Compacts parts individually toward the bottom-left of the work area.
/// Each part is pushed against all others as obstacles, closing geometry-based gaps.