From c1c4b18f1770f606fa8af36484568f5c71264d33 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 8 Oct 2026 13:50:12 -0400 Subject: [PATCH] fix(ui): keep closed etch-only parts selectable --- .../EtchVisibilityTests.cs | 31 +++++++++++++++++++ OpenNest/Controls/PlateView.cs | 3 +- OpenNest/Controls/SelectionManager.cs | 12 ++++--- OpenNest/LayoutPart.cs | 4 +++ docs/nesting-view-colors.md | 4 ++- 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/OpenNest.WinForms.Tests/EtchVisibilityTests.cs b/OpenNest.WinForms.Tests/EtchVisibilityTests.cs index 236c1707..7a4cd340 100644 --- a/OpenNest.WinForms.Tests/EtchVisibilityTests.cs +++ b/OpenNest.WinForms.Tests/EtchVisibilityTests.cs @@ -1,5 +1,6 @@ using System.Drawing; using System.Drawing.Drawing2D; +using System.Reflection; using OpenNest.CNC; using OpenNest.Controls; using OpenNest.Geometry; @@ -39,6 +40,36 @@ public class EtchVisibilityTests Assert.Equal(0, layout.Path.PointCount); }, TimeSpan.FromMinutes(1), "Panned etch test timed out."); + [Fact] + public void EtchOnlyPart_RemainsSelectableAndHoverableWithoutCutMaterial() + => StaTestThread.Run(() => + { + var program = new CNC.Program(); + AddSquare(program, 100, LayerType.Scribe); + var part = new Part(new Drawing("etch-only", program)); + using var view = new PannedView(); + view.Plate.Parts.Add(part); + var layout = Assert.Single(view.LayoutParts); + Assert.Equal(0, layout.Path.PointCount); + Assert.True(layout.EtchPath.PointCount > 0); + + Assert.Same(layout, view.Selection.GetPartAtGraphPoint(new PointF(110, 110))); + Assert.Null(view.Selection.GetPartAtGraphPoint(new PointF(50, 50))); + Assert.Contains(layout, view.Selection.GetPartsFromWindow( + new RectangleF(105, 105, 10, 10), SelectionType.Intersect)); + Assert.Contains(layout, view.Selection.GetPartsFromWindow( + new RectangleF(95, 95, 30, 30), SelectionType.Contains)); + + typeof(PlateView).GetField("hoverPending", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(view, true); + typeof(PlateView).GetField("hoverPoint", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(view, new Point(15, 15)); + typeof(PlateView).GetMethod("HoverCheck", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(view, null); + Assert.Same(layout, typeof(PlateView).GetField("hoveredPart", + BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(view)); + }, TimeSpan.FromMinutes(1), "Etch selection test timed out."); + private static void AddSquare(CNC.Program program, double origin, LayerType layer) { program.MoveTo(origin, origin); diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index b9738880..c2a05689 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -816,7 +816,8 @@ namespace OpenNest.Controls for (var i = parts.Count - 1; i >= 0; --i) { - if (parts[i].Path.GetBounds().Contains(graphPt) && parts[i].Path.IsVisible(graphPt)) + var path = parts[i].SelectionPath; + if (path?.GetBounds().Contains(graphPt) == true && path.IsVisible(graphPt)) { hitPart = parts[i]; break; diff --git a/OpenNest/Controls/SelectionManager.cs b/OpenNest/Controls/SelectionManager.cs index 1c4dc30d..338fbacb 100644 --- a/OpenNest/Controls/SelectionManager.cs +++ b/OpenNest/Controls/SelectionManager.cs @@ -208,7 +208,7 @@ namespace OpenNest.Controls var parts = view.LayoutParts; for (var i = parts.Count - 1; i >= 0; --i) { - if (parts[i].Path.IsVisible(pt)) + if (parts[i].SelectionPath?.IsVisible(pt) == true) return parts[i]; } return null; @@ -230,10 +230,12 @@ namespace OpenNest.Controls for (var i = 0; i < parts.Count; ++i) { var part = parts[i]; - var region = new Region(part.Path); + var path = part.SelectionPath; + if (path == null) + continue; + using var region = new Region(path); if (region.IsVisible(rect)) list.Add(part); - region.Dispose(); } } else @@ -241,8 +243,8 @@ namespace OpenNest.Controls for (var i = 0; i < parts.Count; ++i) { var part = parts[i]; - var bounds = part.Path.GetBounds(); - if (rect.Contains(bounds)) + var path = part.SelectionPath; + if (path != null && rect.Contains(path.GetBounds())) list.Add(part); } } diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs index aef8c5d0..39df5f72 100644 --- a/OpenNest/LayoutPart.cs +++ b/OpenNest/LayoutPart.cs @@ -60,6 +60,10 @@ namespace OpenNest public GraphicsPath EtchPath { get; private set; } + // 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 RectangleF DisplayBounds { get; private set; } public Color Color diff --git a/docs/nesting-view-colors.md b/docs/nesting-view-colors.md index 449b73e3..22991213 100644 --- a/docs/nesting-view-colors.md +++ b/docs/nesting-view-colors.md @@ -23,7 +23,9 @@ custom or saved fill colors; open a saved nest before applying Workshop to recol In plate views and drawing thumbnails, scribe/etch motions have a separate stroke from material cuts. A closed etched circle or lettering does not make a hole in -the displayed part fill. Selection changes the part highlight, not the etch color. +the displayed part fill. An etch-only part remains selectable by its closed +mark in the plate view; etch lines still do not become cut material. Selection +changes the part highlight, not the etch color. Lead-in/out strokes keep their existing orange-red appearance. Preview placements show etches too. A cut coincident with an etch is drawn over the etch, so it is not hidden by the mark.