diff --git a/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs b/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs index 44a3a720..ca939aeb 100644 --- a/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs @@ -259,6 +259,46 @@ public class ShapeLibraryFormTests Assert.InRange(preview.LabelBounds("Length")!.Value.X, bounds.X + 9, bounds.X + 11); }); + [Theory] + [InlineData(120, false)] + [InlineData(-120, false)] + [InlineData(120, true)] + [InlineData(-120, true)] + public void DimensionsKeepTheirPlaceOnTheDrawingAsTheViewZooms(int wheel, bool dragged) => ArchUnitsTests.RunSta(() => + { + using var form = new ShapeLibraryForm(); + SelectShape(form, typeof(RectangleShape)); + var preview = Find(form, "previewBox"); + var box = preview.Plate.Parts.Single().BoundingBox; + if (dragged) + { + preview.SetDragOffset("Length", new PointF(0, 25)); + preview.SetDragOffset("Width", new PointF(30, 0)); + } + + // Each dimension line's distance from the drawing's corner, in drawing units. + (double Length, double Width) Standoffs() + { + var corner = preview.PointWorldToControl(box.X, box.Y); + var length = preview.LabelBounds("Length")!.Value; + var width = preview.LabelBounds("Width")!.Value; + return ((length.Y + length.Height / 2.0 - corner.Y) / preview.ViewScale, + (width.X + width.Width / 2.0 - corner.X) / preview.ViewScale); + } + + var before = Standoffs(); + var fitted = preview.ViewScale; + for (var i = 0; i < 2; i++) + Mouse(preview, "OnMouseWheel", MouseButtons.None, new Point(60, 60), wheel); + var after = Standoffs(); + + Assert.NotEqual(fitted, preview.ViewScale); + // Label bounds round to whole pixels. + var pixel = 1.5 / System.Math.Min(fitted, preview.ViewScale); + Assert.InRange(after.Length, before.Length - pixel, before.Length + pixel); + Assert.InRange(after.Width, before.Width - pixel, before.Width + pixel); + }); + [Fact] public void WheelZoomIsKeptWhileEditingAndFitResetsIt() => ArchUnitsTests.RunSta(() => { diff --git a/OpenNest/Controls/ShapePreviewControl.cs b/OpenNest/Controls/ShapePreviewControl.cs index 5dbdafa9..abe9127f 100644 --- a/OpenNest/Controls/ShapePreviewControl.cs +++ b/OpenNest/Controls/ShapePreviewControl.cs @@ -16,11 +16,14 @@ namespace OpenNest.Controls /// raises when one is clicked, as Edit Clamps does on the plate. /// Dragging a dimension's label moves the dimension; the wheel zooms, the middle button /// pans, and F or a middle double-click fits the view again. Until the view is zoomed or - /// panned by hand, it refits whenever the drawing or its dimensions change. + /// panned by hand, it refits whenever the drawing or its dimensions change. Dimension lines + /// and labels keep their place on the drawing as the view zooms; only text, arrows and + /// extension-line ends keep their pixel size. /// public class ShapePreviewControl : PlateView { - // Screen sizes, in pixels. + // Screen sizes, in pixels. Gap, MinimumGap, LeaderClearance and Shoulder are sizes at the + // fitted view: they grow and shrink with the zoom, so dimensions stay put on the drawing. private const float Gap = 18f; private const float ExtensionOverrun = 5f; private const float ArrowLength = 8f; @@ -56,6 +59,8 @@ namespace OpenNest.Controls private string hoverKey; private string activeKey; private bool userView; + // The zoom of the last fit, where the standoffs are their nominal pixel sizes. + private float fitScale; // Each dimension's drag offset, keyed by its property; kept until the shape changes. private readonly Dictionary dragOffsets = new(); @@ -203,6 +208,7 @@ namespace OpenNest.Controls if (Plate.Parts.Count == 0 || Width <= 0 || Height <= 0) { base.ZoomToFit(redraw); + fitScale = ViewScale; return; } @@ -210,6 +216,7 @@ namespace OpenNest.Controls if (box.Length <= 0 || box.Width <= 0) { base.ZoomToFit(redraw); + fitScale = ViewScale; return; } @@ -231,6 +238,7 @@ namespace OpenNest.Controls break; ViewScale = scale; + fitScale = scale; origin.X = ml + (w - (float)box.Length * scale) / 2 - (float)box.X * scale; origin.Y = mt + (h - (float)box.Width * scale) / 2 + (float)(box.Y + box.Width) * scale; UpdateMatrix(); @@ -480,8 +488,7 @@ namespace OpenNest.Controls /// /// 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. + /// . It scales with the drawing, so the dimension keeps its place on it. /// private readonly record struct LabelDrag(PointF Pixels, float Scale); @@ -581,9 +588,10 @@ namespace OpenNest.Controls // A drag moves the line out (or back, never onto the drawing) and the label along it. var drag = DragPixels(d.Property); var standoff = Dot(drag, side); - var gap = System.Math.Max(Gap + standoff, MinimumGap); + var zoom = Zoom; + var gap = System.Math.Max(Gap * zoom + standoff, MinimumGap * zoom); var along = new PointF(-side.Y, side.X); - var shift = AlongShift(d.Property, along); + var shift = Dot(drag, along); var reachA = extreme - Dot(a, side) + gap; var reachB = extreme - Dot(b, side) + gap; @@ -636,17 +644,20 @@ namespace OpenNest.Controls result.Circles.Add((ToScreen(center), LengthWorldToGui(d.CircleRadius))); } - private PointF DragPixels(string property) => - dragOffsets.TryGetValue(property, out var drag) ? drag.Pixels : PointF.Empty; + /// The current zoom relative to the fitted view: 1 until zoomed by hand. + private float Zoom => fitScale > 0 ? ViewScale / fitScale : 1; /// - /// 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. + /// A dimension's drag in pixels at this zoom: it grows and shrinks with the drawing, so + /// the dimension keeps its place on it. /// - private float AlongShift(string property, PointF along) => - dragOffsets.TryGetValue(property, out var drag) && drag.Scale > 0 - ? Dot(drag.Pixels, along) * ViewScale / drag.Scale - : 0; + private PointF DragPixels(string property) + { + if (!dragOffsets.TryGetValue(property, out var drag)) + return PointF.Empty; + var zoom = drag.Scale > 0 ? ViewScale / drag.Scale : 1; + return new PointF(drag.Pixels.X * zoom, drag.Pixels.Y * zoom); + } /// /// The labels centered on the line from to , @@ -672,7 +683,7 @@ 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); - var shift = AlongShift(annotation.Dimension.Property, along); + var shift = Dot(DragPixels(annotation.Dimension.Property), along); AddLabelAlong(result, annotation, a, b, along, shift, Along(PointF.Empty, along, shift)); } @@ -685,13 +696,14 @@ namespace OpenNest.Controls // Run out of the drawing's bounds, then a short level shoulder to the label. var exitX = dir.X > 0 ? (shape.Right - p.X) / dir.X : dir.X < 0 ? (shape.Left - p.X) / dir.X : float.MaxValue; 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; + var zoom = Zoom; + var length = System.Math.Max(System.Math.Min(exitX, exitY), 0) + LeaderClearance * zoom; // A drag moves the label end anywhere; the leader swings to follow. 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; - var shoulder = new PointF(end.X + (right ? Shoulder : -Shoulder), end.Y); + var shoulder = new PointF(end.X + (right ? Shoulder : -Shoulder) * zoom, end.Y); AddReferenceCircle(result, d); if (!AddHoleDiameter(result, d, end, dir))