From 45ab837c8c352053aa95309e26f763596f47610e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 31 Oct 2026 05:40:26 -0400 Subject: [PATCH] feat(cad-converter): drag a dimension by its value Pressing a dimension's value text now grabs it like its line does. The label keeps its offset under the pointer, so a radius or diameter value, which sits past its leader's end, no longer jumps when grabbed. Hit boxes are measured the way painting draws them, at the control's DPI. --- .../Forms/CadConverterDimensionTests.cs | 50 +++++++ OpenNest/Controls/EntityView.Breakoffs.cs | 7 +- OpenNest/Controls/EntityView.Dimensions.cs | 127 ++++++++++++++++-- OpenNest/Controls/EntityView.cs | 1 + 4 files changed, 169 insertions(+), 16 deletions(-) diff --git a/OpenNest.WinForms.Tests/Forms/CadConverterDimensionTests.cs b/OpenNest.WinForms.Tests/Forms/CadConverterDimensionTests.cs index dfc154d2..ff31ebc8 100644 --- a/OpenNest.WinForms.Tests/Forms/CadConverterDimensionTests.cs +++ b/OpenNest.WinForms.Tests/Forms/CadConverterDimensionTests.cs @@ -161,6 +161,56 @@ public class CadConverterDimensionTests reopened.Close(); }); + [Fact] + public void DraggingARadiusByItsValue_KeepsTheValueUnderThePointer() => ArchUnitsTests.RunSta(() => + { + var part = Part(); + var hole = new Circle(5, 3, 1) { Layer = part.SourceEntities![0].Layer }; + part.SourceEntities.Add(hole); + + using var form = new DimensionTestForm(); + Show(form); + form.LoadDrawings([part]); + var view = Field(form, "entityView1"); + var item = Field(form, "fileList").SelectedItem!; + Invoke(form, "OnEntityActionRequested", null, new EntityActionEventArgs(EntityAction.AddRadiusDimension, [hole])); + + // A press just past the value is empty space: it grabs nothing. + var label = LabelInControl(view, 0); + var outside = new Point((int)label.Right + 4, (int)(label.Top + label.Height / 2)); + Mouse(view, "OnMouseDown", outside); + Assert.Null(typeof(EntityView).GetField("draggedDimension", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(view)); + Mouse(view, "OnMouseUp", outside); + var before = item.Dimensions[0].Offset; + + // Grab the value 5 px right of its center and drag it to straight left of the hole. + var grip = new Point((int)(label.Left + label.Width / 2) + 5, (int)(label.Top + label.Height / 2)); + var release = view.PointWorldToControl(new Vector(1.5, 3)); + Mouse(view, "OnMouseDown", grip); + Mouse(view, "OnMouseMove", release); + Mouse(view, "OnMouseUp", release); + + var moved = LabelInControl(view, 0); + Assert.InRange(moved.Left + moved.Width / 2 + 5 - release.X, -2, 2); + Assert.InRange(moved.Top + moved.Height / 2 - release.Y, -2, 2); + Assert.InRange(view.DimensionLayouts[0].ArrowPoint.X, 3.99, 4.01); + Assert.NotEqual(before, item.Dimensions[0].Offset); + form.Close(); + }); + + // The dimension's value box in control coordinates, as the view draws it. + private static RectangleF LabelInControl(EntityView view, int index) + { + var flags = BindingFlags.Instance | BindingFlags.NonPublic; + var g = typeof(EntityView).GetMethod("MeasureGraphics", flags)!.Invoke(view, null); + var box = (RectangleF)typeof(EntityView).GetMethod("DimensionLabelAt", flags)! + .Invoke(view, [g, view.DimensionLayouts[index]])!; + var corner = view.PointGraphToWorld(box.Location); + var control = view.PointWorldToControl(corner); + return new RectangleF(control.X, control.Y, box.Width, box.Height); + } + [Fact] public void DisplayOnlyCircle_ExplainsHowToDimensionIt() => ArchUnitsTests.RunSta(() => { diff --git a/OpenNest/Controls/EntityView.Breakoffs.cs b/OpenNest/Controls/EntityView.Breakoffs.cs index c76570a8..5f593de9 100644 --- a/OpenNest/Controls/EntityView.Breakoffs.cs +++ b/OpenNest/Controls/EntityView.Breakoffs.cs @@ -65,9 +65,8 @@ namespace OpenNest.Controls draggedTab = HitTestTab(e.Location); if (draggedTab == null) draggedCutoutTab = HitTestCutoutTab(e.Location); - if (draggedTab == null && draggedCutoutTab == null) - draggedDimension = HitTestDimension(e.Location); - if (draggedTab == null && draggedCutoutTab == null && draggedDimension == null) + var dimension = draggedTab == null && draggedCutoutTab == null && BeginDimensionDrag(e.Location); + if (draggedTab == null && draggedCutoutTab == null && !dimension) BeginWindowSelect(e); else suppressNextClick = true; // A tab press or drag does not change the selection. @@ -79,7 +78,7 @@ namespace OpenNest.Controls if (e.Button != MouseButtons.Left) return; EndWindowSelect(e); - draggedDimension = null; + EndDimensionDrag(); if (draggedCutoutTab is { } cutoutTab) { draggedCutoutTab = null; diff --git a/OpenNest/Controls/EntityView.Dimensions.cs b/OpenNest/Controls/EntityView.Dimensions.cs index 65b99e01..8a130168 100644 --- a/OpenNest/Controls/EntityView.Dimensions.cs +++ b/OpenNest/Controls/EntityView.Dimensions.cs @@ -42,6 +42,12 @@ namespace OpenNest.Controls private readonly Font dimensionFont = new Font("Segoe UI", 9f); private int? draggedDimension; + // A press on a dimension's value: the label's center minus the pressed point, so the label + // keeps its place under the pointer. Null when the dimension line itself was pressed. + private Vector? draggedDimensionLabelGrab; + private Graphics measureGraphics; + private Bitmap measureBitmap; + /// Resolved dimensions to draw, in dimension order; invalid entries are not drawn. public IReadOnlyList DimensionLayouts { get; set; } = Array.Empty(); @@ -56,6 +62,8 @@ namespace OpenNest.Controls return; var point = PointControlToWorld(location); var minimum = LengthGuiToWorld(DimensionMinimumPixels); + if (draggedDimensionLabelGrab is { } grab) + point = LeaderEndForLabelAt(layout, point + grab); if (layout.IsRadial) { var (radialOffset, angle) = layout.RadialPlacementAt(point, minimum); @@ -66,8 +74,61 @@ namespace OpenNest.Controls DimensionDragged?.Invoke(this, new DimensionDragEventArgs(index, offset)); } + /// Starts dragging the dimension under , by its value or its line. + private bool BeginDimensionDrag(Point location) + { + draggedDimensionLabelGrab = null; + draggedDimension = HitTestDimensionLabel(location); + if (draggedDimension is { } index) + { + var label = DimensionLabelAt(MeasureGraphics(), DimensionLayouts[index]); + draggedDimensionLabelGrab = PointGraphToWorld(new PointF(label.Left + label.Width / 2f, label.Top + label.Height / 2f)) - PointControlToWorld(location); + return true; + } + draggedDimension = HitTestDimension(location); + return draggedDimension != null; + } + + private void EndDimensionDrag() + { + draggedDimension = null; + draggedDimensionLabelGrab = null; + } + + // The overall dimension line runs through its label; a radial label sits past the leader's + // end, so the leader ends that label reach short of the label's center. + private Vector LeaderEndForLabelAt(DimensionLayout layout, Vector labelCenter) + { + if (!layout.IsRadial) + return labelCenter; + var dx = labelCenter.X - layout.Center.X; + var dy = labelCenter.Y - layout.Center.Y; + var distance = System.Math.Sqrt(dx * dx + dy * dy); + if (distance <= 0) + return labelCenter; + var size = MeasureGraphics().MeasureString(layout.DisplayText, dimensionFont); + var reach = LengthGuiToWorld(RadialLabelReach(size, System.Math.Atan2(dy, dx))); + var leader = System.Math.Max(0, distance - reach); + return new Vector(layout.Center.X + dx / distance * leader, layout.Center.Y + dy / distance * leader); + } + + private int? HitTestDimensionLabel(Point location) + { + var point = PointControlToGraph(location); + var g = MeasureGraphics(); + for (var i = DimensionLayouts.Count - 1; i >= 0; i--) + { + var layout = DimensionLayouts[i]; + if (layout is { IsValid: true } && DimensionLabelAt(g, layout).Contains(point)) + return i; + } + return null; + } + private int? HitTestDimension(Point location) { + if (HitTestDimensionLabel(location) is { } label) + return label; var point = PointControlToWorld(location); var tolerance = LengthGuiToWorld(DimensionHitPixels); for (var i = 0; i < DimensionLayouts.Count; i++) @@ -159,13 +220,13 @@ namespace OpenNest.Controls DrawArrow(g, brush, to, new PointF(-along.X, -along.Y)); var mid = PointWorldToGraph(layout.MidPoint); - var size = g.MeasureString(layout.ValueText, dimensionFont); + var size = g.MeasureString(layout.DisplayText, dimensionFont); var state = g.Save(); g.TranslateTransform(mid.X, mid.Y); if (layout.Kind == DimensionKind.OverallVertical) g.RotateTransform(-90f); g.FillRectangle(labelBackBrush, -size.Width / 2f, -size.Height / 2f, size.Width, size.Height); - g.DrawString(layout.ValueText, dimensionFont, brush, -size.Width / 2f, -size.Height / 2f); + g.DrawString(layout.DisplayText, dimensionFont, brush, -size.Width / 2f, -size.Height / 2f); g.Restore(state); } @@ -194,16 +255,58 @@ namespace OpenNest.Controls g.DrawLine(pen, center.X, center.Y - CenterMarkPixels, center.X, center.Y + CenterMarkPixels); } - // The label sits past the leader's end, on the side away from the curve's center. - var text = layout.DisplayText; - var size = g.MeasureString(text, dimensionFont); - var dx = System.Math.Cos(layout.Angle); - var dy = -System.Math.Sin(layout.Angle); // Screen Y runs down. - var reach = (float)(System.Math.Abs(dx) * size.Width / 2 + System.Math.Abs(dy) * size.Height / 2) + 2; - var cx = label.X + (float)dx * reach; - var cy = label.Y + (float)dy * reach; - g.FillRectangle(labelBackBrush, cx - size.Width / 2f, cy - size.Height / 2f, size.Width, size.Height); - g.DrawString(text, dimensionFont, brush, cx - size.Width / 2f, cy - size.Height / 2f); + var box = DimensionLabelAt(g, layout); + g.FillRectangle(labelBackBrush, box); + g.DrawString(layout.DisplayText, dimensionFont, brush, box.Left, box.Top); + } + + /// + /// Where a dimension's value is drawn, in graph coordinates (a vertical overall dimension's + /// value is turned a quarter, so its box is too). A radial label sits past the leader's end, + /// on the side away from the curve's center. + /// + private RectangleF DimensionLabelAt(Graphics g, DimensionLayout layout) + { + var size = g.MeasureString(layout.DisplayText, dimensionFont); + if (!layout.IsRadial) + { + var mid = PointWorldToGraph(layout.MidPoint); + if (layout.Kind == DimensionKind.OverallVertical) + size = new SizeF(size.Height, size.Width); + return new RectangleF(mid.X - size.Width / 2f, mid.Y - size.Height / 2f, size.Width, size.Height); + } + + var end = PointWorldToGraph(layout.LineEnd); + var reach = RadialLabelReach(size, layout.Angle); + var cx = end.X + (float)System.Math.Cos(layout.Angle) * reach; + var cy = end.Y - (float)System.Math.Sin(layout.Angle) * reach; // Screen Y runs down. + return new RectangleF(cx - size.Width / 2f, cy - size.Height / 2f, size.Width, size.Height); + } + + // How far past the leader's end a radial label's center sits, in pixels. + private static float RadialLabelReach(SizeF size, double angle) => + (float)(System.Math.Abs(System.Math.Cos(angle)) * size.Width / 2 + + System.Math.Abs(System.Math.Sin(angle)) * size.Height / 2) + 2; + + // Hit-testing measures labels the way painting does, without waiting for a paint. + private Graphics MeasureGraphics() + { + if (measureGraphics == null || measureGraphics.DpiX != DeviceDpi) + { + DisposeDimensionMeasuring(); + measureBitmap = new Bitmap(1, 1); + measureBitmap.SetResolution(DeviceDpi, DeviceDpi); + measureGraphics = Graphics.FromImage(measureBitmap); + } + return measureGraphics; + } + + private void DisposeDimensionMeasuring() + { + measureGraphics?.Dispose(); + measureBitmap?.Dispose(); + measureGraphics = null; + measureBitmap = null; } // An arrow with its tip at tip and its tail toward toward. diff --git a/OpenNest/Controls/EntityView.cs b/OpenNest/Controls/EntityView.cs index 6cd6e009..777fe5b8 100644 --- a/OpenNest/Controls/EntityView.cs +++ b/OpenNest/Controls/EntityView.cs @@ -517,6 +517,7 @@ namespace OpenNest.Controls gridPen.Dispose(); labelFont.Dispose(); dimensionFont.Dispose(); + DisposeDimensionMeasuring(); labelBrush.Dispose(); labelBackBrush.Dispose(); textBrush.Dispose();