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 currentAction;
private Action previousAction; private Action previousAction;
private CutOffSettings cutOffSettings = new CutOffSettings(); private CutOffSettings cutOffSettings = new CutOffSettings();
private CutOff selectedCutOff; private SelectionManager selection;
private bool draggingCutOff; private bool draggingCutOff;
private Dictionary<Part, Geometry.Entity> dragPerimeterCache; private Dictionary<Part, Geometry.Entity> dragPerimeterCache;
protected List<LayoutPart> parts; protected List<LayoutPart> parts;
@@ -41,6 +41,8 @@ namespace OpenNest.Controls
private Box activeWorkArea; private Box activeWorkArea;
private List<Box> debugRemnants; private List<Box> debugRemnants;
private PlateRenderer renderer; private PlateRenderer renderer;
private LayoutPart hoveredPart;
private Point hoverPoint;
public Box ActiveWorkArea public Box ActiveWorkArea
{ {
@@ -64,13 +66,20 @@ namespace OpenNest.Controls
public List<int> DebugRemnantPriorities { get; set; } public List<int> DebugRemnantPriorities { get; set; }
public List<LayoutPart> SelectedParts; public List<LayoutPart> SelectedParts => selection.SelectedParts;
public ReadOnlyCollection<LayoutPart> Parts; public ReadOnlyCollection<LayoutPart> Parts => new ReadOnlyCollection<LayoutPart>(parts);
internal SelectionManager Selection => selection;
public event EventHandler<ItemAddedEventArgs<Part>> PartAdded; public event EventHandler<ItemAddedEventArgs<Part>> PartAdded;
public event EventHandler<ItemRemovedEventArgs<Part>> PartRemoved; public event EventHandler<ItemRemovedEventArgs<Part>> PartRemoved;
public event EventHandler StatusChanged; public event EventHandler StatusChanged;
public event EventHandler SelectionChanged;
public event EventHandler SelectionChanged
{
add => selection.SelectionChanged += value;
remove => selection.SelectionChanged -= value;
}
public PlateView() public PlateView()
: this(ColorScheme.Default) : this(ColorScheme.Default)
@@ -83,8 +92,7 @@ namespace OpenNest.Controls
programIdFont = new Font(DefaultFont, FontStyle.Bold | FontStyle.Underline); programIdFont = new Font(DefaultFont, FontStyle.Bold | FontStyle.Underline);
origin = new PointF(); origin = new PointF();
parts = new List<LayoutPart>(); parts = new List<LayoutPart>();
Parts = new ReadOnlyCollection<LayoutPart>(parts); selection = new SelectionManager(this);
SelectedParts = new List<LayoutPart>();
redrawTimer = new Timer() redrawTimer = new Timer()
{ {
@@ -173,12 +181,15 @@ namespace OpenNest.Controls
} }
} }
// Temporary — removed in Task 5
public CutOff SelectedCutOff public CutOff SelectedCutOff
{ {
get => selectedCutOff; get => selection.SelectedCutOffs.Count > 0 ? selection.SelectedCutOffs[0] : null;
set set
{ {
selectedCutOff = value; selection.SelectedCutOffs.Clear();
if (value != null)
selection.SelectedCutOffs.Add(value);
Invalidate(); Invalidate();
} }
} }
@@ -202,7 +213,7 @@ namespace OpenNest.Controls
parts.Clear(); parts.Clear();
stationaryParts.Clear(); stationaryParts.Clear();
activeParts.Clear(); activeParts.Clear();
SelectedParts.Clear(); selection.Clear();
} }
plate = p; plate = p;
@@ -288,12 +299,12 @@ namespace OpenNest.Controls
if (dx * dx + dy * dy < 25) if (dx * dx + dy * dy < 25)
{ {
RotateSelectedParts(Angle.ToRadians(90)); selection.RotateSelectedParts(Angle.ToRadians(90));
Invalidate(); Invalidate();
} }
} }
if (draggingCutOff && selectedCutOff != null) if (draggingCutOff && SelectedCutOff != null)
{ {
draggingCutOff = false; draggingCutOff = false;
dragPerimeterCache = null; dragPerimeterCache = null;
@@ -319,7 +330,7 @@ namespace OpenNest.Controls
var angle = Angle.ToRadians((e.Delta > 0 ? -increment : increment) * multiplier); var angle = Angle.ToRadians((e.Delta > 0 ? -increment : increment) * multiplier);
RotateSelectedParts(angle); selection.RotateSelectedParts(angle);
} }
else else
{ {
@@ -358,18 +369,44 @@ namespace OpenNest.Controls
lastPoint = e.Location; lastPoint = e.Location;
if (draggingCutOff && selectedCutOff != null) if (draggingCutOff && SelectedCutOff != null)
{ {
if (selectedCutOff.Axis == CutOffAxis.Vertical) if (SelectedCutOff.Axis == CutOffAxis.Vertical)
selectedCutOff.Position = new Vector(CurrentPoint.X, selectedCutOff.Position.Y); SelectedCutOff.Position = new Vector(CurrentPoint.X, SelectedCutOff.Position.Y);
else 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(); Invalidate();
return; 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); base.OnMouseMove(e);
} }
@@ -386,17 +423,7 @@ namespace OpenNest.Controls
switch (e.KeyCode) switch (e.KeyCode)
{ {
case Keys.Delete: case Keys.Delete:
if (selectedCutOff != null) selection.DeleteSelected();
{
Plate.CutOffs.Remove(selectedCutOff);
selectedCutOff = null;
Plate.RegenerateCutOffs(cutOffSettings);
Invalidate();
}
else
{
RemoveSelectedParts();
}
break; break;
case Keys.F: case Keys.F:
@@ -440,22 +467,22 @@ namespace OpenNest.Controls
case Keys.X: case Keys.X:
case Keys.Shift | Keys.Left: case Keys.Shift | Keys.Left:
PushSelected(PushDirection.Left); selection.PushSelected(PushDirection.Left);
break; break;
case Keys.Shift | Keys.X: case Keys.Shift | Keys.X:
case Keys.Shift | Keys.Right: case Keys.Shift | Keys.Right:
PushSelected(PushDirection.Right); selection.PushSelected(PushDirection.Right);
break; break;
case Keys.Shift | Keys.Y: case Keys.Shift | Keys.Y:
case Keys.Shift | Keys.Up: case Keys.Shift | Keys.Up:
PushSelected(PushDirection.Up); selection.PushSelected(PushDirection.Up);
break; break;
case Keys.Y: case Keys.Y:
case Keys.Shift | Keys.Down: case Keys.Shift | Keys.Down:
PushSelected(PushDirection.Down); selection.PushSelected(PushDirection.Down);
break; break;
case Keys.Right: case Keys.Right:
@@ -496,6 +523,26 @@ namespace OpenNest.Controls
renderer.DrawDebugRemnants(e.Graphics); renderer.DrawDebugRemnants(e.Graphics);
base.OnPaint(e); 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) protected override void OnHandleDestroyed(EventArgs e)
@@ -508,6 +555,7 @@ namespace OpenNest.Controls
currentAction.DisconnectEvents(); currentAction.DisconnectEvents();
currentAction = null; currentAction = null;
} }
} }
public override void Refresh() public override void Refresh()
@@ -544,62 +592,10 @@ namespace OpenNest.Controls
return null; return null;
} }
public LayoutPart GetPartAtControlPoint(Point pt) public LayoutPart GetPartAtControlPoint(Point pt) => selection.GetPartAtControlPoint(pt);
{ public LayoutPart GetPartAtGraphPoint(PointF pt) => selection.GetPartAtGraphPoint(pt);
var pt2 = PointControlToGraph(pt); public LayoutPart GetPartAtPoint(Vector pt) => selection.GetPartAtPoint(pt);
return GetPartAtGraphPoint(pt2); public IList<LayoutPart> GetPartsFromWindow(RectangleF rect, SelectionType selectionType) => selection.GetPartsFromWindow(rect, selectionType);
}
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 void SetAction(Type type) public void SetAction(Type type)
{ {
@@ -668,57 +664,8 @@ namespace OpenNest.Controls
Status = GetDisplayName(action.GetType()); Status = GetDisplayName(action.GetType());
} }
public void AlignSelected(AlignType alignType) public void AlignSelected(AlignType alignType) => selection.AlignSelected(alignType);
{ public void AlignSelected(AlignType alignType, LayoutPart fixedPart) => selection.AlignSelected(alignType, fixedPart);
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 AddPartFromDrawing(Drawing dwg, Vector location) public void AddPartFromDrawing(Drawing dwg, Vector location)
{ {
@@ -848,14 +795,7 @@ namespace OpenNest.Controls
} }
} }
public void RemoveSelectedParts() public void RemoveSelectedParts() => selection.RemoveSelectedParts();
{
foreach (var part in SelectedParts)
Plate.Parts.Remove(part.BasePart);
DeselectAll();
Invalidate();
}
private void redrawTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) private void redrawTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
@@ -880,24 +820,9 @@ namespace OpenNest.Controls
parts.RemoveAll(p => p.BasePart == e.Item); parts.RemoveAll(p => p.BasePart == e.Item);
} }
public void DeselectAll() public void DeselectAll() => selection.DeselectAll();
{ public void SelectAll() => selection.SelectAll();
SelectedParts.ForEach(p => p.IsSelected = false); public void NotifySelectionChanged() => selection.NotifySelectionChanged();
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 override void ZoomToPoint(Vector pt, float zoomFactor, bool redraw = true) public override void ZoomToPoint(Vector pt, float zoomFactor, bool redraw = true)
{ {
@@ -930,13 +855,7 @@ namespace OpenNest.Controls
ZoomToArea(plate.BoundingBox(false), redraw); ZoomToArea(plate.BoundingBox(false), redraw);
} }
public void PushSelected(PushDirection direction) public void PushSelected(PushDirection direction) => selection.PushSelected(direction);
{
var movingParts = SelectedParts.Select(p => p.BasePart).ToList();
Compactor.Push(movingParts, Plate, direction);
SelectedParts.ForEach(p => p.IsDirty = true);
Invalidate();
}
private string GetDisplayName(Type type) private string GetDisplayName(Type type)
{ {
@@ -953,27 +872,7 @@ namespace OpenNest.Controls
return type.Name; return type.Name;
} }
public void RotateSelectedParts(double angle) public void RotateSelectedParts(double angle) => selection.RotateSelectedParts(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);
}
protected override void UpdateMatrix() 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();
}
}
}