diff --git a/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs b/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs index 19d2dda8..976efd1f 100644 --- a/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs @@ -198,6 +198,41 @@ public class ShapeLibraryFormTests Assert.True(after.Y > before.Y - 20); }); + [Fact] + public void ASlidLabelKeepsItsPlaceBetweenTheArrowsAsTheViewZooms() => ArchUnitsTests.RunSta(() => + { + using var form = new ShapeLibraryForm(); + SelectShape(form, typeof(RectangleShape)); + var preview = Find(form, "previewBox"); + var box = preview.Plate.Parts.Single().BoundingBox; + + // Where the label's center sits along the drawing's width: 0 at the left arrow, 1 at the right. + double Place() + { + var bounds = preview.LabelBounds("Length")!.Value; + var left = preview.PointWorldToControl(box.X, box.Y).X; + var right = preview.PointWorldToControl(box.X + box.Length, box.Y).X; + return (bounds.X + bounds.Width / 2.0 - left) / (right - left); + } + + var width = preview.PointWorldToControl(box.X + box.Length, box.Y).X - preview.PointWorldToControl(box.X, box.Y).X; + preview.SetDragOffset("Length", new PointF(width * 0.3f, 0)); + var place = Place(); + Assert.InRange(place, 0.75, 0.85); + + for (var i = 0; i < 4; i++) + Mouse(preview, "OnMouseWheel", MouseButtons.None, new Point(40, 40), -120); + Assert.InRange(Place(), place - 0.02, place + 0.02); + + // Dragging again starts from where the label is now shown. + var bounds = preview.LabelBounds("Length")!.Value; + var grab = new Point(bounds.X + bounds.Width / 2, bounds.Y + bounds.Height / 2); + Mouse(preview, "OnMouseDown", MouseButtons.Left, grab); + Mouse(preview, "OnMouseMove", MouseButtons.Left, new Point(grab.X + 10, grab.Y)); + Mouse(preview, "OnMouseUp", MouseButtons.Left, new Point(grab.X + 10, grab.Y)); + Assert.InRange(preview.LabelBounds("Length")!.Value.X, bounds.X + 9, bounds.X + 11); + }); + [Fact] public void WheelZoomIsKeptWhileEditingAndFitResetsIt() => ArchUnitsTests.RunSta(() => { @@ -225,6 +260,37 @@ public class ShapeLibraryFormTests Assert.False(preview.IsUserView); }); + [Theory] + [InlineData(MouseButtons.Left)] + [InlineData(MouseButtons.Middle)] + public void DraggingTheViewPansItAndMovesTheLabels(MouseButtons button) => ArchUnitsTests.RunSta(() => + { + using var form = new ShapeLibraryForm(); + SelectShape(form, typeof(RectangleShape)); + var preview = Find(form, "previewBox"); + var scale = preview.ViewScale; + var before = preview.LabelBounds("Length")!.Value; + var start = new Point(preview.Width / 2, preview.Height / 2); + var end = new Point(start.X + 25, start.Y - 15); + + Mouse(preview, "OnMouseDown", button, start); + Mouse(preview, "OnMouseMove", button, end); + Mouse(preview, "OnMouseUp", button, end); + + var after = preview.LabelBounds("Length")!.Value; + Assert.Null(form.ActiveEditor); + Assert.True(preview.IsUserView); + Assert.Equal(scale, preview.ViewScale); + Assert.InRange(after.X, before.X + 24, before.X + 26); + Assert.InRange(after.Y, before.Y - 16, before.Y - 14); + + // A changed parameter keeps the panned view. + var box = OpenEditor(form, "Width"); + box.Text = "7"; + Press(box, Keys.Enter); + Assert.True(preview.IsUserView); + }); + [Fact] public void ShapeGridShowsAThumbnailForEveryShape() => ArchUnitsTests.RunSta(() => { diff --git a/OpenNest/Controls/ShapePreviewControl.cs b/OpenNest/Controls/ShapePreviewControl.cs index ce3d50c9..93d15916 100644 --- a/OpenNest/Controls/ShapePreviewControl.cs +++ b/OpenNest/Controls/ShapePreviewControl.cs @@ -54,13 +54,14 @@ namespace OpenNest.Controls private string activeKey; private bool userView; - // Each dimension's drag offset in pixels, keyed by its property; kept until the shape changes. - private readonly Dictionary dragOffsets = new(); + // Each dimension's drag offset, keyed by its property; kept until the shape changes. + private readonly Dictionary dragOffsets = new(); private string pressedKey; private string clickKey; private string dragKey; private Point pressPoint; private PointF pressOffset; + private bool panning; public ShapePreviewControl() { @@ -278,6 +279,22 @@ namespace OpenNest.Controls return; } + if (panning && e.Button == MouseButtons.Left) + { + var dx = e.X - lastPoint.X; + var dy = e.Y - lastPoint.Y; + lastPoint = e.Location; + if (dx == 0 && dy == 0 || Plate.Parts.Count == 0) + return; + origin.X += dx; + origin.Y += dy; + userView = true; + layout = null; + Invalidate(); + ViewChanged?.Invoke(this, EventArgs.Empty); + return; + } + if (pressedKey != null && e.Button == MouseButtons.Left) { if (dragKey == null @@ -285,7 +302,8 @@ namespace OpenNest.Controls && System.Math.Abs(e.Y - pressPoint.Y) <= DragThreshold) return; dragKey = pressedKey; - dragOffsets[dragKey] = new PointF(pressOffset.X + e.X - pressPoint.X, pressOffset.Y + e.Y - pressPoint.Y); + dragOffsets[dragKey] = new LabelDrag( + new PointF(pressOffset.X + e.X - pressPoint.X, pressOffset.Y + e.Y - pressPoint.Y), ViewScale); Cursor = Cursors.SizeAll; layout = null; Invalidate(); @@ -315,11 +333,22 @@ namespace OpenNest.Controls /// /// A press on a dimension's label waits for the release: a click opens it for editing, - /// a drag moves the dimension instead. A note opens at once; it has no drag. + /// a drag moves the dimension instead. A note opens at once; it has no drag. A left drag + /// anywhere else pans the view, as a middle drag does. /// protected override void OnMouseDown(MouseEventArgs e) { + // A pan measures from the press, not from wherever the mouse last hovered. + lastPoint = e.Location; var hit = e.Button == MouseButtons.Left ? HitTest(e.Location) : null; + if (hit == null && e.Button == MouseButtons.Left) + { + if (!Focused) + Focus(); + panning = true; + Cursor = Cursors.SizeAll; + return; + } if (hit == null) { base.OnMouseDown(e); @@ -337,11 +366,19 @@ namespace OpenNest.Controls clickKey = hit.Key; dragKey = null; pressPoint = e.Location; - pressOffset = dragOffsets.TryGetValue(pressedKey, out var offset) ? offset : PointF.Empty; + // The drag picks up from where the label is shown at this zoom. + pressOffset = hit.Drag; } protected override void OnMouseUp(MouseEventArgs e) { + if (panning && e.Button == MouseButtons.Left) + { + panning = false; + Cursor = Cursors.Default; + return; + } + if (e.Button != MouseButtons.Left || pressedKey == null) { base.OnMouseUp(e); @@ -356,10 +393,10 @@ namespace OpenNest.Controls LabelClicked?.Invoke(this, clicked); } - /// Moves a dimension pixels from its own place, as dragging its label does. + /// Moves a dimension pixels from its own place at this zoom, as dragging its label does. internal void SetDragOffset(string property, PointF offset) { - dragOffsets[property] = offset; + dragOffsets[property] = new LabelDrag(offset, ViewScale); layout = null; Invalidate(); ViewChanged?.Invoke(this, EventArgs.Empty); @@ -426,8 +463,18 @@ namespace OpenNest.Controls /// The property of the dimension a drag on this label moves; null for notes. public string DragKey; + + /// Where its dimension is dragged to at the current zoom, in pixels from its own place. + public PointF Drag; } + /// + /// A dimension's drag: from its own place when it was dragged at + /// . The slide along a dimension line scales with the drawing so the + /// label keeps its place between the arrows; the standoff from the drawing stays in pixels. + /// + private readonly record struct LabelDrag(PointF Pixels, float Scale); + private sealed class AnnotationLayout { public readonly List<(PointF A, PointF B)> Lines = new(); @@ -519,9 +566,11 @@ namespace OpenNest.Controls }.Max(c => Dot(c, side)); // A drag moves the line out (or back, never onto the drawing) and the label along it. - var drag = DragOffset(d.Property); - var gap = System.Math.Max(Gap + Dot(drag, side), MinimumGap); + var drag = DragPixels(d.Property); + var standoff = Dot(drag, side); + var gap = System.Math.Max(Gap + standoff, MinimumGap); var along = new PointF(-side.Y, side.X); + var shift = AlongShift(d.Property, along); var reachA = extreme - Dot(a, side) + gap; var reachB = extreme - Dot(b, side) + gap; @@ -531,7 +580,7 @@ namespace OpenNest.Controls result.Lines.Add((Along(a, side, ExtensionStart(d.Start, d.Side, reachA, shape)), Along(lineA, side, ExtensionOverrun))); result.Lines.Add((Along(b, side, ExtensionStart(d.End, d.Side, reachB, shape)), Along(lineB, side, ExtensionOverrun))); AddDimensionLine(result, lineA, lineB); - AddLabelAlong(result, annotation, lineA, lineB, along, Dot(drag, along)); + AddLabelAlong(result, annotation, lineA, lineB, along, shift, Along(Along(PointF.Empty, side, standoff), along, shift)); } /// @@ -574,15 +623,24 @@ namespace OpenNest.Controls result.Circles.Add((ToScreen(center), LengthWorldToGui(d.CircleRadius))); } - private PointF DragOffset(string property) => - dragOffsets.TryGetValue(property, out var offset) ? offset : PointF.Empty; + private PointF DragPixels(string property) => + dragOffsets.TryGetValue(property, out var drag) ? drag.Pixels : PointF.Empty; + + /// + /// How far a drag slid the label along , in pixels at this zoom: + /// it grows and shrinks with the drawing, so the label keeps its place between the arrows. + /// + private float AlongShift(string property, PointF along) => + dragOffsets.TryGetValue(property, out var drag) && drag.Scale > 0 + ? Dot(drag.Pixels, along) * ViewScale / drag.Scale + : 0; /// /// The labels centered on the line from to , /// moved pixels along . Moved past an /// end, the line runs on to them. /// - private void AddLabelAlong(AnnotationLayout result, Annotation annotation, PointF a, PointF b, PointF along, float shift) + private void AddLabelAlong(AnnotationLayout result, Annotation annotation, PointF a, PointF b, PointF along, float shift, PointF drag) { var center = Along(new PointF((a.X + b.X) / 2, (a.Y + b.Y) / 2), along, shift); var (low, high) = Dot(a, along) <= Dot(b, along) ? (a, b) : (b, a); @@ -590,7 +648,7 @@ namespace OpenNest.Controls result.Lines.Add((low, center)); else if (Dot(center, along) > Dot(high, along)) result.Lines.Add((high, center)); - AddLabels(result, annotation, center, 0.5f); + AddLabels(result, annotation, center, 0.5f, drag); } private void LayoutInline(AnnotationLayout result, Annotation annotation) @@ -601,7 +659,8 @@ namespace OpenNest.Controls // The line stays on its points; a drag slides only the label along it. var length = (float)System.Math.Sqrt(Dot(new PointF(b.X - a.X, b.Y - a.Y), new PointF(b.X - a.X, b.Y - a.Y))); var along = length > 0 ? new PointF((b.X - a.X) / length, (b.Y - a.Y) / length) : new PointF(1, 0); - AddLabelAlong(result, annotation, a, b, along, Dot(DragOffset(annotation.Dimension.Property), along)); + var shift = AlongShift(annotation.Dimension.Property, along); + AddLabelAlong(result, annotation, a, b, along, shift, Along(PointF.Empty, along, shift)); } private void LayoutLeader(AnnotationLayout result, Annotation annotation, RectangleF shape) @@ -615,7 +674,7 @@ namespace OpenNest.Controls var exitY = dir.Y > 0 ? (shape.Bottom - p.Y) / dir.Y : dir.Y < 0 ? (shape.Top - p.Y) / dir.Y : float.MaxValue; var length = System.Math.Max(System.Math.Min(exitX, exitY), 0) + LeaderClearance; // A drag moves the label end anywhere; the leader swings to follow. - var drag = DragOffset(d.Property); + var drag = DragPixels(d.Property); var end = Along(p, dir, length); end = new PointF(end.X + drag.X, end.Y + drag.Y); var right = end.X >= p.X; @@ -628,7 +687,7 @@ namespace OpenNest.Controls result.Arrows.Add((p, end)); } result.Lines.Add((end, shoulder)); - AddLabels(result, annotation, new PointF(shoulder.X + (right ? 2 : -2), shoulder.Y), right ? 0 : 1); + AddLabels(result, annotation, new PointF(shoulder.X + (right ? 2 : -2), shoulder.Y), right ? 0 : 1, drag); } /// @@ -669,7 +728,7 @@ namespace OpenNest.Controls /// The value label, and the count label joined after it, at /// (vertically centered; 0 starts there, 0.5 centers, 1 ends there). /// - private void AddLabels(AnnotationLayout result, Annotation annotation, PointF anchor, float alignX) + private void AddLabels(AnnotationLayout result, Annotation annotation, PointF anchor, float alignX, PointF drag) { var d = annotation.Dimension; var main = LabelSize(annotation.Text); @@ -683,6 +742,7 @@ namespace OpenNest.Controls { Key = d.Property, DragKey = d.Property, + Drag = drag, Text = annotation.Text, Bounds = new RectangleF(x, y, main.Width, main.Height), }); @@ -691,6 +751,7 @@ namespace OpenNest.Controls { Key = d.CountProperty, DragKey = d.Property, + Drag = drag, Text = annotation.CountText, Bounds = new RectangleF(x + main.Width, y, count.Width, count.Height), }); diff --git a/OpenNest/Forms/ShapeLibraryForm.Designer.cs b/OpenNest/Forms/ShapeLibraryForm.Designer.cs index 4f166b7a..cbfa0c8f 100644 --- a/OpenNest/Forms/ShapeLibraryForm.Designer.cs +++ b/OpenNest/Forms/ShapeLibraryForm.Designer.cs @@ -197,7 +197,7 @@ namespace OpenNest.Forms // previewBox // previewBox.ActiveWorkArea = null; - previewBox.AllowPan = false; + previewBox.AllowPan = true; previewBox.AllowSelect = false; previewBox.AllowZoom = false; colorScheme1.BackgroundColor = System.Drawing.Color.DarkGray;