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