From 6be8a7b41cc0bcd4bd7f97b7f27b8bdb9e27b94c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Nov 2026 17:56:13 -0500 Subject: [PATCH] feat(ui): edit Shape Library parameters as dimensions on the drawing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Shape Library no longer has a side panel of inputs. Each length is a dimension line or leader on the preview; click its label to type a new value (Enter applies, Esc cancels, Tab moves to the next dimension), as Edit Clamps does on the plate. Invalid values keep the editor open in red and the last good shape on screen. Settings with no place on the drawing (sides, pipe size, clearance, blind) are clickable notes along the bottom. The flange hole leader carries the count beside the size, such as "Ø7/8" x 8 holes". Shapes declare their dimensions through ShapeDefinition.GetDimensions; lengths show as fractions when exact to 1/64 (Fraction.Format). Edit Clamps' inline text box moves to a shared DimensionBox. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Math/Fraction.cs | 33 ++ OpenNest.Core/Shapes/CircleShape.cs | 6 + .../Shapes/IsoscelesTriangleShape.cs | 7 + OpenNest.Core/Shapes/LShape.cs | 17 +- OpenNest.Core/Shapes/NgonShape.cs | 7 + OpenNest.Core/Shapes/PipeFlangeShape.cs | 23 + OpenNest.Core/Shapes/RectangleShape.cs | 7 + OpenNest.Core/Shapes/RightTriangleShape.cs | 7 + OpenNest.Core/Shapes/RingShape.cs | 8 + OpenNest.Core/Shapes/RoundedRectangleShape.cs | 12 + OpenNest.Core/Shapes/ShapeDefinition.cs | 6 + OpenNest.Core/Shapes/ShapeDimension.cs | 96 ++++ OpenNest.Core/Shapes/TShape.cs | 23 +- OpenNest.Core/Shapes/TrapezoidShape.cs | 10 + OpenNest.Tests/Shapes/ShapeDimensionTests.cs | 74 +++ .../Forms/ShapeLibraryFormTests.cs | 130 ++++- OpenNest/Actions/ActionClampEdit.cs | 20 - OpenNest/Controls/DimensionBox.cs | 50 ++ OpenNest/Controls/ShapePreviewControl.cs | 449 ++++++++++++++- OpenNest/Forms/ShapeLibraryForm.Designer.cs | 15 +- OpenNest/Forms/ShapeLibraryForm.cs | 517 ++++++++++-------- 21 files changed, 1237 insertions(+), 280 deletions(-) create mode 100644 OpenNest.Core/Shapes/ShapeDimension.cs create mode 100644 OpenNest.Tests/Shapes/ShapeDimensionTests.cs create mode 100644 OpenNest/Controls/DimensionBox.cs diff --git a/OpenNest.Core/Math/Fraction.cs b/OpenNest.Core/Math/Fraction.cs index 0b582559..36c14323 100644 --- a/OpenNest.Core/Math/Fraction.cs +++ b/OpenNest.Core/Math/Fraction.cs @@ -58,6 +58,39 @@ namespace OpenNest.Math } } + /// + /// as a whole number and a reduced fraction, such as "5 1/2", + /// when it is a multiple of 1/; otherwise as a decimal + /// with up to four places. reads either back. + /// + public static string Format(double value, int maxDenominator = 64) + { + var scaled = value * maxDenominator; + var rounded = System.Math.Round(scaled); + if (!double.IsFinite(value) || System.Math.Abs(scaled - rounded) > 1e-6 || System.Math.Abs(rounded) > long.MaxValue / 2) + return value.ToString("0.####", System.Globalization.CultureInfo.CurrentCulture); + + var sign = rounded < 0 ? "-" : ""; + var units = (long)System.Math.Abs(rounded); + var whole = units / maxDenominator; + var numerator = units % maxDenominator; + if (numerator == 0) + return sign + whole; + + var denominator = (long)maxDenominator; + var divisor = Gcd(numerator, denominator); + numerator /= divisor; + denominator /= divisor; + return whole == 0 ? $"{sign}{numerator}/{denominator}" : $"{sign}{whole} {numerator}/{denominator}"; + } + + private static long Gcd(long a, long b) + { + while (b != 0) + (a, b) = (b, a % b); + return a; + } + public static string ReplaceFractionsWithDecimals(string input) { var sb = new StringBuilder(input); diff --git a/OpenNest.Core/Shapes/CircleShape.cs b/OpenNest.Core/Shapes/CircleShape.cs index 4fe286ae..37e3933f 100644 --- a/OpenNest.Core/Shapes/CircleShape.cs +++ b/OpenNest.Core/Shapes/CircleShape.cs @@ -14,6 +14,12 @@ namespace OpenNest.Shapes Diameter = 8; } + public override IReadOnlyList GetDimensions() => + new[] + { + ShapeDimension.Linear(nameof(Diameter), new Vector(-Diameter / 2, 0), new Vector(Diameter / 2, 0), ShapeDimension.Up, "Ø"), + }; + public override Drawing GetDrawing() { var entities = new List { new Circle(0, 0, Diameter / 2.0) }; diff --git a/OpenNest.Core/Shapes/IsoscelesTriangleShape.cs b/OpenNest.Core/Shapes/IsoscelesTriangleShape.cs index b317479a..e92c8c77 100644 --- a/OpenNest.Core/Shapes/IsoscelesTriangleShape.cs +++ b/OpenNest.Core/Shapes/IsoscelesTriangleShape.cs @@ -16,6 +16,13 @@ namespace OpenNest.Shapes Height = 10; } + public override IReadOnlyList GetDimensions() => + new[] + { + ShapeDimension.Linear(nameof(Base), new Vector(0, 0), new Vector(Base, 0), ShapeDimension.Down), + ShapeDimension.Linear(nameof(Height), new Vector(Base, 0), new Vector(Base / 2.0, Height), ShapeDimension.Right), + }; + public override Drawing GetDrawing() { var midX = Base / 2.0; diff --git a/OpenNest.Core/Shapes/LShape.cs b/OpenNest.Core/Shapes/LShape.cs index e35c8d7a..beeb762c 100644 --- a/OpenNest.Core/Shapes/LShape.cs +++ b/OpenNest.Core/Shapes/LShape.cs @@ -20,10 +20,23 @@ namespace OpenNest.Shapes LegHeight = 3; } + private double EffectiveLegWidth => LegWidth > 0 ? LegWidth : Width / 2.0; + + private double EffectiveLegHeight => LegHeight > 0 ? LegHeight : Height / 2.0; + + public override IReadOnlyList GetDimensions() => + new[] + { + ShapeDimension.Linear(nameof(Width), new Vector(0, 0), new Vector(Width, 0), ShapeDimension.Down), + ShapeDimension.Linear(nameof(Height), new Vector(0, 0), new Vector(0, Height), ShapeDimension.Left), + ShapeDimension.Linear(nameof(LegWidth), new Vector(0, Height), new Vector(EffectiveLegWidth, Height), ShapeDimension.Up), + ShapeDimension.Linear(nameof(LegHeight), new Vector(Width, 0), new Vector(Width, EffectiveLegHeight), ShapeDimension.Right), + }; + public override Drawing GetDrawing() { - var lw = LegWidth > 0 ? LegWidth : Width / 2.0; - var lh = LegHeight > 0 ? LegHeight : Height / 2.0; + var lw = EffectiveLegWidth; + var lh = EffectiveLegHeight; var entities = new List { diff --git a/OpenNest.Core/Shapes/NgonShape.cs b/OpenNest.Core/Shapes/NgonShape.cs index 96653c2c..326cb47d 100644 --- a/OpenNest.Core/Shapes/NgonShape.cs +++ b/OpenNest.Core/Shapes/NgonShape.cs @@ -16,6 +16,13 @@ namespace OpenNest.Shapes Width = 8; } + public override IReadOnlyList GetDimensions() => + new[] + { + // Across the flats: the flat at angle 0 and the one opposite it. + ShapeDimension.Linear(nameof(Width), new Vector(0, Width / 2.0), new Vector(Width, Width / 2.0), ShapeDimension.Down), + }; + public override Drawing GetDrawing() { var n = Sides < 3 ? 3 : Sides; diff --git a/OpenNest.Core/Shapes/PipeFlangeShape.cs b/OpenNest.Core/Shapes/PipeFlangeShape.cs index 1db16bc3..d5da7afc 100644 --- a/OpenNest.Core/Shapes/PipeFlangeShape.cs +++ b/OpenNest.Core/Shapes/PipeFlangeShape.cs @@ -32,6 +32,29 @@ namespace OpenNest.Shapes Blind = false; } + public override IReadOnlyList GetDimensions() + { + var dimensions = new List + { + ShapeDimension.Linear(nameof(OD), new Vector(-OD / 2, 0), new Vector(OD / 2, 0), ShapeDimension.Up, "Ø"), + ShapeDimension.Linear(nameof(HolePatternDiameter), new Vector(-HolePatternDiameter / 2, 0), + new Vector(HolePatternDiameter / 2, 0), ShapeDimension.Down, "Ø"), + }; + + // The leader starts on the hole nearest 45 degrees, at its edge facing out that way. + if (HoleCount > 0) + { + var step = 2.0 * System.Math.PI / HoleCount; + var angle = System.Math.Round(System.Math.PI / 4 / step) * step; + var out45 = new Vector(System.Math.Sqrt(0.5), System.Math.Sqrt(0.5)); + var center = new Vector(HolePatternDiameter / 2 * System.Math.Cos(angle), HolePatternDiameter / 2 * System.Math.Sin(angle)); + var edge = new Vector(center.X + out45.X * HoleDiameter / 2, center.Y + out45.Y * HoleDiameter / 2); + dimensions.Add(ShapeDimension.Leader(nameof(HoleDiameter), edge, out45, "Ø").WithCount(nameof(HoleCount), "holes")); + } + + return dimensions; + } + public override Drawing GetDrawing() { var entities = new List(); diff --git a/OpenNest.Core/Shapes/RectangleShape.cs b/OpenNest.Core/Shapes/RectangleShape.cs index 3459a930..40ca857a 100644 --- a/OpenNest.Core/Shapes/RectangleShape.cs +++ b/OpenNest.Core/Shapes/RectangleShape.cs @@ -16,6 +16,13 @@ namespace OpenNest.Shapes Width = 6; } + public override IReadOnlyList GetDimensions() => + new[] + { + ShapeDimension.Linear(nameof(Length), new Vector(0, 0), new Vector(Length, 0), ShapeDimension.Down), + ShapeDimension.Linear(nameof(Width), new Vector(Length, 0), new Vector(Length, Width), ShapeDimension.Right), + }; + public override Drawing GetDrawing() { var entities = new List diff --git a/OpenNest.Core/Shapes/RightTriangleShape.cs b/OpenNest.Core/Shapes/RightTriangleShape.cs index 06e2ed39..27ab52ab 100644 --- a/OpenNest.Core/Shapes/RightTriangleShape.cs +++ b/OpenNest.Core/Shapes/RightTriangleShape.cs @@ -16,6 +16,13 @@ namespace OpenNest.Shapes Height = 6; } + public override IReadOnlyList GetDimensions() => + new[] + { + ShapeDimension.Linear(nameof(Width), new Vector(0, 0), new Vector(Width, 0), ShapeDimension.Down), + ShapeDimension.Linear(nameof(Height), new Vector(0, 0), new Vector(0, Height), ShapeDimension.Left), + }; + public override Drawing GetDrawing() { var entities = new List diff --git a/OpenNest.Core/Shapes/RingShape.cs b/OpenNest.Core/Shapes/RingShape.cs index 8c168cce..a4077581 100644 --- a/OpenNest.Core/Shapes/RingShape.cs +++ b/OpenNest.Core/Shapes/RingShape.cs @@ -16,6 +16,14 @@ namespace OpenNest.Shapes InnerDiameter = 6; } + public override IReadOnlyList GetDimensions() => + new[] + { + ShapeDimension.Linear(nameof(OuterDiameter), new Vector(-OuterDiameter / 2, 0), new Vector(OuterDiameter / 2, 0), + ShapeDimension.Up, "Ø"), + ShapeDimension.Inline(nameof(InnerDiameter), new Vector(-InnerDiameter / 2, 0), new Vector(InnerDiameter / 2, 0), "Ø"), + }; + public override Drawing GetDrawing() { var entities = new List diff --git a/OpenNest.Core/Shapes/RoundedRectangleShape.cs b/OpenNest.Core/Shapes/RoundedRectangleShape.cs index e0ce89ed..94e207f9 100644 --- a/OpenNest.Core/Shapes/RoundedRectangleShape.cs +++ b/OpenNest.Core/Shapes/RoundedRectangleShape.cs @@ -20,6 +20,18 @@ namespace OpenNest.Shapes Radius = 1; } + public override IReadOnlyList GetDimensions() => + new[] + { + ShapeDimension.Linear(nameof(Length), new Vector(0, 0), new Vector(Length, 0), ShapeDimension.Down), + ShapeDimension.Linear(nameof(Width), new Vector(Length, 0), new Vector(Length, Width), ShapeDimension.Right), + // On the top-left corner arc, pointing out along its 45-degree radius, clear of + // the width dimension on the right. + ShapeDimension.Leader(nameof(Radius), + new Vector(Radius * (1 - System.Math.Sqrt(0.5)), Width - Radius * (1 - System.Math.Sqrt(0.5))), + new Vector(-1, 1), "R"), + }; + public override Drawing GetDrawing() { var r = Radius; diff --git a/OpenNest.Core/Shapes/ShapeDefinition.cs b/OpenNest.Core/Shapes/ShapeDefinition.cs index 449162c6..d8f9841a 100644 --- a/OpenNest.Core/Shapes/ShapeDefinition.cs +++ b/OpenNest.Core/Shapes/ShapeDefinition.cs @@ -36,6 +36,12 @@ namespace OpenNest.Shapes public virtual void SetPreviewDefaults() { } + /// + /// Where the Shape Library draws each parameter's dimension, in the coordinates of the + /// entities builds. Parameters without one are shown as notes. + /// + public virtual IReadOnlyList GetDimensions() => Array.Empty(); + public static List LoadFromJson(string path) where T : ShapeDefinition { diff --git a/OpenNest.Core/Shapes/ShapeDimension.cs b/OpenNest.Core/Shapes/ShapeDimension.cs new file mode 100644 index 00000000..cc6292e2 --- /dev/null +++ b/OpenNest.Core/Shapes/ShapeDimension.cs @@ -0,0 +1,96 @@ +using OpenNest.Geometry; + +namespace OpenNest.Shapes +{ + public enum ShapeDimensionKind + { + /// A dimension line outside the shape, past its bounds on the side. + Linear, + + /// A dimension line drawn directly between the two points, such as across a hole. + Inline, + + /// A leader from out along . + Leader, + } + + /// + /// Where the Shape Library draws one parameter's dimension on the shape's preview, in the + /// shape's own coordinates (those of the entities + /// builds). Annotation only: it never affects the drawing. + /// + public sealed class ShapeDimension + { + private ShapeDimension(string property, ShapeDimensionKind kind, Vector start, Vector end, Vector side, string prefix) + { + Property = property; + Kind = kind; + Start = start; + End = end; + Side = side; + Prefix = prefix ?? ""; + } + + /// The name of the shape property the dimension shows and edits. + public string Property { get; } + + public ShapeDimensionKind Kind { get; } + + public Vector Start { get; } + + /// The second measured point; equal to for a leader. + public Vector End { get; } + + /// Unit vector: the side a linear dimension sits on, or a leader's direction. + public Vector Side { get; } + + /// Text before the value, such as "Ø" or "R". + public string Prefix { get; } + + /// + /// An integer property shown after the value as "x {count} {noun}", such as a + /// flange's hole count beside its hole diameter; null when there is none. + /// + public string CountProperty { get; private init; } + + /// The plural noun after the count, such as "holes". + public string CountNoun { get; private init; } + + /// This dimension with "x {count} {noun}" after its value. + public ShapeDimension WithCount(string countProperty, string noun) => + new(Property, Kind, Start, End, Side, Prefix) { CountProperty = countProperty, CountNoun = noun }; + + /// + /// Measures from to across + /// , which must be one of the four axis directions. + /// + public static ShapeDimension Linear(string property, Vector start, Vector end, Vector side, string prefix = "") => + new(property, ShapeDimensionKind.Linear, start, end, side, prefix); + + public static ShapeDimension Inline(string property, Vector start, Vector end, string prefix = "") => + new(property, ShapeDimensionKind.Inline, start, end, new Vector(0, 1), prefix); + + public static ShapeDimension Leader(string property, Vector point, Vector direction, string prefix = "") + { + var length = System.Math.Sqrt(direction.X * direction.X + direction.Y * direction.Y); + return new(property, ShapeDimensionKind.Leader, point, point, + new Vector(direction.X / length, direction.Y / length), prefix); + } + + /// The distance the dimension shows between its points (zero for a leader). + public double Measured => + Kind switch + { + ShapeDimensionKind.Linear => System.Math.Abs(Side.X) > System.Math.Abs(Side.Y) + ? System.Math.Abs(End.Y - Start.Y) + : System.Math.Abs(End.X - Start.X), + ShapeDimensionKind.Inline => Start.DistanceTo(End), + _ => 0, + }; + + internal static readonly Vector Up = new(0, 1); + internal static readonly Vector Down = new(0, -1); + internal static readonly Vector Left = new(-1, 0); + internal static readonly Vector Right = new(1, 0); + } +} diff --git a/OpenNest.Core/Shapes/TShape.cs b/OpenNest.Core/Shapes/TShape.cs index 1ddabcc3..4a7fdb6a 100644 --- a/OpenNest.Core/Shapes/TShape.cs +++ b/OpenNest.Core/Shapes/TShape.cs @@ -20,11 +20,28 @@ namespace OpenNest.Shapes BarHeight = 3; } + private double EffectiveStemWidth => StemWidth > 0 ? StemWidth : Width / 3.0; + + private double EffectiveBarHeight => BarHeight > 0 ? BarHeight : Height / 3.0; + + private double StemLeft => (Width - EffectiveStemWidth) / 2.0; + + public override IReadOnlyList GetDimensions() => + new[] + { + ShapeDimension.Linear(nameof(Width), new Vector(0, Height), new Vector(Width, Height), ShapeDimension.Up), + ShapeDimension.Linear(nameof(Height), new Vector(StemLeft, 0), new Vector(0, Height), ShapeDimension.Left), + ShapeDimension.Linear(nameof(StemWidth), new Vector(StemLeft, 0), new Vector(StemLeft + EffectiveStemWidth, 0), + ShapeDimension.Down), + ShapeDimension.Linear(nameof(BarHeight), new Vector(Width, Height - EffectiveBarHeight), new Vector(Width, Height), + ShapeDimension.Right), + }; + public override Drawing GetDrawing() { - var sw = StemWidth > 0 ? StemWidth : Width / 3.0; - var bh = BarHeight > 0 ? BarHeight : Height / 3.0; - var stemLeft = (Width - sw) / 2.0; + var sw = EffectiveStemWidth; + var bh = EffectiveBarHeight; + var stemLeft = StemLeft; var stemRight = stemLeft + sw; var stemTop = Height - bh; diff --git a/OpenNest.Core/Shapes/TrapezoidShape.cs b/OpenNest.Core/Shapes/TrapezoidShape.cs index 5792354d..defb1df6 100644 --- a/OpenNest.Core/Shapes/TrapezoidShape.cs +++ b/OpenNest.Core/Shapes/TrapezoidShape.cs @@ -19,6 +19,16 @@ namespace OpenNest.Shapes Height = 6; } + public override IReadOnlyList GetDimensions() => + new[] + { + ShapeDimension.Linear(nameof(BottomWidth), new Vector(0, 0), new Vector(BottomWidth, 0), ShapeDimension.Down), + ShapeDimension.Linear(nameof(TopWidth), new Vector((BottomWidth - TopWidth) / 2.0, Height), + new Vector((BottomWidth + TopWidth) / 2.0, Height), ShapeDimension.Up), + ShapeDimension.Linear(nameof(Height), new Vector(BottomWidth, 0), new Vector((BottomWidth + TopWidth) / 2.0, Height), + ShapeDimension.Right), + }; + public override Drawing GetDrawing() { var offset = (BottomWidth - TopWidth) / 2.0; diff --git a/OpenNest.Tests/Shapes/ShapeDimensionTests.cs b/OpenNest.Tests/Shapes/ShapeDimensionTests.cs new file mode 100644 index 00000000..2aace7b7 --- /dev/null +++ b/OpenNest.Tests/Shapes/ShapeDimensionTests.cs @@ -0,0 +1,74 @@ +using OpenNest.Math; +using OpenNest.Shapes; + +namespace OpenNest.Tests.Shapes; + +public class ShapeDimensionTests +{ + public static TheoryData ShapeTypes() + { + var data = new TheoryData(); + foreach (var type in typeof(ShapeDefinition).Assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && typeof(ShapeDefinition).IsAssignableFrom(t))) + data.Add(type); + return data; + } + + [Theory] + [MemberData(nameof(ShapeTypes))] + public void EveryDimensionMeasuresTheParameterItShows(Type shapeType) + { + var shape = (ShapeDefinition)Activator.CreateInstance(shapeType)!; + shape.SetPreviewDefaults(); + var dimensions = shape.GetDimensions(); + + Assert.NotEmpty(dimensions); + Assert.Equal(dimensions.Count, dimensions.Select(d => d.Property).Distinct().Count()); + foreach (var dimension in dimensions) + { + var property = shapeType.GetProperty(dimension.Property); + Assert.NotNull(property); + Assert.Equal(typeof(double), property!.PropertyType); + if (dimension.Kind != ShapeDimensionKind.Leader) + Assert.Equal((double)property.GetValue(shape)!, dimension.Measured, 6); + if (dimension.CountProperty != null) + Assert.Equal(typeof(int), shapeType.GetProperty(dimension.CountProperty)!.PropertyType); + } + } + + [Fact] + public void FlangeHoleDiameterCarriesTheHoleCount() + { + var flange = new PipeFlangeShape(); + flange.SetPreviewDefaults(); + + var holes = flange.GetDimensions().Single(d => d.Property == nameof(PipeFlangeShape.HoleDiameter)); + + Assert.Equal(ShapeDimensionKind.Leader, holes.Kind); + Assert.Equal(nameof(PipeFlangeShape.HoleCount), holes.CountProperty); + Assert.Equal("holes", holes.CountNoun); + } + + [Fact] + public void RoundedRectangleRadiusLeaderStartsOnTheCornerArc() + { + var shape = new RoundedRectangleShape { Length = 12, Width = 6, Radius = 2 }; + var radius = shape.GetDimensions().Single(d => d.Property == nameof(RoundedRectangleShape.Radius)); + + Assert.Equal(2, radius.Start.DistanceTo(new OpenNest.Geometry.Vector(2, 4)), 6); + } + + [Theory] + [InlineData(0.5, "1/2")] + [InlineData(5.5, "5 1/2")] + [InlineData(0.875, "7/8")] + [InlineData(12, "12")] + [InlineData(0.0625, "1/16")] + [InlineData(0.1, "0.1")] + [InlineData(-1.25, "-1 1/4")] + public void FractionFormatsSixtyFourthsAndFallsBackToDecimals(double value, string expected) + { + Assert.Equal(expected, Fraction.Format(value)); + Assert.Equal(value, double.Parse(Fraction.ReplaceFractionsWithDecimals(Fraction.Format(value))), 6); + } +} diff --git a/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs b/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs index 47828d3a..596130a0 100644 --- a/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs @@ -48,30 +48,113 @@ public class ShapeLibraryFormTests } [Fact] - public void InvalidShapeInputClearsOldPreviewAndBlocksAddUntilCorrected() => ArchUnitsTests.RunSta(() => + public void ParametersAreDimensionsOnTheDrawingNotASidePanel() => ArchUnitsTests.RunSta(() => { using var form = new ShapeLibraryForm(); SelectShape(form, typeof(RectangleShape)); var preview = Find(form, "previewBox"); - var add = Find