merge: resolve polylabel conflicts, keep remote version with hole support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1645
docs/superpowers/plans/2026-03-16-engine-refactor.md
Normal file
1645
docs/superpowers/plans/2026-03-16-engine-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
570
docs/superpowers/plans/2026-03-16-polylabel-part-labels.md
Normal file
570
docs/superpowers/plans/2026-03-16-polylabel-part-labels.md
Normal file
@@ -0,0 +1,570 @@
|
||||
# Polylabel Part Label Positioning Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Position part ID labels at the visual center of each part using the polylabel (pole of inaccessibility) algorithm, so labels are readable and don't overlap adjacent parts.
|
||||
|
||||
**Architecture:** Add a `PolyLabel` static class in `OpenNest.Geometry` that finds the point inside a polygon farthest from all edges (including holes). `LayoutPart` caches this point in program-local coordinates and transforms it each frame for rendering.
|
||||
|
||||
**Tech Stack:** .NET 8, xUnit, WinForms (System.Drawing)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-16-polylabel-part-labels-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Polylabel Algorithm
|
||||
|
||||
### Task 1: PolyLabel — square polygon test + implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/PolyLabel.cs`
|
||||
- Create: `OpenNest.Tests/PolyLabelTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test for a square polygon**
|
||||
|
||||
```csharp
|
||||
// OpenNest.Tests/PolyLabelTests.cs
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class PolyLabelTests
|
||||
{
|
||||
private static Polygon Square(double size)
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(size, 0));
|
||||
p.Vertices.Add(new Vector(size, size));
|
||||
p.Vertices.Add(new Vector(0, size));
|
||||
return p;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Square_ReturnsCenterPoint()
|
||||
{
|
||||
var poly = Square(100);
|
||||
|
||||
var result = PolyLabel.Find(poly);
|
||||
|
||||
Assert.Equal(50, result.X, 1.0);
|
||||
Assert.Equal(50, result.Y, 1.0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter PolyLabelTests.Square_ReturnsCenterPoint`
|
||||
Expected: FAIL — `PolyLabel` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement PolyLabel.Find**
|
||||
|
||||
```csharp
|
||||
// OpenNest.Core/Geometry/PolyLabel.cs
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public static class PolyLabel
|
||||
{
|
||||
public static Vector Find(Polygon outer, IList<Polygon> holes = null, double precision = 0.5)
|
||||
{
|
||||
if (outer.Vertices.Count < 3)
|
||||
return outer.Vertices.Count > 0
|
||||
? outer.Vertices[0]
|
||||
: new Vector();
|
||||
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
|
||||
for (var i = 0; i < outer.Vertices.Count; i++)
|
||||
{
|
||||
var v = outer.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 + maxX) / 2, (minY + maxY) / 2);
|
||||
|
||||
var halfCell = cellSize / 2;
|
||||
|
||||
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, outer, holes));
|
||||
|
||||
queue.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
|
||||
|
||||
var bestCell = GetCentroidCell(outer, holes);
|
||||
|
||||
for (var i = 0; i < queue.Count; i++)
|
||||
if (queue[i].Dist > bestCell.Dist)
|
||||
{
|
||||
bestCell = queue[i];
|
||||
break;
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var cell = queue[0];
|
||||
queue.RemoveAt(0);
|
||||
|
||||
if (cell.Dist > bestCell.Dist)
|
||||
bestCell = cell;
|
||||
|
||||
if (cell.MaxDist - bestCell.Dist <= precision)
|
||||
continue;
|
||||
|
||||
halfCell = cell.HalfSize / 2;
|
||||
|
||||
var newCells = new[]
|
||||
{
|
||||
new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, outer, holes),
|
||||
new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, outer, holes),
|
||||
new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, outer, holes),
|
||||
new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, outer, holes),
|
||||
};
|
||||
|
||||
for (var i = 0; i < newCells.Length; i++)
|
||||
{
|
||||
if (newCells[i].MaxDist > bestCell.Dist + precision)
|
||||
InsertSorted(queue, newCells[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return new Vector(bestCell.X, bestCell.Y);
|
||||
}
|
||||
|
||||
private static void InsertSorted(List<Cell> list, Cell cell)
|
||||
{
|
||||
var idx = 0;
|
||||
while (idx < list.Count && list[idx].MaxDist > cell.MaxDist)
|
||||
idx++;
|
||||
list.Insert(idx, cell);
|
||||
}
|
||||
|
||||
private static Cell GetCentroidCell(Polygon outer, IList<Polygon> holes)
|
||||
{
|
||||
var area = 0.0;
|
||||
var cx = 0.0;
|
||||
var cy = 0.0;
|
||||
var verts = outer.Vertices;
|
||||
|
||||
for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
|
||||
{
|
||||
var a = verts[i];
|
||||
var b = verts[j];
|
||||
var cross = a.X * b.Y - b.X * a.Y;
|
||||
cx += (a.X + b.X) * cross;
|
||||
cy += (a.Y + b.Y) * cross;
|
||||
area += cross;
|
||||
}
|
||||
|
||||
area *= 0.5;
|
||||
|
||||
if (System.Math.Abs(area) < 1e-10)
|
||||
return new Cell(verts[0].X, verts[0].Y, 0, outer, holes);
|
||||
|
||||
cx /= (6 * area);
|
||||
cy /= (6 * area);
|
||||
|
||||
return new Cell(cx, cy, 0, outer, holes);
|
||||
}
|
||||
|
||||
private static double PointToPolygonDist(double x, double y, Polygon polygon)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var verts = polygon.Vertices;
|
||||
|
||||
for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
|
||||
{
|
||||
var a = verts[i];
|
||||
var b = verts[j];
|
||||
|
||||
var dx = b.X - a.X;
|
||||
var dy = b.Y - a.Y;
|
||||
|
||||
if (dx != 0 || dy != 0)
|
||||
{
|
||||
var t = ((x - a.X) * dx + (y - a.Y) * dy) / (dx * dx + dy * dy);
|
||||
|
||||
if (t > 1)
|
||||
{
|
||||
a = b;
|
||||
}
|
||||
else if (t > 0)
|
||||
{
|
||||
a = new Vector(a.X + dx * t, a.Y + dy * t);
|
||||
}
|
||||
}
|
||||
|
||||
var segDx = x - a.X;
|
||||
var segDy = y - a.Y;
|
||||
var dist = System.Math.Sqrt(segDx * segDx + segDy * segDy);
|
||||
|
||||
if (dist < minDist)
|
||||
minDist = dist;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
private sealed class Cell
|
||||
{
|
||||
public readonly double X;
|
||||
public readonly double Y;
|
||||
public readonly double HalfSize;
|
||||
public readonly double Dist;
|
||||
public readonly double MaxDist;
|
||||
|
||||
public Cell(double x, double y, double halfSize, Polygon outer, IList<Polygon> holes)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
HalfSize = halfSize;
|
||||
|
||||
var pt = new Vector(x, y);
|
||||
var inside = outer.ContainsPoint(pt);
|
||||
|
||||
if (inside && holes != null)
|
||||
{
|
||||
for (var i = 0; i < holes.Count; i++)
|
||||
{
|
||||
if (holes[i].ContainsPoint(pt))
|
||||
{
|
||||
inside = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dist = PointToAllEdgesDist(x, y, outer, holes);
|
||||
|
||||
if (!inside)
|
||||
Dist = -Dist;
|
||||
|
||||
MaxDist = Dist + HalfSize * System.Math.Sqrt(2);
|
||||
}
|
||||
}
|
||||
|
||||
private static double PointToAllEdgesDist(double x, double y, Polygon outer, IList<Polygon> holes)
|
||||
{
|
||||
var minDist = PointToPolygonDist(x, y, outer);
|
||||
|
||||
if (holes != null)
|
||||
{
|
||||
for (var i = 0; i < holes.Count; i++)
|
||||
{
|
||||
var d = PointToPolygonDist(x, y, holes[i]);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter PolyLabelTests.Square_ReturnsCenterPoint`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/PolyLabel.cs OpenNest.Tests/PolyLabelTests.cs
|
||||
git commit -m "feat(geometry): add PolyLabel algorithm with square test"
|
||||
```
|
||||
|
||||
### Task 2: PolyLabel — additional shape tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Tests/PolyLabelTests.cs`
|
||||
|
||||
- [ ] **Step 1: Add tests for L-shape, triangle, thin rectangle, C-shape, hole, and degenerate**
|
||||
|
||||
```csharp
|
||||
// Append to PolyLabelTests.cs
|
||||
|
||||
[Fact]
|
||||
public void Triangle_ReturnsIncenter()
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(100, 0));
|
||||
p.Vertices.Add(new Vector(50, 86.6));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
// Incenter of equilateral triangle is at (50, ~28.9)
|
||||
Assert.Equal(50, result.X, 1.0);
|
||||
Assert.Equal(28.9, result.Y, 1.0);
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LShape_ReturnsPointInBottomLobe()
|
||||
{
|
||||
// L-shape: 100x100 with 50x50 cut from top-right
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(100, 0));
|
||||
p.Vertices.Add(new Vector(100, 50));
|
||||
p.Vertices.Add(new Vector(50, 50));
|
||||
p.Vertices.Add(new Vector(50, 100));
|
||||
p.Vertices.Add(new Vector(0, 100));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
// The bottom 100x50 lobe is the widest region
|
||||
Assert.True(result.Y < 50, $"Expected label in bottom lobe, got Y={result.Y}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThinRectangle_CenteredOnBothAxes()
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(200, 0));
|
||||
p.Vertices.Add(new Vector(200, 10));
|
||||
p.Vertices.Add(new Vector(0, 10));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.Equal(100, result.X, 1.0);
|
||||
Assert.Equal(5, result.Y, 1.0);
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SquareWithLargeHole_AvoidsHole()
|
||||
{
|
||||
var outer = Square(100);
|
||||
|
||||
var hole = new Polygon();
|
||||
hole.Vertices.Add(new Vector(20, 20));
|
||||
hole.Vertices.Add(new Vector(80, 20));
|
||||
hole.Vertices.Add(new Vector(80, 80));
|
||||
hole.Vertices.Add(new Vector(20, 80));
|
||||
|
||||
var result = PolyLabel.Find(outer, new[] { hole });
|
||||
|
||||
// Point should be inside outer but outside hole
|
||||
Assert.True(outer.ContainsPoint(result));
|
||||
Assert.False(hole.ContainsPoint(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CShape_ReturnsPointInLeftBar()
|
||||
{
|
||||
// C-shape opening to the right: left bar is 20 wide, top/bottom arms are 20 tall
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(100, 0));
|
||||
p.Vertices.Add(new Vector(100, 20));
|
||||
p.Vertices.Add(new Vector(20, 20));
|
||||
p.Vertices.Add(new Vector(20, 80));
|
||||
p.Vertices.Add(new Vector(100, 80));
|
||||
p.Vertices.Add(new Vector(100, 100));
|
||||
p.Vertices.Add(new Vector(0, 100));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
// Label should be in the left vertical bar (x < 20), not at bbox center (50, 50)
|
||||
Assert.True(result.X < 20, $"Expected label in left bar, got X={result.X}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DegeneratePolygon_ReturnsFallback()
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(5, 5));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.Equal(5, result.X, 0.01);
|
||||
Assert.Equal(5, result.Y, 0.01);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run all PolyLabel tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter PolyLabelTests`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Tests/PolyLabelTests.cs
|
||||
git commit -m "test(geometry): add PolyLabel tests for L, C, triangle, thin rect, hole"
|
||||
```
|
||||
|
||||
## Chunk 2: Label Rendering
|
||||
|
||||
### Task 3: Update LayoutPart label positioning
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/LayoutPart.cs`
|
||||
|
||||
- [ ] **Step 1: Add cached label point field and computation method**
|
||||
|
||||
Add a `Vector? _labelPoint` field and a method to compute it from the part's geometry. Uses `ShapeProfile` to identify the outer contour and holes.
|
||||
|
||||
```csharp
|
||||
// Add field near the top of the class (after the existing private fields):
|
||||
private Vector? _labelPoint;
|
||||
|
||||
// Add method:
|
||||
private Vector ComputeLabelPoint()
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(BasePart.Program);
|
||||
var nonRapid = entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||
|
||||
if (nonRapid.Count == 0)
|
||||
{
|
||||
var bbox = BasePart.Program.BoundingBox();
|
||||
return new Vector(bbox.Location.X + bbox.Width / 2, bbox.Location.Y + bbox.Length / 2);
|
||||
}
|
||||
|
||||
var profile = new ShapeProfile(nonRapid);
|
||||
var outer = profile.Perimeter.ToPolygonWithTolerance(0.1);
|
||||
|
||||
List<Polygon> holes = null;
|
||||
|
||||
if (profile.Cutouts.Count > 0)
|
||||
{
|
||||
holes = new List<Polygon>();
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
holes.Add(cutout.ToPolygonWithTolerance(0.1));
|
||||
}
|
||||
|
||||
return PolyLabel.Find(outer, holes);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Invalidate the cache when IsDirty is set**
|
||||
|
||||
Modify the `IsDirty` property to clear `_labelPoint`:
|
||||
|
||||
```csharp
|
||||
// Replace:
|
||||
internal bool IsDirty { get; set; }
|
||||
|
||||
// With:
|
||||
private bool _isDirty;
|
||||
internal bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
set
|
||||
{
|
||||
_isDirty = value;
|
||||
if (value) _labelPoint = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add screen-space label point field and compute it in Update()**
|
||||
|
||||
Compute the polylabel in program-local coordinates (cached, expensive) and transform to screen space in `Update()` (cheap, runs on every zoom/pan).
|
||||
|
||||
```csharp
|
||||
// Add field:
|
||||
private PointF _labelScreenPoint;
|
||||
|
||||
// Replace existing Update():
|
||||
public void Update(DrawControl plateView)
|
||||
{
|
||||
Path = GraphicsHelper.GetGraphicsPath(BasePart.Program, BasePart.Location);
|
||||
Path.Transform(plateView.Matrix);
|
||||
|
||||
_labelPoint ??= ComputeLabelPoint();
|
||||
var labelPt = new PointF(
|
||||
(float)(_labelPoint.Value.X + BasePart.Location.X),
|
||||
(float)(_labelPoint.Value.Y + BasePart.Location.Y));
|
||||
var pts = new[] { labelPt };
|
||||
plateView.Matrix.TransformPoints(pts);
|
||||
_labelScreenPoint = pts[0];
|
||||
|
||||
IsDirty = false;
|
||||
}
|
||||
```
|
||||
|
||||
Note: setting `IsDirty = false` at the end of `Update()` will NOT clear `_labelPoint` because the setter only clears when `value` is `true`.
|
||||
|
||||
- [ ] **Step 4: Update Draw(Graphics g, string id) to use the cached screen point**
|
||||
|
||||
```csharp
|
||||
// Replace the existing Draw(Graphics g, string id) method body.
|
||||
// Old code (lines 85-101 of LayoutPart.cs):
|
||||
// if (IsSelected) { ... } else { ... }
|
||||
// var pt = Path.PointCount > 0 ? Path.PathPoints[0] : PointF.Empty;
|
||||
// g.DrawString(id, programIdFont, Brushes.Black, pt.X, pt.Y);
|
||||
|
||||
// New code:
|
||||
public void Draw(Graphics g, string id)
|
||||
{
|
||||
if (IsSelected)
|
||||
{
|
||||
g.FillPath(selectedBrush, Path);
|
||||
g.DrawPath(selectedPen, Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
g.FillPath(brush, Path);
|
||||
g.DrawPath(pen, Path);
|
||||
}
|
||||
|
||||
using var sf = new StringFormat
|
||||
{
|
||||
Alignment = StringAlignment.Center,
|
||||
LineAlignment = StringAlignment.Center
|
||||
};
|
||||
g.DrawString(id, programIdFont, Brushes.Black, _labelScreenPoint.X, _labelScreenPoint.Y, sf);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/LayoutPart.cs
|
||||
git commit -m "feat(ui): position part labels at polylabel center"
|
||||
```
|
||||
|
||||
### Task 4: Manual visual verification
|
||||
|
||||
- [ ] **Step 1: Run the application and verify labels**
|
||||
|
||||
Run the OpenNest application, load a nest with multiple parts, and verify:
|
||||
- Labels appear centered inside each part.
|
||||
- Labels don't overlap adjacent part edges.
|
||||
- Labels stay centered when zooming and panning.
|
||||
- Parts with holes have labels placed in the solid material, not in the hole.
|
||||
|
||||
- [ ] **Step 2: Run all tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 3: Final commit if any tweaks needed**
|
||||
197
docs/superpowers/specs/2026-03-16-engine-refactor-design.md
Normal file
197
docs/superpowers/specs/2026-03-16-engine-refactor-design.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Engine Refactor: Extract Shared Algorithms from DefaultNestEngine and StripNestEngine
|
||||
|
||||
## Problem
|
||||
|
||||
`DefaultNestEngine` (~550 lines) mixes phase orchestration with strategy-specific logic (pair candidate selection, angle building, pattern helpers). `StripNestEngine` (~450 lines) duplicates patterns that DefaultNestEngine also uses: shrink-to-fit loops, iterative remnant filling, and progress accumulation. Both engines would benefit from extracting shared algorithms into focused, reusable classes.
|
||||
|
||||
## Approach
|
||||
|
||||
Extract five classes from the two engines. No new interfaces or strategy patterns — just focused helper classes that each engine composes.
|
||||
|
||||
## Extracted Classes
|
||||
|
||||
### 1. PairFiller
|
||||
|
||||
**Source:** DefaultNestEngine lines 362-489 (`FillWithPairs`, `SelectPairCandidates`, `BuildRemainderPatterns`, `MinPairCandidates`, `PairTimeLimit`).
|
||||
|
||||
**API:**
|
||||
```csharp
|
||||
public class PairFiller
|
||||
{
|
||||
public PairFiller(Size plateSize, double partSpacing) { }
|
||||
|
||||
public List<Part> Fill(NestItem item, Box workArea,
|
||||
int plateNumber = 0,
|
||||
CancellationToken token = default,
|
||||
IProgress<NestProgress> progress = null);
|
||||
}
|
||||
```
|
||||
|
||||
**Details:**
|
||||
- Constructor takes plate size and spacing — decoupled from `Plate` object.
|
||||
- `SelectPairCandidates` and `BuildRemainderPatterns` become private methods.
|
||||
- Uses `BestFitCache.GetOrCompute()` internally (same as today).
|
||||
- Calls `BuildRotatedPattern` and `FillPattern` — these become `internal static` methods on DefaultNestEngine so PairFiller can call them without ceremony.
|
||||
- Returns `List<Part>` (empty list if no result), same contract as today.
|
||||
- Progress reporting: PairFiller accepts `IProgress<NestProgress>` and `int plateNumber` in its `Fill` method to maintain per-candidate progress updates. The caller passes these through from the engine.
|
||||
|
||||
**Caller:** `DefaultNestEngine.FindBestFill` replaces `this.FillWithPairs(...)` with `new PairFiller(Plate.Size, Plate.PartSpacing).Fill(...)`.
|
||||
|
||||
### 2. AngleCandidateBuilder
|
||||
|
||||
**Source:** DefaultNestEngine lines 279-347 (`BuildCandidateAngles`, `knownGoodAngles` HashSet, `ForceFullAngleSweep` property).
|
||||
|
||||
**API:**
|
||||
```csharp
|
||||
public class AngleCandidateBuilder
|
||||
{
|
||||
public bool ForceFullSweep { get; set; }
|
||||
|
||||
public List<double> Build(NestItem item, double bestRotation, Box workArea);
|
||||
|
||||
public void RecordProductive(List<AngleResult> angleResults);
|
||||
}
|
||||
```
|
||||
|
||||
**Details:**
|
||||
- Owns `knownGoodAngles` state — lives as long as the engine instance so pruning accumulates across fills.
|
||||
- `Build()` encapsulates the full pipeline: base angles, sweep check, ML prediction, known-good pruning.
|
||||
- `RecordProductive()` replaces the inline loop that feeds `knownGoodAngles` after the linear phase.
|
||||
- `ForceFullAngleSweep` moves from DefaultNestEngine to `AngleCandidateBuilder.ForceFullSweep`. DefaultNestEngine keeps a forwarding property `ForceFullAngleSweep` that delegates to its `AngleCandidateBuilder` instance, so `BruteForceRunner` (which sets `engine.ForceFullAngleSweep = true`) continues to work without changes.
|
||||
|
||||
**Caller:** DefaultNestEngine creates one `AngleCandidateBuilder` instance as a field and calls `Build()`/`RecordProductive()` from `FindBestFill`.
|
||||
|
||||
### 3. ShrinkFiller
|
||||
|
||||
**Source:** StripNestEngine `TryOrientation` shrink loop (lines 188-215) and `ShrinkFill` (lines 358-418).
|
||||
|
||||
**API:**
|
||||
```csharp
|
||||
public static class ShrinkFiller
|
||||
{
|
||||
public static ShrinkResult Shrink(
|
||||
Func<NestItem, Box, List<Part>> fillFunc,
|
||||
NestItem item, Box box,
|
||||
double spacing,
|
||||
ShrinkAxis axis,
|
||||
CancellationToken token = default,
|
||||
int maxIterations = 20);
|
||||
}
|
||||
|
||||
public enum ShrinkAxis { Width, Height }
|
||||
|
||||
public class ShrinkResult
|
||||
{
|
||||
public List<Part> Parts { get; set; }
|
||||
public double Dimension { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Details:**
|
||||
- `fillFunc` delegate decouples ShrinkFiller from any specific engine — the caller provides how to fill.
|
||||
- `ShrinkAxis` determines which dimension to reduce. `TryOrientation` maps strip direction to axis: `StripDirection.Bottom` → `ShrinkAxis.Height`, `StripDirection.Left` → `ShrinkAxis.Width`. `ShrinkFill` calls `Shrink` twice (width then height).
|
||||
- Loop logic: fill initial box, measure placed bounding box, reduce dimension by `spacing`, retry until count drops below initial count. Dimension is measured as `placedBox.Right - box.X` for Width or `placedBox.Top - box.Y` for Height.
|
||||
- Returns both the best parts and the final tight dimension (needed by `TryOrientation` to compute the remnant box).
|
||||
- **Two-axis independence:** When `ShrinkFill` calls `Shrink` twice, each axis shrinks against the **original** box dimensions, not the result of the prior axis. This preserves the current behavior where width and height are shrunk independently.
|
||||
|
||||
**Callers:**
|
||||
- `StripNestEngine.TryOrientation` replaces its inline shrink loop.
|
||||
- `StripNestEngine.ShrinkFill` replaces its two-axis inline shrink loops.
|
||||
|
||||
### 4. RemnantFiller
|
||||
|
||||
**Source:** StripNestEngine remnant loop (lines 253-343) and the simpler version in NestEngineBase.Nest (lines 74-97).
|
||||
|
||||
**API:**
|
||||
```csharp
|
||||
public class RemnantFiller
|
||||
{
|
||||
public RemnantFiller(Box workArea, double spacing) { }
|
||||
|
||||
public void AddObstacles(IEnumerable<Part> parts);
|
||||
|
||||
public List<Part> FillItems(
|
||||
List<NestItem> items,
|
||||
Func<NestItem, Box, List<Part>> fillFunc,
|
||||
CancellationToken token = default,
|
||||
IProgress<NestProgress> progress = null);
|
||||
}
|
||||
```
|
||||
|
||||
**Details:**
|
||||
- Owns a `RemnantFinder` instance internally.
|
||||
- `AddObstacles` registers already-placed parts (bounding boxes offset by spacing).
|
||||
- `FillItems` runs the iterative loop: find remnants, try each item in each remnant, fill, update obstacles, repeat until no progress.
|
||||
- Local quantity tracking (dictionary keyed by drawing name) stays internal — does not mutate the input `NestItem` quantities. Returns the placed parts; the caller deducts quantities.
|
||||
- Uses minimum-remnant-size filtering (smallest remaining part dimension), same as StripNestEngine today.
|
||||
- `fillFunc` delegate allows callers to provide any fill strategy (DefaultNestEngine.Fill, ShrinkFill, etc.).
|
||||
|
||||
**Callers:**
|
||||
- `StripNestEngine.TryOrientation` replaces its inline remnant loop with `RemnantFiller.FillItems(...)`.
|
||||
- `NestEngineBase.Nest` replaces its hand-rolled largest-remnant loop. **Note:** This is a deliberate behavioral improvement — the base class currently uses only the single largest remnant, while `RemnantFiller` tries all remnants iteratively with minimum-size filtering. This may produce better fill results for engines that rely on the base `Nest` method.
|
||||
|
||||
**Unchanged:** `NestEngineBase.Nest` phase 2 (bin-packing single-quantity items via `PackArea`, lines 100-119) is not affected by this change.
|
||||
|
||||
### 5. AccumulatingProgress
|
||||
|
||||
**Source:** StripNestEngine nested class (lines 425-449).
|
||||
|
||||
**API:**
|
||||
```csharp
|
||||
internal class AccumulatingProgress : IProgress<NestProgress>
|
||||
{
|
||||
public AccumulatingProgress(IProgress<NestProgress> inner, List<Part> previousParts) { }
|
||||
public void Report(NestProgress value);
|
||||
}
|
||||
```
|
||||
|
||||
**Details:**
|
||||
- Moved from private nested class to standalone `internal` class in OpenNest.Engine.
|
||||
- No behavioral change — wraps an `IProgress<NestProgress>` and prepends previously placed parts to each report.
|
||||
|
||||
## What Stays on Each Engine
|
||||
|
||||
### DefaultNestEngine (~200 lines after extraction)
|
||||
|
||||
- `Fill(NestItem, Box, ...)` — public entry point, unchanged.
|
||||
- `Fill(List<Part>, Box, ...)` — group-parts overload, unchanged.
|
||||
- `PackArea` — bin packing delegation, unchanged.
|
||||
- `FindBestFill` — orchestration, now ~30 lines: calls `AngleCandidateBuilder.Build()`, `PairFiller.Fill()`, linear angle loop, `FillRectangleBestFit`, picks best.
|
||||
- `FillRectangleBestFit` — 6-line private method, too small to extract.
|
||||
- `BuildRotatedPattern` / `FillPattern` — become `internal static`, used by both the linear loop and PairFiller.
|
||||
- `QuickFillCount` — stays (used by binary search, not shared).
|
||||
|
||||
### StripNestEngine (~200 lines after extraction)
|
||||
|
||||
- `Nest` — orchestration, unchanged.
|
||||
- `TryOrientation` — becomes thinner: calls `DefaultNestEngine.Fill` for initial fill, `ShrinkFiller.Shrink()` for tightening, `RemnantFiller.FillItems()` for remnants.
|
||||
- `ShrinkFill` — replaced by two `ShrinkFiller.Shrink()` calls.
|
||||
- `SelectStripItemIndex` / `EstimateStripDimension` — stay private, strip-specific.
|
||||
- `AccumulatingProgress` — removed, uses shared class.
|
||||
|
||||
### NestEngineBase
|
||||
|
||||
- `Nest` — switches from hand-rolled remnant loop to `RemnantFiller.FillItems()`.
|
||||
- All other methods unchanged.
|
||||
|
||||
## File Layout
|
||||
|
||||
All new classes go in `OpenNest.Engine/`:
|
||||
|
||||
```
|
||||
OpenNest.Engine/
|
||||
PairFiller.cs
|
||||
AngleCandidateBuilder.cs
|
||||
ShrinkFiller.cs
|
||||
RemnantFiller.cs
|
||||
AccumulatingProgress.cs
|
||||
```
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No new interfaces or strategy patterns.
|
||||
- No changes to FillLinear, FillBestFit, PackBottomLeft, or any other existing algorithm.
|
||||
- No changes to NestEngineRegistry or the plugin system.
|
||||
- No changes to public API surface — all existing callers continue to work unchanged. One deliberate behavioral improvement: `NestEngineBase.Nest` gains multi-remnant filling (see RemnantFiller section).
|
||||
- PatternHelper extraction deferred — `BuildRotatedPattern`/`FillPattern` become `internal static` on DefaultNestEngine for now. Extract if a third consumer appears.
|
||||
- StripNestEngine continues to create fresh `DefaultNestEngine` instances per fill call. Sharing an `AngleCandidateBuilder` across sub-fills to enable angle pruning is a potential future optimization, not part of this refactor.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Polylabel Part Label Positioning
|
||||
|
||||
**Date:** 2026-03-16
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Part ID labels in `PlateView` are drawn at `PathPoints[0]` — the first point of the graphics path, which sits on the part contour edge. This causes labels to overlap adjacent parts and be unreadable, especially in dense nests.
|
||||
|
||||
## Solution
|
||||
|
||||
Implement the polylabel algorithm (pole of inaccessibility) to find the point inside each part's polygon with maximum distance from all edges, including hole edges. Draw the part ID label centered on that point.
|
||||
|
||||
## Design
|
||||
|
||||
### Part 1: Polylabel Algorithm
|
||||
|
||||
Add `PolyLabel` static class in `OpenNest.Geometry` namespace (file: `OpenNest.Core/Geometry/PolyLabel.cs`).
|
||||
|
||||
**Public API:**
|
||||
|
||||
```csharp
|
||||
public static class PolyLabel
|
||||
{
|
||||
public static Vector Find(Polygon outer, IList<Polygon> holes = null, double precision = 0.5);
|
||||
}
|
||||
```
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
1. Compute bounding box of the outer polygon.
|
||||
2. Divide into a grid of cells (cell size = shorter bbox dimension).
|
||||
3. For each cell, compute signed distance from cell center to nearest edge on any ring (outer boundary + all holes). Use `Polygon.ContainsPoint` for sign (negative if outside outer polygon or inside a hole).
|
||||
4. Track the best interior point found so far.
|
||||
5. Use a priority queue (sorted list) ordered by maximum possible distance for each cell.
|
||||
6. Subdivide promising cells that could beat the current best; discard the rest.
|
||||
7. Stop when the best cell's potential improvement over the current best is less than the precision tolerance.
|
||||
|
||||
**Dependencies within codebase:**
|
||||
|
||||
- `Polygon.ContainsPoint(Vector)` — ray-casting point-in-polygon test (already exists).
|
||||
- Point-to-segment distance — compute from `Line` or inline (distance from point to each polygon edge).
|
||||
|
||||
**Fallback:** If the polygon is degenerate (< 3 vertices) or the program has no geometry, fall back to the bounding box center.
|
||||
|
||||
**No external dependencies.**
|
||||
|
||||
### Part 2: Label Rendering in LayoutPart
|
||||
|
||||
Modify `LayoutPart` in `OpenNest/LayoutPart.cs`.
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Add a cached `Vector? _labelPoint` field in **program-local coordinates** (pre-transform). Invalidated when `IsDirty` is set.
|
||||
2. When computing the label point (on first draw after invalidation):
|
||||
- Convert the part's `Program` to geometry via `ConvertProgram.ToGeometry`.
|
||||
- Build shapes via `ShapeBuilder.GetShapes`.
|
||||
- Identify the outer contour using `ShapeProfile` (the `Perimeter` shape) and convert cutouts to hole polygons.
|
||||
- Run `PolyLabel.Find(outer, holes)` on the result.
|
||||
- Cache the `Vector` in program-local coordinates.
|
||||
3. In `Draw(Graphics g, string id)`:
|
||||
- Offset the cached label point by `BasePart.Location`.
|
||||
- Transform through the current view matrix (handles zoom/pan without cache invalidation).
|
||||
- Draw the ID string centered using `StringFormat` with `Alignment = Center` and `LineAlignment = Center`.
|
||||
|
||||
**Coordinate pipeline:** polylabel runs once in program-local coordinates (expensive, cached). Location offset + matrix transform happen every frame (cheap, no caching needed). This matches how the existing `GraphicsPath` pipeline works and avoids stale cache on zoom/pan.
|
||||
|
||||
## Scope
|
||||
|
||||
- **In scope:** polylabel algorithm, label positioning change in `LayoutPart.Draw`.
|
||||
- **Out of scope:** changing part origins, modifying the nesting engine, any changes to `Part`, `Drawing`, or `Program` classes.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests for `PolyLabel.Find()` with known polygons:
|
||||
- Square — label at center.
|
||||
- L-shape — label in the larger lobe.
|
||||
- C-shape — label inside the concavity, not at bounding box center.
|
||||
- Triangle — label at incenter.
|
||||
- Thin rectangle (10:1 aspect ratio) — label centered along the short axis.
|
||||
- Square with large centered hole — label avoids the hole.
|
||||
- Verify the returned point is inside the polygon and has the expected distance from edges.
|
||||
Reference in New Issue
Block a user