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