mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-06 18:05:44 -05:00
Moving one member of a common-line group used to break the group, so a drag or nudge silently undid a join. Selecting a member, by click or window, now selects its whole group, so drag, nudge, push, rotate and align carry the joined parts together and keep their shared spans. Right-clicking a part now offers Join Common Line and Remove Common Line, running the Plate > Arrange commands on the selection. A right-click on a part keeps its selection (or selects it with its group) instead of clearing it, so the menu has something to act on; a right-click on empty plate still clears. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
484 lines
18 KiB
C#
484 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Drawing;
|
|
using System.Linq;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using OpenNest.CNC;
|
|
using OpenNest.Engine.Fill;
|
|
using OpenNest.Geometry;
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds <paramref name="part"/> to the selection with every other member of its
|
|
/// common-line group, so the joined parts move, rotate and push as one unit and the
|
|
/// group's shared spans survive the edit (see docs/common-line-cutting.md).
|
|
/// </summary>
|
|
public void Select(LayoutPart part)
|
|
{
|
|
foreach (var layout in WithCommonLineGroup(part))
|
|
{
|
|
if (selectedParts.Contains(layout))
|
|
continue;
|
|
selectedParts.Add(layout);
|
|
layout.IsSelected = true;
|
|
}
|
|
}
|
|
|
|
private IEnumerable<LayoutPart> WithCommonLineGroup(LayoutPart part)
|
|
{
|
|
yield return part;
|
|
var group = view.Plate?.CommonLineGroupOf(part.BasePart);
|
|
if (group == null)
|
|
yield break;
|
|
foreach (var layout in view.LayoutParts)
|
|
{
|
|
if (!ReferenceEquals(layout, part) && group.Contains(layout.BasePart))
|
|
yield return layout;
|
|
}
|
|
}
|
|
|
|
public void NotifySelectionChanged()
|
|
{
|
|
SelectionChanged?.Invoke(view, EventArgs.Empty);
|
|
}
|
|
|
|
public void DeleteSelected()
|
|
{
|
|
using var change = view.BeginChange("Delete");
|
|
var regenerateCutOffs = view.Plate.CutOffs.Count > 0
|
|
&& (selectedCutOffs.Count > 0 || selectedParts.Count > 0);
|
|
if (selectedCutOffs.Count > 0)
|
|
change.MarkChanged();
|
|
foreach (var cutOff in selectedCutOffs)
|
|
view.Plate.CutOffs.Remove(cutOff);
|
|
|
|
// Finish removing both kinds before rebuilding surviving cut strokes;
|
|
// otherwise they retain gaps around parts deleted in the same window.
|
|
RemoveSelectedParts();
|
|
if (regenerateCutOffs)
|
|
view.Plate.RegenerateCutOffs(view.CutOffSettings);
|
|
change.Complete();
|
|
}
|
|
|
|
public void RemoveSelectedParts()
|
|
{
|
|
using var change = view.BeginChange("Delete");
|
|
if (selectedParts.Count > 0)
|
|
change.MarkChanged();
|
|
foreach (var part in selectedParts)
|
|
view.Plate.Parts.Remove(part.BasePart);
|
|
|
|
DeselectAll();
|
|
view.Invalidate();
|
|
change.Complete();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves the selection by (<paramref name="dx"/>, <paramref name="dy"/>); repeated
|
|
/// nudges of the same selection undo as one move.
|
|
/// </summary>
|
|
public void NudgeSelected(double dx, double dy)
|
|
{
|
|
if (selectedParts.Count == 0)
|
|
return;
|
|
|
|
using var change = view.BeginChange("Nudge", new SelectionKey("Nudge", view.Plate, selectedParts));
|
|
change.MarkChanged();
|
|
selectedParts.ForEach(part => part.Offset(dx, dy));
|
|
view.Invalidate();
|
|
change.Complete();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves the selection one repeat step in <paramref name="direction"/>: its own
|
|
/// size along that axis plus the part spacing, measured on its geometry
|
|
/// (<see cref="Compactor.Step"/>). Like a nudge it always moves the full step, even
|
|
/// off the plate or onto a part. Repeated steps undo as one move.
|
|
/// </summary>
|
|
public void StepSelected(PushDirection direction)
|
|
{
|
|
if (selectedParts.Count == 0)
|
|
return;
|
|
|
|
using var change = view.BeginChange("Nudge", new SelectionKey("Nudge", view.Plate, selectedParts));
|
|
var movingParts = selectedParts.Select(p => p.BasePart).ToList();
|
|
if (Compactor.Step(movingParts, view.Plate, direction) > 0)
|
|
change.MarkChanged();
|
|
selectedParts.ForEach(p => p.IsDirty = true);
|
|
view.Invalidate();
|
|
change.Complete();
|
|
}
|
|
|
|
public void AlignSelected(AlignType alignType)
|
|
{
|
|
if (selectedParts.Count == 0)
|
|
return;
|
|
|
|
AlignSelected(alignType, selectedParts[0]);
|
|
}
|
|
|
|
public void AlignSelected(AlignType alignType, LayoutPart fixedPart)
|
|
{
|
|
if (selectedParts.Count == 0)
|
|
return;
|
|
|
|
using var change = view.BeginChange("Align");
|
|
change.MarkChanged();
|
|
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:
|
|
// An unknown alignment moves nothing; completing records nothing either.
|
|
change.Complete();
|
|
return;
|
|
}
|
|
|
|
selectedParts.ForEach(p => p.IsDirty = true);
|
|
view.Invalidate();
|
|
change.Complete();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rotates the selection by <paramref name="angle"/> about its center, keeping its
|
|
/// lower-left corner; with <paramref name="coalesce"/>, quick repeats undo as one.
|
|
/// </summary>
|
|
public void RotateSelectedParts(double angle, bool coalesce = false)
|
|
{
|
|
if (selectedParts.Count == 0)
|
|
return;
|
|
|
|
using var change = view.BeginChange("Rotate",
|
|
coalesce ? new SelectionKey("Rotate", view.Plate, selectedParts) : null);
|
|
change.MarkChanged();
|
|
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);
|
|
change.Complete();
|
|
}
|
|
|
|
public void PushSelected(PushDirection direction)
|
|
{
|
|
if (selectedParts.Count == 0)
|
|
return;
|
|
|
|
using var change = view.BeginChange("Push");
|
|
change.MarkChanged();
|
|
var movingParts = selectedParts.Select(p => p.BasePart).ToList();
|
|
Compactor.Push(movingParts, view.Plate, direction);
|
|
selectedParts.ForEach(p => p.IsDirty = true);
|
|
view.Invalidate();
|
|
change.Complete();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Grows the part-to-part spacing of the selection as far as the plate
|
|
/// allows (Expander). <paramref name="run"/> executes the search and may
|
|
/// cancel it through its token (the desktop shows a Cancel window); by
|
|
/// default it runs synchronously. A canceled run moves no part. Returns
|
|
/// false only when fewer than two parts are selected.
|
|
/// </summary>
|
|
public bool ExpandSelected(
|
|
Func<Func<CancellationToken, Expander.Result>, Expander.Result> run = null
|
|
)
|
|
{
|
|
var movingParts = selectedParts.Select(p => p.BasePart).ToList();
|
|
|
|
if (movingParts.Count < 2)
|
|
return false;
|
|
|
|
var plate = view.Plate;
|
|
Expander.Result Work(CancellationToken token) =>
|
|
Expander.Expand(movingParts, plate, null, token);
|
|
|
|
using var change = view.BeginChange("Expand Spacing");
|
|
var result = run != null ? run(Work) : Work(CancellationToken.None);
|
|
|
|
if (result.Canceled)
|
|
{
|
|
view.Status = "Expand Spacing canceled - no parts were moved";
|
|
change.Complete();
|
|
return true;
|
|
}
|
|
|
|
change.MarkChanged();
|
|
selectedParts.ForEach(p => p.IsDirty = true);
|
|
|
|
if (view.Plate.CutOffs.Count > 0)
|
|
view.Plate.RegenerateCutOffs(view.CutOffSettings);
|
|
|
|
view.Invalidate();
|
|
|
|
view.Status = result.KeptPattern
|
|
? (result.PatternMoved
|
|
? $"Spread the repeating pattern - smallest gap {result.AchievedSpacing:0.###}"
|
|
: $"The repeating pattern has no room to spread - smallest gap {result.AchievedSpacing:0.###}")
|
|
: $"Expanded spacing to {result.AchievedSpacing:0.###}"
|
|
+ (result.Violations.Count > 0 ? $" - {result.Violations.Count} blocked pairs" : "");
|
|
|
|
change.Complete();
|
|
return true;
|
|
}
|
|
|
|
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].SelectionPath?.IsVisible(pt) == true)
|
|
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 path = part.SelectionPath;
|
|
if (path == null)
|
|
continue;
|
|
using var region = new Region(path);
|
|
if (region.IsVisible(rect))
|
|
list.Add(part);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
for (var i = 0; i < parts.Count; ++i)
|
|
{
|
|
var part = parts[i];
|
|
var path = part.SelectionPath;
|
|
if (path != null && rect.Contains(path.GetBounds()))
|
|
list.Add(part);
|
|
}
|
|
}
|
|
|
|
return list;
|
|
}
|
|
|
|
public IList<CutOff> GetCutOffsFromWindow(RectangleF rect, SelectionType selectionType)
|
|
{
|
|
var result = new List<CutOff>();
|
|
foreach (var cutOff in view.Plate.CutOffs)
|
|
{
|
|
var hasSegment = false;
|
|
var matches = selectionType == SelectionType.Contains;
|
|
// Match the actual cut strokes drawn by PlateRenderer, not the nominal
|
|
// full-length line: a gap around a part is not selectable material.
|
|
foreach (var stroke in CutOff.CutStrokes(cutOff.Drawing?.Program))
|
|
{
|
|
hasSegment = true;
|
|
var start = view.PointWorldToGraph(stroke.StartPoint);
|
|
var end = view.PointWorldToGraph(stroke.EndPoint);
|
|
var hit = selectionType == SelectionType.Contains
|
|
? ContainsInclusive(rect, start) && ContainsInclusive(rect, end)
|
|
: SegmentIntersects(rect, start, end);
|
|
if (selectionType == SelectionType.Intersect && hit)
|
|
{
|
|
matches = true;
|
|
break;
|
|
}
|
|
if (selectionType == SelectionType.Contains && !hit)
|
|
{
|
|
matches = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (hasSegment && matches)
|
|
result.Add(cutOff);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static bool ContainsInclusive(RectangleF rect, PointF point) =>
|
|
point.X >= rect.Left && point.X <= rect.Right
|
|
&& point.Y >= rect.Top && point.Y <= rect.Bottom;
|
|
|
|
private static bool SegmentIntersects(RectangleF rect, PointF start, PointF end)
|
|
{
|
|
if (ContainsInclusive(rect, start) || ContainsInclusive(rect, end))
|
|
return true;
|
|
|
|
// Clip the segment against all four window edges. Endpoints alone miss
|
|
// a stroke that enters and exits the window without ending inside it.
|
|
var min = 0.0;
|
|
var max = 1.0;
|
|
var dx = (double)end.X - start.X;
|
|
var dy = (double)end.Y - start.Y;
|
|
return Clip(-dx, start.X - rect.Left)
|
|
&& Clip(dx, rect.Right - start.X)
|
|
&& Clip(-dy, start.Y - rect.Top)
|
|
&& Clip(dy, rect.Bottom - start.Y);
|
|
|
|
bool Clip(double p, double q)
|
|
{
|
|
if (p == 0)
|
|
return q >= 0;
|
|
var t = q / p;
|
|
if (p < 0)
|
|
{
|
|
if (t > max)
|
|
return false;
|
|
min = System.Math.Max(min, t);
|
|
}
|
|
else
|
|
{
|
|
if (t < min)
|
|
return false;
|
|
max = System.Math.Min(max, t);
|
|
}
|
|
return min <= max;
|
|
}
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
selectedParts.Clear();
|
|
selectedCutOffs.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Equal for the same edit of the same parts on the same plate, so repeated nudges or
|
|
/// wheel turns coalesce into one undo entry.
|
|
/// </summary>
|
|
private sealed class SelectionKey : IEquatable<SelectionKey>
|
|
{
|
|
private readonly string operation;
|
|
private readonly Plate plate;
|
|
private readonly Part[] parts;
|
|
|
|
public SelectionKey(string operation, Plate plate, IEnumerable<LayoutPart> selection)
|
|
{
|
|
this.operation = operation;
|
|
this.plate = plate;
|
|
parts = selection.Select(p => p.BasePart).ToArray();
|
|
}
|
|
|
|
public bool Equals(SelectionKey other) =>
|
|
other != null
|
|
&& operation == other.operation
|
|
&& ReferenceEquals(plate, other.plate)
|
|
&& parts.Length == other.parts.Length
|
|
&& parts.Zip(other.parts).All(pair => ReferenceEquals(pair.First, pair.Second));
|
|
|
|
public override bool Equals(object obj) => Equals(obj as SelectionKey);
|
|
|
|
public override int GetHashCode() => HashCode.Combine(operation, RuntimeHelpers.GetHashCode(plate), parts.Length);
|
|
}
|
|
}
|
|
}
|