Files
OpenNest/docs/superpowers/plans/2026-03-16-polylabel-part-labels.md
2026-03-16 20:36:45 -04:00

16 KiB

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

// 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
// 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
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

// 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
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.

// 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:

// 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).

// 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
// 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
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