feat: add polylabel algorithm for part label positioning and README

Use pole-of-inaccessibility (polylabel) to place part labels at the
visual center of shapes instead of the first path vertex. Labels now
stay correctly positioned regardless of part rotation or shape.

Also adds project README and MIT license.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 08:52:48 -04:00
parent a0865405e2
commit 224fbde19a
4 changed files with 363 additions and 3 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 AJ Isaacs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
namespace OpenNest.Geometry
{
/// <summary>
/// Finds the pole of inaccessibility — the point inside a polygon that is
/// farthest from any edge. Based on the polylabel algorithm by Mapbox.
/// </summary>
public static class PolyLabel
{
public static Vector Find(List<Vector> vertices, double precision = 1.0)
{
if (vertices == null || vertices.Count < 3)
return Vector.Zero;
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
for (var i = 0; i < vertices.Count; i++)
{
var v = vertices[i];
if (v.X < minX) minX = v.X;
if (v.Y < minY) minY = v.Y;
if (v.X > maxX) maxX = v.X;
if (v.Y > maxY) maxY = v.Y;
}
var width = maxX - minX;
var height = maxY - minY;
var cellSize = System.Math.Min(width, height);
if (cellSize == 0)
return new Vector(minX, minY);
var halfCell = cellSize / 2.0;
// Priority queue (sorted list, largest distance first)
var queue = new List<Cell>();
for (var x = minX; x < maxX; x += cellSize)
{
for (var y = minY; y < maxY; y += cellSize)
{
queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, vertices));
}
}
queue.Sort((a, b) => b.Max.CompareTo(a.Max));
var bestCell = GetCentroidCell(vertices);
var bboxCell = new Cell(minX + width / 2, minY + height / 2, 0, vertices);
if (bboxCell.Distance > bestCell.Distance)
bestCell = bboxCell;
while (queue.Count > 0)
{
var cell = queue[queue.Count - 1];
queue.RemoveAt(queue.Count - 1);
if (cell.Distance > bestCell.Distance)
bestCell = cell;
if (cell.Max - bestCell.Distance <= precision)
continue;
halfCell = cell.HalfSize / 2;
var c1 = new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, vertices);
var c2 = new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, vertices);
var c3 = new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, vertices);
var c4 = new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, vertices);
InsertSorted(queue, c1);
InsertSorted(queue, c2);
InsertSorted(queue, c3);
InsertSorted(queue, c4);
}
return new Vector(bestCell.X, bestCell.Y);
}
private static void InsertSorted(List<Cell> queue, Cell cell)
{
var index = queue.BinarySearch(cell, CellComparer.Instance);
if (index < 0) index = ~index;
queue.Insert(index, cell);
}
private static Cell GetCentroidCell(List<Vector> vertices)
{
var area = 0.0;
var cx = 0.0;
var cy = 0.0;
var n = vertices.Count;
for (int i = 0, j = n - 1; i < n; j = i++)
{
var a = vertices[i];
var b = vertices[j];
var f = a.X * b.Y - b.X * a.Y;
cx += (a.X + b.X) * f;
cy += (a.Y + b.Y) * f;
area += f * 3;
}
if (area == 0)
return new Cell(vertices[0].X, vertices[0].Y, 0, vertices);
return new Cell(cx / area, cy / area, 0, vertices);
}
private static double PointToPolygonDistance(double x, double y, List<Vector> vertices)
{
var inside = false;
var minDistSq = double.MaxValue;
var n = vertices.Count;
for (int i = 0, j = n - 1; i < n; j = i++)
{
var a = vertices[i];
var b = vertices[j];
if ((a.Y > y) != (b.Y > y) &&
x < (b.X - a.X) * (y - a.Y) / (b.Y - a.Y) + a.X)
{
inside = !inside;
}
var distSq = SegmentDistanceSq(x, y, a.X, a.Y, b.X, b.Y);
if (distSq < minDistSq)
minDistSq = distSq;
}
var dist = System.Math.Sqrt(minDistSq);
return inside ? dist : -dist;
}
private static double SegmentDistanceSq(double px, double py,
double ax, double ay, double bx, double by)
{
var dx = bx - ax;
var dy = by - ay;
if (dx != 0 || dy != 0)
{
var t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy);
if (t > 1)
{
ax = bx;
ay = by;
}
else if (t > 0)
{
ax += dx * t;
ay += dy * t;
}
}
dx = px - ax;
dy = py - ay;
return dx * dx + dy * dy;
}
private struct Cell
{
public readonly double X;
public readonly double Y;
public readonly double HalfSize;
public readonly double Distance;
public readonly double Max;
public Cell(double x, double y, double halfSize, List<Vector> vertices)
{
X = x;
Y = y;
HalfSize = halfSize;
Distance = PointToPolygonDistance(x, y, vertices);
Max = Distance + halfSize * System.Math.Sqrt(2);
}
}
private class CellComparer : IComparer<Cell>
{
public static readonly CellComparer Instance = new CellComparer();
public int Compare(Cell a, Cell b) => b.Max.CompareTo(a.Max);
}
}
}

View File

@@ -20,6 +20,8 @@ namespace OpenNest
private Brush brush;
private Pen pen;
private PointF _labelPoint;
private List<PointF[]> _offsetPolygonPoints;
private double _cachedOffsetSpacing;
private double _cachedOffsetTolerance;
@@ -95,9 +97,7 @@ namespace OpenNest
g.DrawPath(pen, Path);
}
var pt = Path.PointCount > 0 ? Path.PathPoints[0] : PointF.Empty;
g.DrawString(id, programIdFont, Brushes.Black, pt.X, pt.Y);
g.DrawString(id, programIdFont, Brushes.Black, _labelPoint.X, _labelPoint.Y);
}
public GraphicsPath OffsetPath { get; private set; }
@@ -106,9 +106,52 @@ namespace OpenNest
{
Path = GraphicsHelper.GetGraphicsPath(BasePart.Program, BasePart.Location);
Path.Transform(plateView.Matrix);
_labelPoint = ComputeLabelPoint();
IsDirty = false;
}
private PointF ComputeLabelPoint()
{
if (Path.PointCount == 0)
return PointF.Empty;
var points = Path.PathPoints;
var types = Path.PathTypes;
// Extract the largest figure from the path for polylabel.
var bestFigure = new List<Vector>();
var currentFigure = new List<Vector>();
for (var i = 0; i < points.Length; i++)
{
if ((types[i] & 0x01) == 0 && currentFigure.Count > 0)
{
// New figure starting — save previous if it's the largest so far.
if (currentFigure.Count > bestFigure.Count)
bestFigure = currentFigure;
currentFigure = new List<Vector>();
}
currentFigure.Add(new Vector(points[i].X, points[i].Y));
}
if (currentFigure.Count > bestFigure.Count)
bestFigure = currentFigure;
if (bestFigure.Count < 3)
return points[0];
// Close the polygon if needed.
var first = bestFigure[0];
var last = bestFigure[bestFigure.Count - 1];
if (first.DistanceTo(last) > 1e-6)
bestFigure.Add(first);
var label = PolyLabel.Find(bestFigure, 0.5);
return new PointF((float)label.X, (float)label.Y);
}
public void UpdateOffset(double spacing, double tolerance, Matrix matrix)
{
if (_offsetPolygonPoints == null ||

102
README.md Normal file
View File

@@ -0,0 +1,102 @@
# OpenNest
A Windows desktop app for CNC nesting — imports DXF drawings, arranges parts on plates and exports layouts as DXF or G-code for cutting.
<!-- TODO: Add screenshot of main window with a nested plate -->
OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and arranges the parts to make efficient use of material. The result can be exported as DXF files or post-processed into G-code that your CNC cutting machine understands.
## Features
- **DXF Import/Export** — Load part drawings from DXF files and export completed nest layouts
- **Multiple Fill Strategies** — Grid-based linear fill, NFP (No Fit Polygon) pair fitting, and rectangle bin packing
- **Part Rotation** — Automatically tries different rotation angles to find better fits
- **Gravity Compaction** — After placing parts, pushes them together to close gaps
- **Multi-Plate Support** — Work with multiple plates of different sizes and materials in a single nest
- **G-code Output** — Post-process nested layouts to G-code for CNC cutting machines
- **Built-in Shapes** — Create basic geometric parts (circles, rectangles, triangles, etc.) without needing a DXF file
- **Interactive Editing** — Zoom, pan, select, clone, and manually arrange parts on the plate view
- **Lead-in/Lead-out & Tabs** — Configure cutting parameters like approach paths and holding tabs
<!-- TODO: Add screenshot showing parts arranged on a plate -->
## Prerequisites
- **Windows 10 or later**
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
## Getting Started
### Build
```bash
git clone https://github.com/ajisaacs/OpenNest.git
cd OpenNest
dotnet build OpenNest.sln
```
### Run
```bash
dotnet run --project OpenNest/OpenNest.csproj
```
Or open `OpenNest.sln` in Visual Studio and run the `OpenNest` project.
### Quick Walkthrough
1. **Create a nest** — File > New Nest
2. **Add drawings** — Import DXF files or create built-in shapes (rectangles, circles, etc.). DXF drawings should be 1:1 scale CAD files.
3. **Set up a plate** — Define the plate size and material
4. **Fill the plate** — The nesting engine will automatically arrange parts on the plate
5. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
<!-- TODO: Add screenshots for each step -->
## Project Structure
```
OpenNest.sln
├── OpenNest/ # WinForms desktop application (UI)
├── OpenNest.Core/ # Domain model, geometry, and CNC primitives
├── OpenNest.Engine/ # Nesting algorithms (fill, pack, compact)
├── OpenNest.IO/ # File I/O — DXF import/export, nest file format
├── OpenNest.Console/ # Command-line interface for batch nesting
├── OpenNest.Gpu/ # GPU-accelerated nesting evaluation
├── OpenNest.Training/ # ML training data collection
├── OpenNest.Mcp/ # MCP server for AI tool integration
└── OpenNest.Tests/ # Unit tests
```
For most users, only the first four matter:
| Project | What it does |
|---------|-------------|
| **OpenNest** | The app you run. WinForms UI with plate viewer, drawing list, and dialogs. |
| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation. |
| **OpenNest.Engine** | The brains — algorithms that decide where parts go on a plate. |
| **OpenNest.IO** | Reads and writes files — DXF (via ACadSharp), G-code, and the `.nest` ZIP format. |
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `Ctrl+F` | Fill the area around the cursor with the selected drawing |
| `F` | Zoom to fit the plate view |
## Supported Formats
| Format | Import | Export |
|--------|--------|--------|
| DXF (AutoCAD Drawing Exchange) | Yes | Yes |
| DWG (AutoCAD Drawing) | Yes | No |
| G-code | No | Yes (via post-processors) |
| `.nest` (ZIP-based project file) | Yes | Yes |
## Status
OpenNest is under active development. The core nesting workflows function, but there's plenty of room for improvement in packing efficiency, UI polish, and format support. Contributions and feedback are welcome.
## License
This project is licensed under the [MIT License](LICENSE).