mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 00:21:06 -04:00
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:
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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user