diff --git a/docs/plans/2026-03-06-geometry-push-design.md b/docs/plans/2026-03-06-geometry-push-design.md
index fc634e1..49fa8d8 100644
--- a/docs/plans/2026-03-06-geometry-push-design.md
+++ b/docs/plans/2026-03-06-geometry-push-design.md
@@ -1,63 +1,427 @@
-# Geometry-Based Push
+# Geometry-Based Push — Implementation Plan
-## Problem
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-`PlateView.PushSelected` uses bounding boxes to determine how far to slide parts. For irregular shapes, this leaves large gaps between actual cut geometry. Parts should nestle together based on their true shape.
+**Goal:** Replace bounding-box push with polygon-based directional distance so parts nestle together based on actual cut geometry.
-## Behavior
+**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.
-When pushing a selected part in a cardinal direction:
+**Tech Stack:** .NET Framework 4.8, OpenNest.Core geometry primitives
-1. The moving part's geometry is offset outward by `Plate.PartSpacing` (the kerf buffer)
-2. That offset geometry slides until it touches the **actual cut geometry** of stationary parts (zero gap between offset and cut)
-3. The actual cut paths end up separated by exactly `PartSpacing`
-4. For plate edges: the **actual geometry** bounding box stops at the work area boundary (no offset applied to edges — same as current behavior)
+---
-## Algorithm: Analytical Polygon Directional-Distance
+## Context
-### Conversion Pipeline
+- `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`)
-```
-Part.Program → ConvertProgram.ToGeometry() → Helper.GetShapes() → Shape.ToPolygon() → Polygon.ToLines()
+---
+
+### Task 1: Move PushDirection enum to OpenNest.Core
+
+**Files:**
+- Move: `OpenNest\PushDirection.cs` → `OpenNest.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**
+
+```csharp
+namespace OpenNest
+{
+ public enum PushDirection
+ {
+ Up,
+ Down,
+ Left,
+ Right
+ }
+}
```
-For the moving part, insert an offset step after getting shapes:
+**Step 2: Delete `OpenNest\PushDirection.cs`**
+
+**Step 3: Add the new file to `OpenNest.Core.csproj`**
+
+Add `` 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**
```
-shapes → Shape.OffsetEntity(PartSpacing, OffsetSide.Left) → ToPolygon() → ToLines()
+feat: move PushDirection enum to OpenNest.Core
```
-### Directional Distance
+---
-For a given push direction, compute the minimum translation distance before any edge of the offset moving polygon contacts any edge of a stationary polygon.
+### Task 2: Add Helper.GetPartLines — convert a Part to positioned line segments
-Three cases per polygon pair:
+**Files:**
+- Modify: `OpenNest.Core\Helper.cs`
-1. **Moving vertex → stationary edge**: For each vertex of the offset polygon, cast a ray in the push direction. Find where it crosses a stationary edge. Record the distance.
-2. **Stationary vertex → moving edge**: For each vertex of the stationary polygon, cast a ray in the opposite direction. Find where it crosses an offset-moving edge. Record the distance.
-3. **Edge-edge sliding contact**: For non-parallel edge pairs, compute the translation distance along the push axis where they first intersect.
+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.
-The minimum positive distance across all cases and all polygon pairs is the push distance.
+**Step 1: Add GetPartLines to Helper.cs** (after the existing `GetShapes` methods, around line 337)
-### Early-Out
+```csharp
+public static List 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();
-Before polygon math, check bounding box overlap on the perpendicular axis. If a stationary part's bounding box doesn't overlap the moving part's bounding box on the axis perpendicular to the push direction, skip that pair.
+ foreach (var shape in shapes)
+ {
+ var polygon = shape.ToPolygon();
+ polygon.Offset(part.Location);
+ lines.AddRange(polygon.ToLines());
+ }
-## Changes
+ return lines;
+}
+```
-| File | Change |
-|------|--------|
-| `OpenNest.Core\Helper.cs` | Add `DirectionalDistance(List, List, PushDirection)` |
-| `OpenNest.Core\Helper.cs` | Add `RayEdgeDistance(Vector, Line, PushDirection)` helper |
-| `OpenNest\Controls\PlateView.cs` | Rewrite `PushSelected` to use polygon directional-distance |
+**Step 2: Add GetOffsetPartLines to Helper.cs** (immediately after)
-### What stays the same
+Same pipeline but offsets shapes before converting to polygon:
-- Plate edge distance check (actual bounding box vs work area)
-- Key bindings (X/Y/Shift combinations)
-- Existing `ClosestDistance*` methods remain (may be used elsewhere)
+```csharp
+public static List 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();
-### What changes
+ foreach (var shape in shapes)
+ {
+ var offsetEntity = shape.OffsetEntity(spacing, OffsetSide.Left) as Shape;
-- `PushSelected` converts parts to polygons instead of using bounding boxes
-- Distance calculation uses actual geometry edges instead of box edges
+ 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:
+
+```csharp
+///
+/// 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.
+///
+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**
+
+```csharp
+///
+/// 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.
+///
+public static double DirectionalDistance(List movingLines, List 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:
+
+```csharp
+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>(stationaryParts.Count);
+ var stationaryBoxes = new List(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 ``
+- 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
+```