refactor: extract SelectionManager from PlateView

Move all selection state and operations (SelectedParts, SelectedCutOffs, DeselectAll, SelectAll, AlignSelected, RotateSelectedParts, PushSelected, GetPartAt*, GetPartsFromWindow, DeleteSelected) into a new internal SelectionManager class. PlateView retains public forwarding methods and properties to preserve the existing API surface. SelectedCutOff property kept public for WinForms designer compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 15:09:08 -04:00
parent 11884e712d
commit 089df67627
3 changed files with 310 additions and 193 deletions

View File

@@ -31,7 +31,7 @@ namespace OpenNest.Controls
private Action currentAction;
private Action previousAction;
private CutOffSettings cutOffSettings = new CutOffSettings();
private CutOff selectedCutOff;
private SelectionManager selection;
private bool draggingCutOff;
private Dictionary<Part, Geometry.Entity> dragPerimeterCache;
protected List<LayoutPart> parts;
@@ -41,6 +41,8 @@ namespace OpenNest.Controls
private Box activeWorkArea;
private List<Box> debugRemnants;
private PlateRenderer renderer;
private LayoutPart hoveredPart;
private Point hoverPoint;
public Box ActiveWorkArea
{
@@ -64,13 +66,20 @@ namespace OpenNest.Controls
public List<int> DebugRemnantPriorities { get; set; }
public List<LayoutPart> SelectedParts;
public ReadOnlyCollection<LayoutPart> Parts;
public List<LayoutPart> SelectedParts => selection.SelectedParts;
public ReadOnlyCollection<LayoutPart> Parts => new ReadOnlyCollection<LayoutPart>(parts);
internal SelectionManager Selection => selection;
public event EventHandler<ItemAddedEventArgs<Part>> PartAdded;
public event EventHandler<ItemRemovedEventArgs<Part>> PartRemoved;
public event EventHandler StatusChanged;
public event EventHandler SelectionChanged;
public event EventHandler SelectionChanged
{
add => selection.SelectionChanged += value;
remove => selection.SelectionChanged -= value;
}
public PlateView()
: this(ColorScheme.Default)
@@ -83,8 +92,7 @@ namespace OpenNest.Controls
programIdFont = new Font(DefaultFont, FontStyle.Bold | FontStyle.Underline);
origin = new PointF();
parts = new List<LayoutPart>();
Parts = new ReadOnlyCollection<LayoutPart>(parts);
SelectedParts = new List<LayoutPart>();
selection = new SelectionManager(this);
redrawTimer = new Timer()
{
@@ -173,12 +181,15 @@ namespace OpenNest.Controls
}
}
// Temporary — removed in Task 5
public CutOff SelectedCutOff
{
get => selectedCutOff;
get => selection.SelectedCutOffs.Count > 0 ? selection.SelectedCutOffs[0] : null;
set
{
selectedCutOff = value;
selection.SelectedCutOffs.Clear();
if (value != null)
selection.SelectedCutOffs.Add(value);
Invalidate();
}
}
@@ -202,7 +213,7 @@ namespace OpenNest.Controls
parts.Clear();
stationaryParts.Clear();
activeParts.Clear();
SelectedParts.Clear();
selection.Clear();
}
plate = p;
@@ -288,12 +299,12 @@ namespace OpenNest.Controls
if (dx * dx + dy * dy < 25)
{
RotateSelectedParts(Angle.ToRadians(90));
selection.RotateSelectedParts(Angle.ToRadians(90));
Invalidate();
}
}
if (draggingCutOff && selectedCutOff != null)
if (draggingCutOff && SelectedCutOff != null)
{
draggingCutOff = false;
dragPerimeterCache = null;
@@ -319,7 +330,7 @@ namespace OpenNest.Controls
var angle = Angle.ToRadians((e.Delta > 0 ? -increment : increment) * multiplier);
RotateSelectedParts(angle);
selection.RotateSelectedParts(angle);
}
else
{
@@ -358,18 +369,44 @@ namespace OpenNest.Controls
lastPoint = e.Location;
if (draggingCutOff && selectedCutOff != null)
if (draggingCutOff && SelectedCutOff != null)
{
if (selectedCutOff.Axis == CutOffAxis.Vertical)
selectedCutOff.Position = new Vector(CurrentPoint.X, selectedCutOff.Position.Y);
if (SelectedCutOff.Axis == CutOffAxis.Vertical)
SelectedCutOff.Position = new Vector(CurrentPoint.X, SelectedCutOff.Position.Y);
else
selectedCutOff.Position = new Vector(selectedCutOff.Position.X, CurrentPoint.Y);
SelectedCutOff.Position = new Vector(SelectedCutOff.Position.X, CurrentPoint.Y);
selectedCutOff.Regenerate(Plate, cutOffSettings, dragPerimeterCache);
SelectedCutOff.Regenerate(Plate, cutOffSettings, dragPerimeterCache);
Invalidate();
return;
}
if (e.Button == MouseButtons.None && currentAction is ActionSelect)
{
var graphPt = PointControlToGraph(e.Location);
LayoutPart hitPart = null;
for (var i = parts.Count - 1; i >= 0; --i)
{
if (parts[i].Path.IsVisible(graphPt))
{
hitPart = parts[i];
break;
}
}
if (hitPart != hoveredPart)
{
hoveredPart = hitPart;
hoverPoint = e.Location;
Invalidate();
}
}
else if (hoveredPart != null)
{
hoveredPart = null;
Invalidate();
}
base.OnMouseMove(e);
}
@@ -386,17 +423,7 @@ namespace OpenNest.Controls
switch (e.KeyCode)
{
case Keys.Delete:
if (selectedCutOff != null)
{
Plate.CutOffs.Remove(selectedCutOff);
selectedCutOff = null;
Plate.RegenerateCutOffs(cutOffSettings);
Invalidate();
}
else
{
RemoveSelectedParts();
}
selection.DeleteSelected();
break;
case Keys.F:
@@ -440,22 +467,22 @@ namespace OpenNest.Controls
case Keys.X:
case Keys.Shift | Keys.Left:
PushSelected(PushDirection.Left);
selection.PushSelected(PushDirection.Left);
break;
case Keys.Shift | Keys.X:
case Keys.Shift | Keys.Right:
PushSelected(PushDirection.Right);
selection.PushSelected(PushDirection.Right);
break;
case Keys.Shift | Keys.Y:
case Keys.Shift | Keys.Up:
PushSelected(PushDirection.Up);
selection.PushSelected(PushDirection.Up);
break;
case Keys.Y:
case Keys.Shift | Keys.Down:
PushSelected(PushDirection.Down);
selection.PushSelected(PushDirection.Down);
break;
case Keys.Right:
@@ -496,6 +523,26 @@ namespace OpenNest.Controls
renderer.DrawDebugRemnants(e.Graphics);
base.OnPaint(e);
if (hoveredPart != null)
{
e.Graphics.ResetTransform();
var text = hoveredPart.BasePart.BaseDrawing.Name;
var size = e.Graphics.MeasureString(text, Font);
var x = hoverPoint.X + 16f;
var y = hoverPoint.Y - size.Height - 6f;
if (x + size.Width + 8 > ClientSize.Width)
x = hoverPoint.X - size.Width - 8;
if (y < 0)
y = hoverPoint.Y + 20;
var rect = new RectangleF(x, y, size.Width + 6, size.Height + 4);
using (var bgBrush = new SolidBrush(Color.FromArgb(230, Color.White)))
e.Graphics.FillRectangle(bgBrush, rect);
e.Graphics.DrawRectangle(Pens.DimGray, rect.X, rect.Y, rect.Width, rect.Height);
e.Graphics.DrawString(text, Font, Brushes.Black, x + 3, y + 2);
}
}
protected override void OnHandleDestroyed(EventArgs e)
@@ -508,6 +555,7 @@ namespace OpenNest.Controls
currentAction.DisconnectEvents();
currentAction = null;
}
}
public override void Refresh()
@@ -544,62 +592,10 @@ namespace OpenNest.Controls
return null;
}
public LayoutPart GetPartAtControlPoint(Point pt)
{
var pt2 = PointControlToGraph(pt);
return GetPartAtGraphPoint(pt2);
}
public LayoutPart GetPartAtGraphPoint(PointF pt)
{
for (int i = parts.Count - 1; i >= 0; --i)
{
if (parts[i].Path.IsVisible(pt))
return parts[i];
}
return null;
}
public LayoutPart GetPartAtPoint(Vector pt)
{
var pt2 = PointWorldToGraph(pt);
return GetPartAtGraphPoint(pt2);
}
public IList<LayoutPart> GetPartsFromWindow(RectangleF rect, SelectionType selectionType)
{
var list = new List<LayoutPart>();
if (selectionType == SelectionType.Intersect)
{
for (int i = 0; i < parts.Count; ++i)
{
var part = parts[i];
var path = part.Path;
var region = new Region(path);
if (region.IsVisible(rect))
list.Add(part);
region.Dispose();
}
}
else
{
for (int i = 0; i < parts.Count; ++i)
{
var part = parts[i];
var path = part.Path;
var bounds = path.GetBounds();
if (rect.Contains(bounds))
list.Add(part);
}
}
return list;
}
public LayoutPart GetPartAtControlPoint(Point pt) => selection.GetPartAtControlPoint(pt);
public LayoutPart GetPartAtGraphPoint(PointF pt) => selection.GetPartAtGraphPoint(pt);
public LayoutPart GetPartAtPoint(Vector pt) => selection.GetPartAtPoint(pt);
public IList<LayoutPart> GetPartsFromWindow(RectangleF rect, SelectionType selectionType) => selection.GetPartsFromWindow(rect, selectionType);
public void SetAction(Type type)
{
@@ -668,57 +664,8 @@ namespace OpenNest.Controls
Status = GetDisplayName(action.GetType());
}
public void AlignSelected(AlignType alignType)
{
if (SelectedParts.Count == 0)
return;
AlignSelected(alignType, SelectedParts[0]);
}
public void AlignSelected(AlignType alignType, LayoutPart fixedPart)
{
switch (alignType)
{
case AlignType.Bottom:
Align.Bottom(fixedPart.BasePart, SelectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.Horizontally:
Align.Horizontally(fixedPart.BasePart, SelectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.Left:
Align.Left(fixedPart.BasePart, SelectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.Right:
Align.Right(fixedPart.BasePart, SelectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.Top:
Align.Top(fixedPart.BasePart, SelectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.Vertically:
Align.Vertically(fixedPart.BasePart, SelectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.EvenlySpaceHorizontally:
Align.EvenlyDistributeHorizontally(SelectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.EvenlySpaceVertically:
Align.EvenlyDistributeVertically(SelectedParts.Select(p => p.BasePart).ToList());
break;
default:
return;
}
SelectedParts.ForEach(p => p.IsDirty = true);
Invalidate();
}
public void AlignSelected(AlignType alignType) => selection.AlignSelected(alignType);
public void AlignSelected(AlignType alignType, LayoutPart fixedPart) => selection.AlignSelected(alignType, fixedPart);
public void AddPartFromDrawing(Drawing dwg, Vector location)
{
@@ -848,14 +795,7 @@ namespace OpenNest.Controls
}
}
public void RemoveSelectedParts()
{
foreach (var part in SelectedParts)
Plate.Parts.Remove(part.BasePart);
DeselectAll();
Invalidate();
}
public void RemoveSelectedParts() => selection.RemoveSelectedParts();
private void redrawTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
@@ -880,24 +820,9 @@ namespace OpenNest.Controls
parts.RemoveAll(p => p.BasePart == e.Item);
}
public void DeselectAll()
{
SelectedParts.ForEach(p => p.IsSelected = false);
SelectedParts.Clear();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
public void SelectAll()
{
parts.ForEach(p => p.IsSelected = true);
SelectedParts.AddRange(parts);
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
public void NotifySelectionChanged()
{
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
public void DeselectAll() => selection.DeselectAll();
public void SelectAll() => selection.SelectAll();
public void NotifySelectionChanged() => selection.NotifySelectionChanged();
public override void ZoomToPoint(Vector pt, float zoomFactor, bool redraw = true)
{
@@ -930,13 +855,7 @@ namespace OpenNest.Controls
ZoomToArea(plate.BoundingBox(false), redraw);
}
public void PushSelected(PushDirection direction)
{
var movingParts = SelectedParts.Select(p => p.BasePart).ToList();
Compactor.Push(movingParts, Plate, direction);
SelectedParts.ForEach(p => p.IsDirty = true);
Invalidate();
}
public void PushSelected(PushDirection direction) => selection.PushSelected(direction);
private string GetDisplayName(Type type)
{
@@ -953,27 +872,7 @@ namespace OpenNest.Controls
return type.Name;
}
public void RotateSelectedParts(double angle)
{
var parts = SelectedParts.Select(p => p.BasePart).ToList();
var bounds = parts.GetBoundingBox();
var center = bounds.Center;
var anchor = bounds.Location;
for (var i = 0; i < SelectedParts.Count; ++i)
{
var part = SelectedParts[i];
part.BasePart.Rotate(angle, center);
}
var diff = anchor - parts.GetBoundingBox().Location;
for (var i = 0; i < SelectedParts.Count; ++i)
SelectedParts[i].Offset(diff);
if (Plate.CutOffs.Count > 0)
Plate.RegenerateCutOffs(cutOffSettings);
}
public void RotateSelectedParts(double angle) => selection.RotateSelectedParts(angle);
protected override void UpdateMatrix()
{

View File

@@ -0,0 +1,218 @@
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Drawing;
using System.Linq;
namespace OpenNest.Controls
{
internal class SelectionManager
{
private readonly PlateView view;
private readonly List<LayoutPart> selectedParts = new List<LayoutPart>();
private readonly List<CutOff> selectedCutOffs = new List<CutOff>();
public SelectionManager(PlateView view)
{
this.view = view;
}
public List<LayoutPart> SelectedParts => selectedParts;
public List<CutOff> SelectedCutOffs => selectedCutOffs;
public event EventHandler SelectionChanged;
public void DeselectAll()
{
selectedParts.ForEach(p => p.IsSelected = false);
selectedParts.Clear();
selectedCutOffs.Clear();
SelectionChanged?.Invoke(view, EventArgs.Empty);
}
public void DeselectParts()
{
selectedParts.ForEach(p => p.IsSelected = false);
selectedParts.Clear();
SelectionChanged?.Invoke(view, EventArgs.Empty);
}
public void DeselectCutOffs()
{
selectedCutOffs.Clear();
view.Invalidate();
}
public void SelectAll()
{
var parts = view.LayoutParts;
parts.ForEach(p => p.IsSelected = true);
selectedParts.AddRange(parts);
SelectionChanged?.Invoke(view, EventArgs.Empty);
}
public void NotifySelectionChanged()
{
SelectionChanged?.Invoke(view, EventArgs.Empty);
}
public void DeleteSelected()
{
if (selectedCutOffs.Count > 0)
{
foreach (var cutOff in selectedCutOffs)
view.Plate.CutOffs.Remove(cutOff);
selectedCutOffs.Clear();
view.Plate.RegenerateCutOffs(view.CutOffSettings);
view.Invalidate();
}
else
{
RemoveSelectedParts();
}
}
public void RemoveSelectedParts()
{
foreach (var part in selectedParts)
view.Plate.Parts.Remove(part.BasePart);
DeselectAll();
view.Invalidate();
}
public void AlignSelected(AlignType alignType)
{
if (selectedParts.Count == 0)
return;
AlignSelected(alignType, selectedParts[0]);
}
public void AlignSelected(AlignType alignType, LayoutPart fixedPart)
{
switch (alignType)
{
case AlignType.Bottom:
Align.Bottom(fixedPart.BasePart, selectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.Horizontally:
Align.Horizontally(fixedPart.BasePart, selectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.Left:
Align.Left(fixedPart.BasePart, selectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.Right:
Align.Right(fixedPart.BasePart, selectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.Top:
Align.Top(fixedPart.BasePart, selectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.Vertically:
Align.Vertically(fixedPart.BasePart, selectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.EvenlySpaceHorizontally:
Align.EvenlyDistributeHorizontally(selectedParts.Select(p => p.BasePart).ToList());
break;
case AlignType.EvenlySpaceVertically:
Align.EvenlyDistributeVertically(selectedParts.Select(p => p.BasePart).ToList());
break;
default:
return;
}
selectedParts.ForEach(p => p.IsDirty = true);
view.Invalidate();
}
public void RotateSelectedParts(double angle)
{
var parts = selectedParts.Select(p => p.BasePart).ToList();
var bounds = parts.GetBoundingBox();
var center = bounds.Center;
var anchor = bounds.Location;
for (var i = 0; i < selectedParts.Count; ++i)
selectedParts[i].BasePart.Rotate(angle, center);
var diff = anchor - parts.GetBoundingBox().Location;
for (var i = 0; i < selectedParts.Count; ++i)
selectedParts[i].Offset(diff);
if (view.Plate.CutOffs.Count > 0)
view.Plate.RegenerateCutOffs(view.CutOffSettings);
}
public void PushSelected(PushDirection direction)
{
var movingParts = selectedParts.Select(p => p.BasePart).ToList();
Compactor.Push(movingParts, view.Plate, direction);
selectedParts.ForEach(p => p.IsDirty = true);
view.Invalidate();
}
public LayoutPart GetPartAtControlPoint(Point pt)
{
var pt2 = view.PointControlToGraph(pt);
return GetPartAtGraphPoint(pt2);
}
public LayoutPart GetPartAtGraphPoint(PointF pt)
{
var parts = view.LayoutParts;
for (var i = parts.Count - 1; i >= 0; --i)
{
if (parts[i].Path.IsVisible(pt))
return parts[i];
}
return null;
}
public LayoutPart GetPartAtPoint(Vector pt)
{
var pt2 = view.PointWorldToGraph(pt);
return GetPartAtGraphPoint(pt2);
}
public IList<LayoutPart> GetPartsFromWindow(RectangleF rect, SelectionType selectionType)
{
var list = new List<LayoutPart>();
var parts = view.LayoutParts;
if (selectionType == SelectionType.Intersect)
{
for (var i = 0; i < parts.Count; ++i)
{
var part = parts[i];
var region = new Region(part.Path);
if (region.IsVisible(rect))
list.Add(part);
region.Dispose();
}
}
else
{
for (var i = 0; i < parts.Count; ++i)
{
var part = parts[i];
var bounds = part.Path.GetBounds();
if (rect.Contains(bounds))
list.Add(part);
}
}
return list;
}
public void Clear()
{
selectedParts.Clear();
selectedCutOffs.Clear();
}
}
}