fix(ui): fill tabbed cutouts as one hole

A cutout cut in pieces around tabs is a set of open cut figures. Filling the
cut outline closed each piece with a chord, drawing a slanted bridge across
the hole. The fill path now rejoins open figures across their tab gaps, and
parts with open cuts use it for drawing, hit-testing and the lead-in overlay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-17 23:40:11 -04:00
1 parent af65cb178b
commit 3672697cb2
4 files changed
+218 -32

No files matched your search

@@ -0,0 +1,88 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests;
public class TabbedCutoutFillTests
{
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void CutoutCutAroundTabsFillsAsOneHole(Mode mode)
{
// A 4x4 cutout cut in two pieces with lead-ins/outs, leaving 0.2 tabs on its left and
// right edges, inside a closed 10x10 perimeter.
var program = new CNC.Program(Mode.Absolute);
program.Codes.AddRange(new ICode[]
{
new RapidMove(3.2, 5.3),
new LinearMove(3, 5.1) { Layer = LayerType.Leadin },
new LinearMove(3, 7),
new LinearMove(7, 7),
new LinearMove(7, 5.1),
new LinearMove(6.8, 5.3) { Layer = LayerType.Leadout },
new RapidMove(6.8, 4.7),
new LinearMove(7, 4.9) { Layer = LayerType.Leadin },
new LinearMove(7, 3),
new LinearMove(3, 3),
new LinearMove(3, 4.9),
new LinearMove(3.2, 4.7) { Layer = LayerType.Leadout },
new RapidMove(0, 0),
new LinearMove(10, 0),
new LinearMove(10, 10),
new LinearMove(0, 10),
new LinearMove(0, 0),
});
program.Mode = mode;
program.GetDisplayPaths(Vector.Zero, out var cut, out var lead, out var etch, out var hasOpenCuts);
using (cut)
using (lead)
using (etch)
using (var fill = program.GetFillPath(Vector.Zero))
{
Assert.True(hasOpenCuts);
// Filling the cut outline closes each piece with a chord and fills the band between them.
Assert.True(Covers(cut, 5, 5));
Assert.False(Covers(fill, 5, 5));
Assert.False(Covers(fill, 4, 6.5));
Assert.False(Covers(fill, 6, 3.5));
Assert.True(Covers(fill, 1, 5));
Assert.True(Covers(fill, 8.5, 8.5));
}
}
[Fact]
public void ClosedContoursReportNoOpenCuts()
{
var program = new CNC.Program(Mode.Absolute);
program.Codes.AddRange(new ICode[]
{
new RapidMove(0, 0),
new LinearMove(10, 0),
new LinearMove(10, 10),
new LinearMove(0, 0),
new RapidMove(6, 3),
new ArcMove(6, 3, 5, 3),
});
program.GetDisplayPaths(Vector.Zero, out var cut, out var lead, out var etch, out var hasOpenCuts);
cut.Dispose();
lead.Dispose();
etch.Dispose();
Assert.False(hasOpenCuts);
}
// GraphicsPath.IsVisible tests on a one-unit pixel grid, so scale up before hit-testing.
private static bool Covers(GraphicsPath path, double x, double y)
{
using var scaled = (GraphicsPath)path.Clone();
using var scale = new Matrix();
scale.Scale(1000, 1000);
scaled.Transform(scale);
return scaled.IsVisible((float)(x * 1000), (float)(y * 1000));
}
}
+2 -2
View File
@@ -279,8 +279,8 @@ namespace OpenNest.Actions
{
foreach (var lp in plateView.LayoutParts)
{
if (lp != selectedLayoutPart && lp.Path != null)
g.FillPath(grayOverlay, lp.Path);
if (lp != selectedLayoutPart && lp.MaterialPath != null)
g.FillPath(grayOverlay, lp.MaterialPath);
}
}
+116 -24
View File
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using OpenNest.CNC;
@@ -10,12 +11,12 @@ namespace OpenNest
{
public static GraphicsPath GetGraphicsPath(this Program pgm)
{
var path = new GraphicsPath();
var figures = new List<Figure>();
var curpos = Vector.Zero;
AddProgram(path, pgm, pgm.Mode, ref curpos);
AddProgram(figures, pgm, pgm.Mode, ref curpos);
return path;
return Combine(figures, bridgeTabs: false);
}
public static GraphicsPath GetGraphicsPath(this Program pgm, Vector origin)
@@ -45,28 +46,46 @@ namespace OpenNest
out GraphicsPath cutPath,
out GraphicsPath leadPath,
out GraphicsPath etchPath
) => pgm.GetDisplayPaths(origin, out cutPath, out leadPath, out etchPath, out _);
/// <param name="hasOpenCuts">
/// Whether a cut contour is open, such as a cutout left attached by tabs. Filling
/// <paramref name="cutPath"/> would close it with a chord; fill <see cref="GetFillPath"/> instead.
/// </param>
public static void GetDisplayPaths(
this Program pgm,
Vector origin,
out GraphicsPath cutPath,
out GraphicsPath leadPath,
out GraphicsPath etchPath,
out bool hasOpenCuts
)
{
cutPath = GetLayerPath(pgm, origin,
layer => layer != LayerType.Scribe && layer != LayerType.Leadin && layer != LayerType.Leadout);
layer => layer != LayerType.Scribe && layer != LayerType.Leadin && layer != LayerType.Leadout,
bridgeTabs: false, out hasOpenCuts);
leadPath = GetLayerPath(pgm, origin,
layer => layer == LayerType.Leadin || layer == LayerType.Leadout);
etchPath = GetLayerPath(pgm, origin, layer => layer == LayerType.Scribe);
layer => layer == LayerType.Leadin || layer == LayerType.Leadout, bridgeTabs: false, out _);
etchPath = GetLayerPath(pgm, origin, layer => layer == LayerType.Scribe, bridgeTabs: false, out _);
}
/// <summary>
/// The closed material outlines to fill. Break cuts are open lines through material: a
/// fill would close each one with a chord and leave the sliver between them unfilled.
/// A contour cut in pieces around tabs is rejoined across its tab gaps for the same reason.
/// </summary>
public static GraphicsPath GetFillPath(this Program pgm, Vector origin) =>
GetLayerPath(pgm, origin, layer => layer is not (LayerType.Scribe or LayerType.Leadin
or LayerType.Leadout or LayerType.Break));
or LayerType.Leadout or LayerType.Break), bridgeTabs: true, out _);
private static GraphicsPath GetLayerPath(Program pgm, Vector origin, System.Func<LayerType, bool> includeLayer)
private static GraphicsPath GetLayerPath(Program pgm, Vector origin,
System.Func<LayerType, bool> includeLayer, bool bridgeTabs, out bool hasOpenFigures)
{
var path = new GraphicsPath();
var figures = new List<Figure>();
var position = Vector.Zero;
AddProgram(path, pgm, pgm.Mode, ref position, includeLayer);
AddProgram(figures, pgm, pgm.Mode, ref position, includeLayer);
hasOpenFigures = figures.Exists(figure => !figure.IsClosed);
var path = Combine(figures, bridgeTabs);
// Place the completed local path; absolute moves must not discard placement.
using var translation = new Matrix();
translation.Translate((float)origin.X, (float)origin.Y);
@@ -329,7 +348,72 @@ namespace OpenNest
curpos = pt;
}
private static void AddProgram(GraphicsPath path, Program pgm, Mode mode, ref Vector curpos,
/// <summary>One continuous run of drawn moves, with its program-space ends.</summary>
private sealed record Figure(GraphicsPath Path, Vector Start, Vector End)
{
public bool IsClosed => Start.DistanceTo(End) <= Tolerance.Epsilon;
}
/// <summary>
/// Adds every figure to one path. With <paramref name="bridgeTabs"/>, an open figure is
/// joined end-to-start with the open figure starting nearest its end, until its own start
/// is nearer, so the tab-separated pieces of one contour fill as a single region.
/// </summary>
private static GraphicsPath Combine(List<Figure> figures, bool bridgeTabs)
{
var path = new GraphicsPath();
var pending = new List<Figure>(figures);
while (pending.Count > 0)
{
var figure = pending[0];
pending.RemoveAt(0);
if (!bridgeTabs || figure.IsClosed)
{
path.AddPath(figure.Path, false);
figure.Path.Dispose();
continue;
}
using var chain = new GraphicsPath();
chain.AddPath(figure.Path, false);
figure.Path.Dispose();
var end = figure.End;
while (true)
{
Figure next = null;
var nextGap = end.DistanceTo(figure.Start);
foreach (var candidate in pending)
{
if (candidate.IsClosed)
continue;
var gap = end.DistanceTo(candidate.Start);
if (gap < nextGap)
{
next = candidate;
nextGap = gap;
}
}
if (next == null)
break;
pending.Remove(next);
chain.AddPath(next.Path, true);
next.Path.Dispose();
end = next.End;
}
path.AddPath(chain, false);
}
return path;
}
private static void AddProgram(List<Figure> figures, Program pgm, Mode mode, ref Vector curpos,
System.Func<LayerType, bool> includeLayer = null)
{
// Capture the frame origin at entry. Sub-program Offsets are relative
@@ -337,17 +421,27 @@ namespace OpenNest
var frameOrigin = curpos;
mode = pgm.Mode;
GraphicsPath currentFigure = null;
var figureStart = curpos;
void Flush()
// Call before moving past a skipped code so the figure records where its cut ended.
void Flush(Vector end)
{
if (currentFigure != null)
{
path.AddPath(currentFigure, false);
currentFigure.Dispose();
figures.Add(new Figure(currentFigure, figureStart, end));
currentFigure = null;
}
}
void BeginFigure(Vector start)
{
if (currentFigure == null)
{
currentFigure = new GraphicsPath();
figureStart = start;
}
}
for (int i = 0; i < pgm.Length; ++i)
{
var code = pgm[i];
@@ -361,13 +455,12 @@ namespace OpenNest
? arc.Layer != LayerType.Leadin && arc.Layer != LayerType.Leadout
: !arc.Suppressed && includeLayer(arc.Layer))
{
if (currentFigure == null)
currentFigure = new GraphicsPath();
BeginFigure(curpos);
AddArc(currentFigure, arc, mode, ref curpos);
}
else
{
Flush();
Flush(curpos);
var endpt = arc.EndPoint;
if (mode == Mode.Incremental)
endpt += curpos;
@@ -383,13 +476,12 @@ namespace OpenNest
? line.Layer != LayerType.Leadin && line.Layer != LayerType.Leadout
: !line.Suppressed && includeLayer(line.Layer))
{
if (currentFigure == null)
currentFigure = new GraphicsPath();
BeginFigure(curpos);
AddLine(currentFigure, line, mode, ref curpos);
}
else
{
Flush();
Flush(curpos);
var endpt = line.EndPoint;
if (mode == Mode.Incremental)
endpt += curpos;
@@ -407,14 +499,14 @@ namespace OpenNest
var dx = endpt.X - curpos.X;
var dy = endpt.Y - curpos.Y;
if (dx * dx + dy * dy > 0.001 * 0.001)
Flush();
Flush(curpos);
curpos = endpt;
}
break;
case CodeType.SubProgramCall:
{
Flush();
Flush(curpos);
var tmpmode = mode;
var subpgm = (SubProgramCall)code;
@@ -424,7 +516,7 @@ namespace OpenNest
frameOrigin.X + subpgm.Offset.X,
frameOrigin.Y + subpgm.Offset.Y
);
AddProgram(path, subpgm.Program, mode, ref curpos, includeLayer);
AddProgram(figures, subpgm.Program, mode, ref curpos, includeLayer);
}
mode = tmpmode;
@@ -433,7 +525,7 @@ namespace OpenNest
}
}
Flush();
Flush(curpos);
}
private static void AddArc(GraphicsPath path, Arc arc)
+12 -6
View File
@@ -62,7 +62,10 @@ namespace OpenNest
// Etch-only parts have no material path, but their visible closed mark must
// remain hit-testable without treating scribe geometry as a cut or fill.
internal GraphicsPath SelectionPath => Path?.PointCount > 0 ? Path : EtchPath;
internal GraphicsPath SelectionPath => Path?.PointCount > 0 ? MaterialPath : EtchPath;
/// <summary>The filled material region: the cut outline unless break cuts or tabs make it differ.</summary>
internal GraphicsPath MaterialPath => FillPath ?? Path;
internal RectangleF DisplayBounds { get; private set; }
@@ -100,7 +103,7 @@ namespace OpenNest
using var schemeSelectionBrush = IsSelected && !colorScheme.SelectedPartColor.IsEmpty
? new SolidBrush(colorScheme.SelectedPartColor) : null;
g.FillPath(muted ? Brushes.Gainsboro : schemeSelectionBrush ?? (IsSelected ? selectedBrush : brush),
FillPath ?? Path);
MaterialPath);
DrawEtch(g, muted);
// Keep real cuts visible even where an etch overlaps them.
g.DrawPath(muted ? Pens.Gray : IsSelected && colorScheme.PartOutlineColor.IsEmpty ? selectedPen : pen, Path);
@@ -150,7 +153,10 @@ namespace OpenNest
public GraphicsPath OffsetPath { get; private set; }
/// <summary>The material fill when it differs from <c>Path</c> (break cuts are drawn, not filled).</summary>
/// <summary>
/// The material fill when it differs from <c>Path</c>: break cuts are drawn, not filled, and a
/// cutout cut in pieces around tabs fills as one hole rather than as chord-closed pieces.
/// </summary>
private GraphicsPath FillPath { get; set; }
private static bool HasBreakCuts(CNC.Program program) =>
@@ -202,7 +208,7 @@ namespace OpenNest
colorScheme = (plateView as PlateView)?.ColorScheme ?? ColorScheme.Default;
RefreshColors();
BasePart.Program.GetDisplayPaths(BasePart.Location,
out var cutPath, out var leadPath, out var etchPath);
out var cutPath, out var leadPath, out var etchPath, out var hasOpenCuts);
cutPath.Transform(plateView.Matrix);
leadPath.Transform(plateView.Matrix);
etchPath.Transform(plateView.Matrix);
@@ -211,8 +217,8 @@ namespace OpenNest
EtchPath?.Dispose();
FillPath?.Dispose();
FillPath = null;
// Only programs with break cuts need a fill apart from the outline path.
if (HasBreakCuts(BasePart.Program))
// Only programs with break cuts or open (tabbed) cuts need a fill apart from the outline path.
if (hasOpenCuts || HasBreakCuts(BasePart.Program))
{
FillPath = BasePart.Program.GetFillPath(BasePart.Location);
FillPath.Transform(plateView.Matrix);