# 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 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(); 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 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 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 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 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 holes = null; if (profile.Cutouts.Count > 0) { holes = new List(); 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**