Files
OpenNest/docs/plans/2026-03-06-geometry-push-design.md
AJ Isaacs cc934f96aa Update design doc with full implementation plan
5 tasks: move PushDirection to Core, add GetPartLines helpers,
add DirectionalDistance algorithm, rewrite PushSelected, clean up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:20:54 -05:00

13 KiB

Geometry-Based Push — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace bounding-box push with polygon-based directional distance so parts nestle together based on actual cut geometry.

Architecture: Convert CNC programs to polygon line segments, offset the moving part's polygon by PartSpacing, then compute the exact minimum translation distance along the push axis before any edge contact. Plate edge checks remain bounding-box based.

Tech Stack: .NET Framework 4.8, OpenNest.Core geometry primitives


Context

  • PlateView.PushSelected (in OpenNest\Controls\PlateView.cs:753-839) currently uses Helper.ClosestDistance* methods that operate on Box objects
  • PushDirection enum lives in OpenNest\PushDirection.cs (UI project) — must move to Core so Helper can reference it
  • Parts convert to geometry via: ConvertProgram.ToGeometry()Helper.GetShapes()Shape.ToPolygon()Polygon.ToLines()
  • Shape.OffsetEntity(distance, OffsetSide.Left) offsets a shape outward (already implemented in OpenNest.Core\Geometry\Shape.cs:355-423)

Task 1: Move PushDirection enum to OpenNest.Core

Files:

  • Move: OpenNest\PushDirection.csOpenNest.Core\PushDirection.cs

PushDirection is currently in the UI project. Helper.cs (Core) needs to reference it. Since the enum has no UI dependencies, move it to Core. The UI project already references Core, so all existing usages continue to compile.

Step 1: Create PushDirection.cs in OpenNest.Core

namespace OpenNest
{
    public enum PushDirection
    {
        Up,
        Down,
        Left,
        Right
    }
}

Step 2: Delete OpenNest\PushDirection.cs

Step 3: Add the new file to OpenNest.Core.csproj

Add <Compile Include="PushDirection.cs" /> and remove from OpenNest.csproj.

Step 4: Build to verify

Run: msbuild OpenNest.sln /p:Configuration=Release Expected: Build succeeds — namespace is already OpenNest, no code changes needed in consumers.

Step 5: Commit

feat: move PushDirection enum to OpenNest.Core

Task 2: Add Helper.GetPartLines — convert a Part to positioned line segments

Files:

  • Modify: OpenNest.Core\Helper.cs

Add a helper that encapsulates the conversion pipeline: Program → geometry → shapes → polygons → lines, positioned at the part's world location. This is called once per part during push.

Step 1: Add GetPartLines to Helper.cs (after the existing GetShapes methods, around line 337)

public static List<Line> GetPartLines(Part part)
{
    var entities = Converters.ConvertProgram.ToGeometry(part.Program);
    var shapes = GetShapes(entities.Where(e => e.Layer != Geometry.SpecialLayers.Rapid));
    var lines = new List<Line>();

    foreach (var shape in shapes)
    {
        var polygon = shape.ToPolygon();
        polygon.Offset(part.Location);
        lines.AddRange(polygon.ToLines());
    }

    return lines;
}

Step 2: Add GetOffsetPartLines to Helper.cs (immediately after)

Same pipeline but offsets shapes before converting to polygon:

public static List<Line> GetOffsetPartLines(Part part, double spacing)
{
    var entities = Converters.ConvertProgram.ToGeometry(part.Program);
    var shapes = GetShapes(entities.Where(e => e.Layer != Geometry.SpecialLayers.Rapid));
    var lines = new List<Line>();

    foreach (var shape in shapes)
    {
        var offsetEntity = shape.OffsetEntity(spacing, OffsetSide.Left) as Shape;

        if (offsetEntity == null)
            continue;

        var polygon = offsetEntity.ToPolygon();
        polygon.Offset(part.Location);
        lines.AddRange(polygon.ToLines());
    }

    return lines;
}

Step 3: Build to verify

Run: msbuild OpenNest.sln /p:Configuration=Release Expected: Build succeeds.

Step 4: Commit

feat: add Helper.GetPartLines and GetOffsetPartLines

Task 3: Add Helper.DirectionalDistance — core algorithm

Files:

  • Modify: OpenNest.Core\Helper.cs

This is the main algorithm. For two sets of line segments and a push direction, compute the minimum translation distance before any contact.

Step 1: Add RayEdgeDistance helper

For a vertex moving along an axis, find where it hits a line segment:

/// <summary>
/// Finds the distance from a vertex to a line segment along a push axis.
/// Returns double.MaxValue if the ray does not hit the segment.
/// </summary>
private static double RayEdgeDistance(Vector vertex, Line edge, PushDirection direction)
{
    var p1 = edge.StartPoint;
    var p2 = edge.EndPoint;

    switch (direction)
    {
        case PushDirection.Left:
        {
            // Ray goes in -X direction. Edge must have a horizontal span.
            if (p1.Y.IsEqualTo(p2.Y))
                return double.MaxValue; // horizontal edge, parallel to ray

            // Find t where ray Y == edge Y at parametric t
            var t = (vertex.Y - p1.Y) / (p2.Y - p1.Y);
            if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
                return double.MaxValue;

            var ix = p1.X + t * (p2.X - p1.X);
            var dist = vertex.X - ix; // positive if edge is to the left
            return dist > Tolerance.Epsilon ? dist : double.MaxValue;
        }

        case PushDirection.Right:
        {
            if (p1.Y.IsEqualTo(p2.Y))
                return double.MaxValue;

            var t = (vertex.Y - p1.Y) / (p2.Y - p1.Y);
            if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
                return double.MaxValue;

            var ix = p1.X + t * (p2.X - p1.X);
            var dist = ix - vertex.X;
            return dist > Tolerance.Epsilon ? dist : double.MaxValue;
        }

        case PushDirection.Down:
        {
            if (p1.X.IsEqualTo(p2.X))
                return double.MaxValue;

            var t = (vertex.X - p1.X) / (p2.X - p1.X);
            if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
                return double.MaxValue;

            var iy = p1.Y + t * (p2.Y - p1.Y);
            var dist = vertex.Y - iy;
            return dist > Tolerance.Epsilon ? dist : double.MaxValue;
        }

        case PushDirection.Up:
        {
            if (p1.X.IsEqualTo(p2.X))
                return double.MaxValue;

            var t = (vertex.X - p1.X) / (p2.X - p1.X);
            if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
                return double.MaxValue;

            var iy = p1.Y + t * (p2.Y - p1.Y);
            var dist = iy - vertex.Y;
            return dist > Tolerance.Epsilon ? dist : double.MaxValue;
        }

        default:
            return double.MaxValue;
    }
}

Step 2: Add DirectionalDistance method

/// <summary>
/// Computes the minimum translation distance along a push direction before
/// any edge of movingLines contacts any edge of stationaryLines.
/// Returns double.MaxValue if no collision path exists.
/// </summary>
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, PushDirection direction)
{
    var minDist = double.MaxValue;

    // Case 1: Each moving vertex → each stationary edge
    for (int i = 0; i < movingLines.Count; i++)
    {
        var movingLine = movingLines[i];

        for (int j = 0; j < stationaryLines.Count; j++)
        {
            var d = RayEdgeDistance(movingLine.StartPoint, stationaryLines[j], direction);
            if (d < minDist) minDist = d;
        }
    }

    // Case 2: Each stationary vertex → each moving edge (opposite direction)
    var opposite = OppositeDirection(direction);

    for (int i = 0; i < stationaryLines.Count; i++)
    {
        var stationaryLine = stationaryLines[i];

        for (int j = 0; j < movingLines.Count; j++)
        {
            var d = RayEdgeDistance(stationaryLine.StartPoint, movingLines[j], opposite);
            if (d < minDist) minDist = d;
        }
    }

    return minDist;
}

private static PushDirection OppositeDirection(PushDirection direction)
{
    switch (direction)
    {
        case PushDirection.Left: return PushDirection.Right;
        case PushDirection.Right: return PushDirection.Left;
        case PushDirection.Up: return PushDirection.Down;
        case PushDirection.Down: return PushDirection.Up;
        default: return direction;
    }
}

Step 3: Build to verify

Run: msbuild OpenNest.sln /p:Configuration=Release Expected: Build succeeds.

Step 4: Commit

feat: add Helper.DirectionalDistance for polygon-based push

Task 4: Rewrite PlateView.PushSelected to use geometry-based distance

Files:

  • Modify: OpenNest\Controls\PlateView.cs:753-839

Step 1: Add using OpenNest.Converters; at the top if not already present.

Step 2: Replace the PushSelected method body

Replace the entire PushSelected method (lines 753-839) with:

public void PushSelected(PushDirection direction)
{
    // Build line segments for all stationary parts.
    var stationaryParts = parts.Where(p => !p.IsSelected && !SelectedParts.Contains(p)).ToList();
    var stationaryLines = new List<List<Line>>(stationaryParts.Count);
    var stationaryBoxes = new List<Box>(stationaryParts.Count);

    foreach (var part in stationaryParts)
    {
        stationaryLines.Add(Helper.GetPartLines(part.BasePart));
        stationaryBoxes.Add(part.BoundingBox);
    }

    var workArea = Plate.WorkArea();
    var distance = double.MaxValue;

    foreach (var selected in SelectedParts)
    {
        // Get offset lines for the moving part.
        var movingLines = Plate.PartSpacing > 0
            ? Helper.GetOffsetPartLines(selected.BasePart, Plate.PartSpacing)
            : Helper.GetPartLines(selected.BasePart);

        var movingBox = selected.BoundingBox;

        // Check geometry distance against each stationary part.
        for (int i = 0; i < stationaryLines.Count; i++)
        {
            // Early-out: skip if bounding boxes don't overlap on the perpendicular axis.
            var stBox = stationaryBoxes[i];
            bool perpOverlap;

            switch (direction)
            {
                case PushDirection.Left:
                case PushDirection.Right:
                    perpOverlap = !(movingBox.Bottom >= stBox.Top || movingBox.Top <= stBox.Bottom);
                    break;
                default: // Up, Down
                    perpOverlap = !(movingBox.Left >= stBox.Right || movingBox.Right <= stBox.Left);
                    break;
            }

            if (!perpOverlap)
                continue;

            var d = Helper.DirectionalDistance(movingLines, stationaryLines[i], direction);
            if (d < distance)
                distance = d;
        }

        // Check distance to plate edge (actual geometry bbox, not offset).
        double edgeDist;
        switch (direction)
        {
            case PushDirection.Left:
                edgeDist = selected.Left - workArea.Left;
                break;
            case PushDirection.Right:
                edgeDist = workArea.Right - selected.Right;
                break;
            case PushDirection.Up:
                edgeDist = workArea.Top - selected.Top;
                break;
            default: // Down
                edgeDist = selected.Bottom - workArea.Bottom;
                break;
        }

        if (edgeDist > 0 && edgeDist < distance)
            distance = edgeDist;
    }

    if (distance < double.MaxValue && distance > 0)
    {
        var offset = new Vector();

        switch (direction)
        {
            case PushDirection.Left:  offset.X = -distance; break;
            case PushDirection.Right: offset.X =  distance; break;
            case PushDirection.Up:    offset.Y =  distance; break;
            case PushDirection.Down:  offset.Y = -distance; break;
        }

        SelectedParts.ForEach(p => p.Offset(offset));
        Invalidate();
    }
}

Step 3: Build to verify

Run: msbuild OpenNest.sln /p:Configuration=Release Expected: Build succeeds.

Step 4: Manual test

  1. Open a nest with at least two irregular parts on a plate
  2. Select one part, press X to push left — it should slide until its offset geometry touches the other part's cut geometry
  3. Press Shift+X to push right
  4. Press Y to push down, Shift+Y to push up
  5. Verify parts nestle closer than before (no bounding box gap)
  6. Verify parts stop at plate edges correctly

Step 5: Commit

feat: rewrite PushSelected to use polygon directional-distance

Parts now push based on actual cut geometry instead of bounding boxes,
allowing irregular shapes to nestle together with PartSpacing gap.

Task 5: Clean up — remove PushDirection from OpenNest.csproj if not already done

Files:

  • Modify: OpenNest\OpenNest.csproj — remove <Compile Include="PushDirection.cs" />
  • Delete: OpenNest\PushDirection.cs (if not already deleted in Task 1)

Step 1: Verify build is clean

Run: msbuild OpenNest.sln /p:Configuration=Release Expected: Build succeeds with zero warnings related to PushDirection.

Step 2: Commit

chore: clean up PushDirection move