Files
OpenNest/docs/plans/2026-03-07-bestfit-pair-finding.md

30 KiB

Best-Fit Pair Finding Implementation Plan

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

Goal: Build a pair-finding engine that arranges two copies of a part in the tightest configuration, then tiles that pair across a plate.

Architecture: Strategy pattern where RotationSlideStrategy instances (parameterized by angle) generate candidate pair configurations by sliding one part against another using existing raycast collision. A PairEvaluator scores candidates by bounding area, a BestFitFilter prunes bad fits, and a TileEvaluator simulates tiling the best pairs onto a plate.

Tech Stack: .NET Framework 4.8, C# 7.3, OpenNest.Engine (class library referencing OpenNest.Core)


Important Context

Codebase Conventions

  • All angles are in radians — use Angle.ToRadians(), Angle.HalfPI, Angle.TwoPI
  • Always use var instead of explicit types
  • OpenNest.Math shadows System.Math — use System.Math fully qualified
  • Legacy .csproj — every new .cs file must be added to OpenNest.Engine.csproj <Compile> items
  • No test project exists — skip TDD steps, verify by building

Key Existing Types

  • Vector (struct, OpenNest.Geometry) — 2D point, has Rotate(), Offset(), DistanceTo(), operators
  • Box (class, OpenNest.Geometry) — AABB with Left/Right/Top/Bottom/Width/Height, Contains(), Intersects()
  • Part (class, OpenNest) — wraps Drawing + Program, has Location, Rotation, Rotate(), Offset(), Clone(), BoundingBox
  • Drawing (class, OpenNest) — has Program, Area, Name
  • Program (class, OpenNest.CNC) — G-code program, has BoundingBox(), Rotate(), Clone()
  • Plate (class, OpenNest) — has Size (Width/Height), EdgeSpacing, PartSpacing, WorkArea()
  • Shape (class, OpenNest.Geometry) — closed contour, has Intersects(Shape), Area(), ToPolygon(), OffsetEntity()
  • Polygon (class, OpenNest.Geometry) — vertex list, has FindBestRotation(), Rotate(), Offset()
  • ConvexHull.Compute(IList<Vector>) — returns closed Polygon
  • BoundingRectangleResultAngle, Width, Height, Area from rotating calipers

Key Existing Methods (in Helper)

  • Helper.GetShapes(IEnumerable<Entity>) — builds Shape list from geometry entities
  • Helper.GetPartLines(Part, PushDirection) — gets polygon edges facing a direction (uses chord tolerance 0.01)
  • Helper.DirectionalDistance(movingLines, stationaryLines, PushDirection) — raycasts to find minimum contact distance
  • Helper.OppositeDirection(PushDirection) — flips direction
  • ConvertProgram.ToGeometry(Program) — converts CNC program to geometry entities

How Existing Push/Contact Works (in FillLinear)

1. Create partA at position
2. Clone to partB, offset by bounding box dimension along axis
3. Get facing lines: movingLines = GetPartLines(partB, pushDir)
4. Get facing lines: stationaryLines = GetPartLines(partA, oppositeDir)
5. slideDistance = DirectionalDistance(movingLines, stationaryLines, pushDir)
6. copyDistance = bboxDim - slideDistance + spacing

The best-fit system adapts this: part2 is rotated, offset perpendicular to the push axis, then pushed toward part1.

Hull Edge Angles (existing pattern in NestEngine)

1. Convert part to polygon via ConvertProgram.ToGeometry → GetShapes → ToPolygonWithTolerance
2. Compute convex hull via ConvexHull.Compute(vertices)
3. Extract edge angles: atan2(dy, dx) for each hull edge
4. Deduplicate angles (within Tolerance.Epsilon)

Task 1: PairCandidate Data Class

Files:

  • Create: OpenNest.Engine/BestFit/PairCandidate.cs
  • Modify: OpenNest.Engine/OpenNest.Engine.csproj (add Compile entry)

Step 1: Create directory and file

using OpenNest.Geometry;

namespace OpenNest.Engine.BestFit
{
    public class PairCandidate
    {
        public Drawing Drawing { get; set; }
        public double Part1Rotation { get; set; }
        public double Part2Rotation { get; set; }
        public Vector Part2Offset { get; set; }
        public int StrategyType { get; set; }
        public int TestNumber { get; set; }
        public double Spacing { get; set; }
    }
}

Step 2: Add to .csproj

Add inside the <ItemGroup> that contains <Compile> entries, before </ItemGroup>:

<Compile Include="BestFit\PairCandidate.cs" />

Step 3: Build to verify

Run: msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q Expected: Build succeeded

Step 4: Commit

feat: add PairCandidate data class for best-fit pair finding

Task 2: BestFitResult Data Class

Files:

  • Create: OpenNest.Engine/BestFit/BestFitResult.cs
  • Modify: OpenNest.Engine/OpenNest.Engine.csproj

Step 1: Create file

namespace OpenNest.Engine.BestFit
{
    public class BestFitResult
    {
        public PairCandidate Candidate { get; set; }
        public double RotatedArea { get; set; }
        public double BoundingWidth { get; set; }
        public double BoundingHeight { get; set; }
        public double OptimalRotation { get; set; }
        public bool Keep { get; set; }
        public string Reason { get; set; }
        public double TrueArea { get; set; }

        public double Utilization
        {
            get { return RotatedArea > 0 ? TrueArea / RotatedArea : 0; }
        }

        public double LongestSide
        {
            get { return System.Math.Max(BoundingWidth, BoundingHeight); }
        }

        public double ShortestSide
        {
            get { return System.Math.Min(BoundingWidth, BoundingHeight); }
        }
    }

    public enum BestFitSortField
    {
        Area,
        LongestSide,
        ShortestSide,
        Type,
        OriginalSequence,
        Keep,
        WhyKeepDrop
    }
}

Step 2: Add to .csproj

<Compile Include="BestFit\BestFitResult.cs" />

Step 3: Build to verify

Run: msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q

Step 4: Commit

feat: add BestFitResult data class and BestFitSortField enum

Task 3: IBestFitStrategy Interface

Files:

  • Create: OpenNest.Engine/BestFit/IBestFitStrategy.cs
  • Modify: OpenNest.Engine/OpenNest.Engine.csproj

Step 1: Create file

using System.Collections.Generic;

namespace OpenNest.Engine.BestFit
{
    public interface IBestFitStrategy
    {
        int Type { get; }
        string Description { get; }
        List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize);
    }
}

Step 2: Add to .csproj

<Compile Include="BestFit\IBestFitStrategy.cs" />

Step 3: Build to verify

Step 4: Commit

feat: add IBestFitStrategy interface

Task 4: RotationSlideStrategy

This is the core algorithm. It generates pair candidates by:

  1. Creating part1 at origin
  2. Creating part2 with a specific rotation
  3. For each push direction (Left, Down):
    • For each perpendicular offset (stepping across the part):
      • Place part2 far away along the push axis
      • Use DirectionalDistance to find contact
      • Record position as a candidate

Files:

  • Create: OpenNest.Engine/BestFit/RotationSlideStrategy.cs
  • Modify: OpenNest.Engine/OpenNest.Engine.csproj

Step 1: Create file

using System;
using System.Collections.Generic;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;

namespace OpenNest.Engine.BestFit
{
    public class RotationSlideStrategy : IBestFitStrategy
    {
        private const double ChordTolerance = 0.01;

        public RotationSlideStrategy(double part2Rotation, int type, string description)
        {
            Part2Rotation = part2Rotation;
            Type = type;
            Description = description;
        }

        public double Part2Rotation { get; }
        public int Type { get; }
        public string Description { get; }

        public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
        {
            var candidates = new List<PairCandidate>();

            var part1 = new Part(drawing);
            var bbox1 = part1.Program.BoundingBox();
            part1.Offset(-bbox1.Location.X, -bbox1.Location.Y);
            part1.UpdateBounds();

            var part2Template = new Part(drawing);
            if (!Part2Rotation.IsEqualTo(0))
                part2Template.Rotate(Part2Rotation);
            var bbox2 = part2Template.Program.BoundingBox();
            part2Template.Offset(-bbox2.Location.X, -bbox2.Location.Y);
            part2Template.UpdateBounds();

            var testNumber = 0;

            // Slide along horizontal axis (push left toward part1)
            GenerateCandidatesForAxis(
                part1, part2Template, drawing, spacing, stepSize,
                PushDirection.Left, candidates, ref testNumber);

            // Slide along vertical axis (push down toward part1)
            GenerateCandidatesForAxis(
                part1, part2Template, drawing, spacing, stepSize,
                PushDirection.Down, candidates, ref testNumber);

            return candidates;
        }

        private void GenerateCandidatesForAxis(
            Part part1, Part part2Template, Drawing drawing,
            double spacing, double stepSize, PushDirection pushDir,
            List<PairCandidate> candidates, ref int testNumber)
        {
            var bbox1 = part1.BoundingBox;
            var bbox2 = part2Template.BoundingBox;

            // Determine perpendicular range based on push direction
            double perpMin, perpMax, pushStartOffset;
            bool isHorizontalPush = (pushDir == PushDirection.Left || pushDir == PushDirection.Right);

            if (isHorizontalPush)
            {
                // Pushing horizontally: perpendicular axis is Y
                perpMin = -(bbox2.Height + spacing);
                perpMax = bbox1.Height + bbox2.Height + spacing;
                pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
            }
            else
            {
                // Pushing vertically: perpendicular axis is X
                perpMin = -(bbox2.Width + spacing);
                perpMax = bbox1.Width + bbox2.Width + spacing;
                pushStartOffset = bbox1.Height + bbox2.Height + spacing * 2;
            }

            var part1Lines = Helper.GetOffsetPartLines(part1, spacing / 2);
            var opposite = Helper.OppositeDirection(pushDir);

            for (var offset = perpMin; offset <= perpMax; offset += stepSize)
            {
                var part2 = (Part)part2Template.Clone();

                if (isHorizontalPush)
                    part2.Offset(pushStartOffset, offset);
                else
                    part2.Offset(offset, pushStartOffset);

                var movingLines = Helper.GetOffsetPartLines(part2, spacing / 2);
                var slideDist = Helper.DirectionalDistance(movingLines, part1Lines, pushDir);

                if (slideDist >= double.MaxValue || slideDist < 0)
                    continue;

                // Move part2 to contact position
                var contactOffset = GetPushVector(pushDir, slideDist);
                var finalPosition = part2.Location + contactOffset;

                candidates.Add(new PairCandidate
                {
                    Drawing = drawing,
                    Part1Rotation = 0,
                    Part2Rotation = Part2Rotation,
                    Part2Offset = finalPosition,
                    StrategyType = Type,
                    TestNumber = testNumber++,
                    Spacing = spacing
                });
            }
        }

        private static Vector GetPushVector(PushDirection direction, double distance)
        {
            switch (direction)
            {
                case PushDirection.Left: return new Vector(-distance, 0);
                case PushDirection.Right: return new Vector(distance, 0);
                case PushDirection.Down: return new Vector(0, -distance);
                case PushDirection.Up: return new Vector(0, distance);
                default: return Vector.Zero;
            }
        }
    }
}

Step 2: Add to .csproj

<Compile Include="BestFit\RotationSlideStrategy.cs" />

Step 3: Build to verify

Run: msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q

Step 4: Commit

feat: add RotationSlideStrategy with directional push contact algorithm

Task 5: PairEvaluator

Scores each candidate by computing the combined bounding box, finding the optimal rotation (via rotating calipers on the convex hull), and checking for overlaps.

Files:

  • Create: OpenNest.Engine/BestFit/PairEvaluator.cs
  • Modify: OpenNest.Engine/OpenNest.Engine.csproj

Step 1: Create file

using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;

namespace OpenNest.Engine.BestFit
{
    public class PairEvaluator
    {
        private const double ChordTolerance = 0.01;

        public BestFitResult Evaluate(PairCandidate candidate)
        {
            var drawing = candidate.Drawing;

            // Build part1 at origin
            var part1 = new Part(drawing);
            var bbox1 = part1.Program.BoundingBox();
            part1.Offset(-bbox1.Location.X, -bbox1.Location.Y);
            part1.UpdateBounds();

            // Build part2 with rotation and offset
            var part2 = new Part(drawing);
            if (!candidate.Part2Rotation.IsEqualTo(0))
                part2.Rotate(candidate.Part2Rotation);
            var bbox2 = part2.Program.BoundingBox();
            part2.Offset(-bbox2.Location.X, -bbox2.Location.Y);
            part2.Location = candidate.Part2Offset;
            part2.UpdateBounds();

            // Check overlap via shape intersection
            var overlaps = CheckOverlap(part1, part2, candidate.Spacing);

            // Collect all polygon vertices for convex hull / optimal rotation
            var allPoints = GetPartVertices(part1);
            allPoints.AddRange(GetPartVertices(part2));

            // Find optimal bounding rectangle via rotating calipers
            double bestArea, bestWidth, bestHeight, bestRotation;

            if (allPoints.Count >= 3)
            {
                var hull = ConvexHull.Compute(allPoints);
                var result = RotatingCalipers.MinimumBoundingRectangle(hull);
                bestArea = result.Area;
                bestWidth = result.Width;
                bestHeight = result.Height;
                bestRotation = result.Angle;
            }
            else
            {
                var combinedBox = ((IEnumerable<IBoundable>)new[] { part1, part2 }).GetBoundingBox();
                bestArea = combinedBox.Area();
                bestWidth = combinedBox.Width;
                bestHeight = combinedBox.Height;
                bestRotation = 0;
            }

            var trueArea = drawing.Area * 2;

            return new BestFitResult
            {
                Candidate = candidate,
                RotatedArea = bestArea,
                BoundingWidth = bestWidth,
                BoundingHeight = bestHeight,
                OptimalRotation = bestRotation,
                TrueArea = trueArea,
                Keep = !overlaps,
                Reason = overlaps ? "Overlap detected" : "Valid"
            };
        }

        private bool CheckOverlap(Part part1, Part part2, double spacing)
        {
            var shapes1 = GetPartShapes(part1);
            var shapes2 = GetPartShapes(part2);

            for (var i = 0; i < shapes1.Count; i++)
            {
                for (var j = 0; j < shapes2.Count; j++)
                {
                    List<Vector> pts;

                    if (shapes1[i].Intersects(shapes2[j], out pts))
                        return true;
                }
            }

            return false;
        }

        private List<Shape> GetPartShapes(Part part)
        {
            var entities = ConvertProgram.ToGeometry(part.Program)
                .Where(e => e.Layer != SpecialLayers.Rapid);
            var shapes = Helper.GetShapes(entities);
            shapes.ForEach(s => s.Offset(part.Location));
            return shapes;
        }

        private List<Vector> GetPartVertices(Part part)
        {
            var entities = ConvertProgram.ToGeometry(part.Program)
                .Where(e => e.Layer != SpecialLayers.Rapid);
            var shapes = Helper.GetShapes(entities);
            var points = new List<Vector>();

            foreach (var shape in shapes)
            {
                var polygon = shape.ToPolygonWithTolerance(ChordTolerance);
                polygon.Offset(part.Location);

                foreach (var vertex in polygon.Vertices)
                    points.Add(vertex);
            }

            return points;
        }
    }
}

Step 2: Add to .csproj

<Compile Include="BestFit\PairEvaluator.cs" />

Step 3: Build to verify

Step 4: Commit

feat: add PairEvaluator with overlap detection and optimal rotation

Task 6: BestFitFilter

Files:

  • Create: OpenNest.Engine/BestFit/BestFitFilter.cs
  • Modify: OpenNest.Engine/OpenNest.Engine.csproj

Step 1: Create file

using System.Collections.Generic;

namespace OpenNest.Engine.BestFit
{
    public class BestFitFilter
    {
        public double MaxPlateWidth { get; set; }
        public double MaxPlateHeight { get; set; }
        public double MaxAspectRatio { get; set; } = 5.0;
        public double MinUtilization { get; set; } = 0.3;

        public void Apply(List<BestFitResult> results)
        {
            foreach (var result in results)
            {
                if (!result.Keep)
                    continue;

                if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight))
                {
                    result.Keep = false;
                    result.Reason = "Exceeds plate dimensions";
                    continue;
                }

                var aspect = result.LongestSide / result.ShortestSide;

                if (aspect > MaxAspectRatio)
                {
                    result.Keep = false;
                    result.Reason = string.Format("Aspect ratio {0:F1} exceeds max {1}", aspect, MaxAspectRatio);
                    continue;
                }

                if (result.Utilization < MinUtilization)
                {
                    result.Keep = false;
                    result.Reason = string.Format("Utilization {0:P0} below minimum", result.Utilization);
                    continue;
                }

                result.Reason = "Valid";
            }
        }
    }
}

Step 2: Add to .csproj

<Compile Include="BestFit\BestFitFilter.cs" />

Step 3: Build to verify

Step 4: Commit

feat: add BestFitFilter with plate size, aspect ratio, and utilization rules

Task 7: TileResult and TileEvaluator

Files:

  • Create: OpenNest.Engine/BestFit/Tiling/TileResult.cs
  • Create: OpenNest.Engine/BestFit/Tiling/TileEvaluator.cs
  • Modify: OpenNest.Engine/OpenNest.Engine.csproj

Step 1: Create TileResult.cs

using System.Collections.Generic;
using OpenNest.Geometry;

namespace OpenNest.Engine.BestFit.Tiling
{
    public class TileResult
    {
        public BestFitResult BestFit { get; set; }
        public int PairsNested { get; set; }
        public int PartsNested { get; set; }
        public int Rows { get; set; }
        public int Columns { get; set; }
        public double Utilization { get; set; }
        public List<PairPlacement> Placements { get; set; }
        public bool PairRotated { get; set; }
    }

    public class PairPlacement
    {
        public Vector Position { get; set; }
        public double PairRotation { get; set; }
    }
}

Step 2: Create TileEvaluator.cs

using System;
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;

namespace OpenNest.Engine.BestFit.Tiling
{
    public class TileEvaluator
    {
        public TileResult Evaluate(BestFitResult bestFit, Plate plate)
        {
            var plateWidth = plate.Size.Width - plate.EdgeSpacing.Left - plate.EdgeSpacing.Right;
            var plateHeight = plate.Size.Height - plate.EdgeSpacing.Top - plate.EdgeSpacing.Bottom;

            var result1 = TryTile(bestFit, plateWidth, plateHeight, false);
            var result2 = TryTile(bestFit, plateWidth, plateHeight, true);
            return result1.PartsNested >= result2.PartsNested ? result1 : result2;
        }

        private TileResult TryTile(BestFitResult bestFit, double plateWidth, double plateHeight, bool rotatePair)
        {
            var pairWidth = rotatePair ? bestFit.BoundingHeight : bestFit.BoundingWidth;
            var pairHeight = rotatePair ? bestFit.BoundingWidth : bestFit.BoundingHeight;
            var spacing = bestFit.Candidate.Spacing;

            var cols = (int)System.Math.Floor((plateWidth + spacing) / (pairWidth + spacing));
            var rows = (int)System.Math.Floor((plateHeight + spacing) / (pairHeight + spacing));
            var pairsNested = cols * rows;
            var partsNested = pairsNested * 2;

            var usedArea = partsNested * (bestFit.TrueArea / 2);
            var plateArea = plateWidth * plateHeight;

            var placements = new List<PairPlacement>();

            for (var row = 0; row < rows; row++)
            {
                for (var col = 0; col < cols; col++)
                {
                    placements.Add(new PairPlacement
                    {
                        Position = new Vector(
                            col * (pairWidth + spacing),
                            row * (pairHeight + spacing)),
                        PairRotation = rotatePair ? Angle.HalfPI : 0
                    });
                }
            }

            return new TileResult
            {
                BestFit = bestFit,
                PairsNested = pairsNested,
                PartsNested = partsNested,
                Rows = rows,
                Columns = cols,
                Utilization = plateArea > 0 ? usedArea / plateArea : 0,
                Placements = placements,
                PairRotated = rotatePair
            };
        }
    }
}

Step 3: Add to .csproj

<Compile Include="BestFit\Tiling\TileResult.cs" />
<Compile Include="BestFit\Tiling\TileEvaluator.cs" />

Step 4: Build to verify

Step 5: Commit

feat: add TileEvaluator and TileResult for pair tiling on plates

Task 8: BestFitFinder (Orchestrator)

Computes hull edge angles from the drawing, builds RotationSlideStrategy instances for each angle in {0, pi/2, pi, 3pi/2} + hull edges + hull edges + pi, runs all strategies, evaluates, filters, and sorts.

Files:

  • Create: OpenNest.Engine/BestFit/BestFitFinder.cs
  • Modify: OpenNest.Engine/OpenNest.Engine.csproj

Step 1: Create file

using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Engine.BestFit.Tiling;
using OpenNest.Geometry;
using OpenNest.Math;

namespace OpenNest.Engine.BestFit
{
    public class BestFitFinder
    {
        private readonly PairEvaluator _evaluator;
        private readonly BestFitFilter _filter;

        public BestFitFinder(double maxPlateWidth, double maxPlateHeight)
        {
            _evaluator = new PairEvaluator();
            _filter = new BestFitFilter
            {
                MaxPlateWidth = maxPlateWidth,
                MaxPlateHeight = maxPlateHeight
            };
        }

        public List<BestFitResult> FindBestFits(
            Drawing drawing,
            double spacing = 0.25,
            double stepSize = 0.25,
            BestFitSortField sortBy = BestFitSortField.Area)
        {
            var strategies = BuildStrategies(drawing);

            var allCandidates = new List<PairCandidate>();

            foreach (var strategy in strategies)
                allCandidates.AddRange(strategy.GenerateCandidates(drawing, spacing, stepSize));

            var results = allCandidates.Select(c => _evaluator.Evaluate(c)).ToList();

            _filter.Apply(results);

            results = SortResults(results, sortBy);

            for (var i = 0; i < results.Count; i++)
                results[i].Candidate.TestNumber = i;

            return results;
        }

        public List<TileResult> FindAndTile(
            Drawing drawing, Plate plate,
            double spacing = 0.25, double stepSize = 0.25, int topN = 10)
        {
            var bestFits = FindBestFits(drawing, spacing, stepSize);
            var tileEvaluator = new TileEvaluator();

            return bestFits
                .Where(r => r.Keep)
                .Take(topN)
                .Select(r => tileEvaluator.Evaluate(r, plate))
                .OrderByDescending(t => t.PartsNested)
                .ThenByDescending(t => t.Utilization)
                .ToList();
        }

        private List<IBestFitStrategy> BuildStrategies(Drawing drawing)
        {
            var angles = GetRotationAngles(drawing);
            var strategies = new List<IBestFitStrategy>();
            var type = 1;

            foreach (var angle in angles)
            {
                var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle));
                strategies.Add(new RotationSlideStrategy(angle, type++, desc));
            }

            return strategies;
        }

        private List<double> GetRotationAngles(Drawing drawing)
        {
            var angles = new List<double>
            {
                0,
                Angle.HalfPI,
                System.Math.PI,
                Angle.HalfPI * 3
            };

            // Add hull edge angles
            var hullAngles = GetHullEdgeAngles(drawing);

            foreach (var hullAngle in hullAngles)
            {
                AddUniqueAngle(angles, hullAngle);
                AddUniqueAngle(angles, Angle.NormalizeRad(hullAngle + System.Math.PI));
            }

            return angles;
        }

        private List<double> GetHullEdgeAngles(Drawing drawing)
        {
            var entities = ConvertProgram.ToGeometry(drawing.Program)
                .Where(e => e.Layer != SpecialLayers.Rapid);
            var shapes = Helper.GetShapes(entities);

            var points = new List<Vector>();

            foreach (var shape in shapes)
            {
                var polygon = shape.ToPolygonWithTolerance(0.1);
                points.AddRange(polygon.Vertices);
            }

            if (points.Count < 3)
                return new List<double>();

            var hull = ConvexHull.Compute(points);
            var vertices = hull.Vertices;
            var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count;
            var hullAngles = new List<double>();

            for (var i = 0; i < n; i++)
            {
                var next = (i + 1) % n;
                var dx = vertices[next].X - vertices[i].X;
                var dy = vertices[next].Y - vertices[i].Y;

                if (dx * dx + dy * dy < Tolerance.Epsilon)
                    continue;

                var angle = Angle.NormalizeRad(System.Math.Atan2(dy, dx));
                AddUniqueAngle(hullAngles, angle);
            }

            return hullAngles;
        }

        private static void AddUniqueAngle(List<double> angles, double angle)
        {
            angle = Angle.NormalizeRad(angle);

            foreach (var existing in angles)
            {
                if (existing.IsEqualTo(angle))
                    return;
            }

            angles.Add(angle);
        }

        private List<BestFitResult> SortResults(List<BestFitResult> results, BestFitSortField sortBy)
        {
            switch (sortBy)
            {
                case BestFitSortField.Area:
                    return results.OrderBy(r => r.RotatedArea).ToList();
                case BestFitSortField.LongestSide:
                    return results.OrderBy(r => r.LongestSide).ToList();
                case BestFitSortField.ShortestSide:
                    return results.OrderBy(r => r.ShortestSide).ToList();
                case BestFitSortField.Type:
                    return results.OrderBy(r => r.Candidate.StrategyType)
                        .ThenBy(r => r.Candidate.TestNumber).ToList();
                case BestFitSortField.OriginalSequence:
                    return results.OrderBy(r => r.Candidate.TestNumber).ToList();
                case BestFitSortField.Keep:
                    return results.OrderByDescending(r => r.Keep)
                        .ThenBy(r => r.RotatedArea).ToList();
                case BestFitSortField.WhyKeepDrop:
                    return results.OrderBy(r => r.Reason)
                        .ThenBy(r => r.RotatedArea).ToList();
                default:
                    return results;
            }
        }
    }
}

Step 2: Add to .csproj

<Compile Include="BestFit\BestFitFinder.cs" />

Step 3: Build full solution to verify all references resolve

Run: msbuild OpenNest.sln /p:Configuration=Debug /v:q

Step 4: Commit

feat: add BestFitFinder orchestrator with hull edge angle strategies

Task 9: Final Integration Build and Smoke Test

Step 1: Clean build of entire solution

Run: msbuild OpenNest.sln /t:Rebuild /p:Configuration=Debug /v:q Expected: Build succeeded, 0 errors

Step 2: Verify all new files are included

Check that all 8 new files appear in the build output by reviewing the .csproj has these entries:

<Compile Include="BestFit\PairCandidate.cs" />
<Compile Include="BestFit\BestFitResult.cs" />
<Compile Include="BestFit\IBestFitStrategy.cs" />
<Compile Include="BestFit\RotationSlideStrategy.cs" />
<Compile Include="BestFit\PairEvaluator.cs" />
<Compile Include="BestFit\BestFitFilter.cs" />
<Compile Include="BestFit\Tiling\TileResult.cs" />
<Compile Include="BestFit\Tiling\TileEvaluator.cs" />
<Compile Include="BestFit\BestFitFinder.cs" />

Step 3: Final commit

If any build fixes were needed, commit them:

fix: resolve build issues in best-fit pair finding engine