From 3672697cb2d058d3fb28cf87cd3546bee6b0357a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 17 Oct 2026 23:40:11 -0400 Subject: [PATCH] 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 --- .../TabbedCutoutFillTests.cs | 88 +++++++++++ OpenNest/Actions/ActionLeadIn.cs | 4 +- OpenNest/GraphicsHelper.cs | 140 +++++++++++++++--- OpenNest/LayoutPart.cs | 18 ++- 4 files changed, 218 insertions(+), 32 deletions(-) create mode 100644 OpenNest.WinForms.Tests/TabbedCutoutFillTests.cs diff --git a/OpenNest.WinForms.Tests/TabbedCutoutFillTests.cs b/OpenNest.WinForms.Tests/TabbedCutoutFillTests.cs new file mode 100644 index 00000000..3864c37b --- /dev/null +++ b/OpenNest.WinForms.Tests/TabbedCutoutFillTests.cs @@ -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)); + } +} diff --git a/OpenNest/Actions/ActionLeadIn.cs b/OpenNest/Actions/ActionLeadIn.cs index 045ef769..b0989ee3 100644 --- a/OpenNest/Actions/ActionLeadIn.cs +++ b/OpenNest/Actions/ActionLeadIn.cs @@ -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); } } diff --git a/OpenNest/GraphicsHelper.cs b/OpenNest/GraphicsHelper.cs index 20b2003d..859e4d04 100644 --- a/OpenNest/GraphicsHelper.cs +++ b/OpenNest/GraphicsHelper.cs @@ -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
(); 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 _); + + /// + /// Whether a cut contour is open, such as a cutout left attached by tabs. Filling + /// would close it with a chord; fill instead. + /// + 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 _); } /// /// 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. /// 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 includeLayer) + private static GraphicsPath GetLayerPath(Program pgm, Vector origin, + System.Func includeLayer, bool bridgeTabs, out bool hasOpenFigures) { - var path = new GraphicsPath(); + var figures = new List
(); 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, + /// One continuous run of drawn moves, with its program-space ends. + private sealed record Figure(GraphicsPath Path, Vector Start, Vector End) + { + public bool IsClosed => Start.DistanceTo(End) <= Tolerance.Epsilon; + } + + /// + /// Adds every figure to one path. With , 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. + /// + private static GraphicsPath Combine(List
figures, bool bridgeTabs) + { + var path = new GraphicsPath(); + var pending = new List
(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
figures, Program pgm, Mode mode, ref Vector curpos, System.Func 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) diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs index b8463524..b1e9c516 100644 --- a/OpenNest/LayoutPart.cs +++ b/OpenNest/LayoutPart.cs @@ -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; + + /// The filled material region: the cut outline unless break cuts or tabs make it differ. + 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; } - /// The material fill when it differs from Path (break cuts are drawn, not filled). + /// + /// The material fill when it differs from Path: break cuts are drawn, not filled, and a + /// cutout cut in pieces around tabs fills as one hole rather than as chord-closed pieces. + /// 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);