feat(ui): pan the Shape Library preview and pin slid labels between the arrows

The preview's middle-drag pan was switched off by the form; turn it on and
let a left drag on empty space pan too. A panned view is kept while editing.

A label slid along its dimension line kept a fixed pixel offset, so zooming
out drifted it off center and past the arrows. Scale that slide with the
drawing; the standoff from the drawing stays in pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-08 14:34:32 -05:00
1 parent aa8616ea84
commit 757d366e4a
3 files changed
+146 -19

No files matched your search

@@ -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<ShapePreviewControl>(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<ShapePreviewControl>(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(() =>
{
+79 -18
View File
@@ -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<string, PointF> dragOffsets = new();
// Each dimension's drag offset, keyed by its property; kept until the shape changes.
private readonly Dictionary<string, LabelDrag> 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
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Moves a dimension <paramref name="offset"/> pixels from its own place, as dragging its label does.</summary>
/// <summary>Moves a dimension <paramref name="offset"/> pixels from its own place at this zoom, as dragging its label does.</summary>
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
/// <summary>The property of the dimension a drag on this label moves; null for notes.</summary>
public string DragKey;
/// <summary>Where its dimension is dragged to at the current zoom, in pixels from its own place.</summary>
public PointF Drag;
}
/// <summary>
/// A dimension's drag: <see cref="Pixels"/> from its own place when it was dragged at
/// <see cref="Scale"/>. 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.
/// </summary>
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));
}
/// <summary>
@@ -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;
/// <summary>
/// How far a drag slid the label along <paramref name="along"/>, in pixels at this zoom:
/// it grows and shrinks with the drawing, so the label keeps its place between the arrows.
/// </summary>
private float AlongShift(string property, PointF along) =>
dragOffsets.TryGetValue(property, out var drag) && drag.Scale > 0
? Dot(drag.Pixels, along) * ViewScale / drag.Scale
: 0;
/// <summary>
/// The labels centered on the line from <paramref name="a"/> to <paramref name="b"/>,
/// moved <paramref name="shift"/> pixels along <paramref name="along"/>. Moved past an
/// end, the line runs on to them.
/// </summary>
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);
}
/// <summary>
@@ -669,7 +728,7 @@ namespace OpenNest.Controls
/// The value label, and the count label joined after it, at <paramref name="anchor"/>
/// (vertically centered; <paramref name="alignX"/> 0 starts there, 0.5 centers, 1 ends there).
/// </summary>
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),
});
+1 -1
View File
@@ -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;