mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-09 13:15:46 -05:00
feat(ui): edit Shape Library parameters as dimensions on the drawing
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9b643aaec9
commit
6be8a7b41c
21 files changed
+1237
-280
No files matched your search
@@ -58,6 +58,39 @@ namespace OpenNest.Math
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="value"/> as a whole number and a reduced fraction, such as "5 1/2",
|
||||
/// when it is a multiple of 1/<paramref name="maxDenominator"/>; otherwise as a decimal
|
||||
/// with up to four places. <see cref="ReplaceFractionsWithDecimals"/> reads either back.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
@@ -14,6 +14,12 @@ namespace OpenNest.Shapes
|
||||
Diameter = 8;
|
||||
}
|
||||
|
||||
public override IReadOnlyList<ShapeDimension> 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<Entity> { new Circle(0, 0, Diameter / 2.0) };
|
||||
|
||||
@@ -16,6 +16,13 @@ namespace OpenNest.Shapes
|
||||
Height = 10;
|
||||
}
|
||||
|
||||
public override IReadOnlyList<ShapeDimension> 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;
|
||||
|
||||
@@ -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<ShapeDimension> 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<Entity>
|
||||
{
|
||||
|
||||
@@ -16,6 +16,13 @@ namespace OpenNest.Shapes
|
||||
Width = 8;
|
||||
}
|
||||
|
||||
public override IReadOnlyList<ShapeDimension> 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;
|
||||
|
||||
@@ -32,6 +32,29 @@ namespace OpenNest.Shapes
|
||||
Blind = false;
|
||||
}
|
||||
|
||||
public override IReadOnlyList<ShapeDimension> GetDimensions()
|
||||
{
|
||||
var dimensions = new List<ShapeDimension>
|
||||
{
|
||||
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<Entity>();
|
||||
|
||||
@@ -16,6 +16,13 @@ namespace OpenNest.Shapes
|
||||
Width = 6;
|
||||
}
|
||||
|
||||
public override IReadOnlyList<ShapeDimension> 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<Entity>
|
||||
|
||||
@@ -16,6 +16,13 @@ namespace OpenNest.Shapes
|
||||
Height = 6;
|
||||
}
|
||||
|
||||
public override IReadOnlyList<ShapeDimension> 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<Entity>
|
||||
|
||||
@@ -16,6 +16,14 @@ namespace OpenNest.Shapes
|
||||
InnerDiameter = 6;
|
||||
}
|
||||
|
||||
public override IReadOnlyList<ShapeDimension> 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<Entity>
|
||||
|
||||
@@ -20,6 +20,18 @@ namespace OpenNest.Shapes
|
||||
Radius = 1;
|
||||
}
|
||||
|
||||
public override IReadOnlyList<ShapeDimension> 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;
|
||||
|
||||
@@ -36,6 +36,12 @@ namespace OpenNest.Shapes
|
||||
|
||||
public virtual void SetPreviewDefaults() { }
|
||||
|
||||
/// <summary>
|
||||
/// Where the Shape Library draws each parameter's dimension, in the coordinates of the
|
||||
/// entities <see cref="GetDrawing"/> builds. Parameters without one are shown as notes.
|
||||
/// </summary>
|
||||
public virtual IReadOnlyList<ShapeDimension> GetDimensions() => Array.Empty<ShapeDimension>();
|
||||
|
||||
public static List<T> LoadFromJson<T>(string path)
|
||||
where T : ShapeDefinition
|
||||
{
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Shapes
|
||||
{
|
||||
public enum ShapeDimensionKind
|
||||
{
|
||||
/// <summary>A dimension line outside the shape, past its bounds on the <see cref="ShapeDimension.Side"/> side.</summary>
|
||||
Linear,
|
||||
|
||||
/// <summary>A dimension line drawn directly between the two points, such as across a hole.</summary>
|
||||
Inline,
|
||||
|
||||
/// <summary>A leader from <see cref="ShapeDimension.Start"/> out along <see cref="ShapeDimension.Side"/>.</summary>
|
||||
Leader,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the Shape Library draws one parameter's dimension on the shape's preview, in the
|
||||
/// shape's own coordinates (those of the entities <see cref="ShapeDefinition.GetDrawing"/>
|
||||
/// builds). Annotation only: it never affects the drawing.
|
||||
/// </summary>
|
||||
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 ?? "";
|
||||
}
|
||||
|
||||
/// <summary>The name of the shape property the dimension shows and edits.</summary>
|
||||
public string Property { get; }
|
||||
|
||||
public ShapeDimensionKind Kind { get; }
|
||||
|
||||
public Vector Start { get; }
|
||||
|
||||
/// <summary>The second measured point; equal to <see cref="Start"/> for a leader.</summary>
|
||||
public Vector End { get; }
|
||||
|
||||
/// <summary>Unit vector: the side a linear dimension sits on, or a leader's direction.</summary>
|
||||
public Vector Side { get; }
|
||||
|
||||
/// <summary>Text before the value, such as "Ø" or "R".</summary>
|
||||
public string Prefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string CountProperty { get; private init; }
|
||||
|
||||
/// <summary>The plural noun after the count, such as "holes".</summary>
|
||||
public string CountNoun { get; private init; }
|
||||
|
||||
/// <summary>This dimension with "x {count} {noun}" after its value.</summary>
|
||||
public ShapeDimension WithCount(string countProperty, string noun) =>
|
||||
new(Property, Kind, Start, End, Side, Prefix) { CountProperty = countProperty, CountNoun = noun };
|
||||
|
||||
/// <summary>
|
||||
/// Measures from <paramref name="start"/> to <paramref name="end"/> across
|
||||
/// <paramref name="side"/>, which must be one of the four axis directions.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The distance the dimension shows between its points (zero for a leader).</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<ShapeDimension> 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;
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@ namespace OpenNest.Shapes
|
||||
Height = 6;
|
||||
}
|
||||
|
||||
public override IReadOnlyList<ShapeDimension> 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;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using OpenNest.Math;
|
||||
using OpenNest.Shapes;
|
||||
|
||||
namespace OpenNest.Tests.Shapes;
|
||||
|
||||
public class ShapeDimensionTests
|
||||
{
|
||||
public static TheoryData<Type> ShapeTypes()
|
||||
{
|
||||
var data = new TheoryData<Type>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<ShapePreviewControl>(form, "previewBox");
|
||||
var add = Find<Button>(form, "addButton");
|
||||
var input = Find<Panel>(form, "parametersPanel").Controls.OfType<TextBox>().First();
|
||||
var valid = input.Text;
|
||||
Assert.NotEmpty(preview.Plate.Parts);
|
||||
Assert.True(add.Enabled);
|
||||
|
||||
input.Text = "invalid dimension";
|
||||
Assert.Equal(Color.Red, input.ForeColor);
|
||||
Assert.Empty(preview.Plate.Parts);
|
||||
Assert.False(add.Enabled);
|
||||
InvokeAdd(form);
|
||||
Assert.Empty(form.GetDrawings());
|
||||
Assert.Empty(form.Controls.Find("parametersPanel", true));
|
||||
Assert.Equal(new[] { "Length", "Width" }, preview.LabelKeys);
|
||||
Assert.NotNull(preview.LabelBounds("Length"));
|
||||
});
|
||||
|
||||
input.Text = valid;
|
||||
Assert.Equal(SystemColors.WindowText, input.ForeColor);
|
||||
Assert.NotEmpty(preview.Plate.Parts);
|
||||
Assert.True(add.Enabled);
|
||||
[Fact]
|
||||
public void TypingADimensionResizesTheShape() => ArchUnitsTests.RunSta(() =>
|
||||
{
|
||||
using var form = new ShapeLibraryForm();
|
||||
SelectShape(form, typeof(RectangleShape));
|
||||
var preview = Find<ShapePreviewControl>(form, "previewBox");
|
||||
|
||||
var box = OpenEditor(form, "Length");
|
||||
Assert.Equal("Length", preview.ActiveKey);
|
||||
box.Text = "20 1/2";
|
||||
Press(box, Keys.Enter);
|
||||
|
||||
Assert.Null(form.ActiveEditor);
|
||||
Assert.Equal(20.5, preview.Plate.Parts.Single().BoundingBox.Length, 6);
|
||||
InvokeAdd(form);
|
||||
Assert.Single(form.GetDrawings());
|
||||
Assert.Equal(20.5, form.GetDrawings().Single().Program.BoundingBox().Length, 6);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void InvalidEntryStaysOpenInRedAndKeepsTheLastShape() => ArchUnitsTests.RunSta(() =>
|
||||
{
|
||||
using var form = new ShapeLibraryForm();
|
||||
SelectShape(form, typeof(RectangleShape));
|
||||
var preview = Find<ShapePreviewControl>(form, "previewBox");
|
||||
var length = preview.Plate.Parts.Single().BoundingBox.Length;
|
||||
|
||||
var box = OpenEditor(form, "Length");
|
||||
box.Text = "invalid dimension";
|
||||
Press(box, Keys.Enter);
|
||||
Assert.Same(box, form.ActiveEditor);
|
||||
Assert.Equal(Color.Red, box.ForeColor);
|
||||
|
||||
box.Text = "0";
|
||||
Press(box, Keys.Enter);
|
||||
Assert.Same(box, form.ActiveEditor);
|
||||
|
||||
Press(box, Keys.Escape);
|
||||
Assert.Null(form.ActiveEditor);
|
||||
Assert.Equal(length, preview.Plate.Parts.Single().BoundingBox.Length, 6);
|
||||
Assert.True(Find<Button>(form, "addButton").Enabled);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void TabCommitsAndMovesToTheNextDimension() => ArchUnitsTests.RunSta(() =>
|
||||
{
|
||||
using var form = new ShapeLibraryForm();
|
||||
SelectShape(form, typeof(RectangleShape));
|
||||
var preview = Find<ShapePreviewControl>(form, "previewBox");
|
||||
|
||||
var box = OpenEditor(form, "Length");
|
||||
box.Text = "15";
|
||||
Press(box, Keys.Tab);
|
||||
|
||||
Assert.Equal("Width", preview.ActiveKey);
|
||||
Assert.Equal(15, preview.Plate.Parts.Single().BoundingBox.Length, 6);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void FlangeHoleCountSitsBesideTheHoleDiameter() => ArchUnitsTests.RunSta(() =>
|
||||
{
|
||||
using var form = new ShapeLibraryForm();
|
||||
SelectShape(form, typeof(PipeFlangeShape));
|
||||
var preview = Find<ShapePreviewControl>(form, "previewBox");
|
||||
|
||||
var diameter = preview.LabelBounds("HoleDiameter")!.Value;
|
||||
var count = preview.LabelBounds("HoleCount")!.Value;
|
||||
Assert.Equal(diameter.Right, count.Left);
|
||||
Assert.Equal(diameter.Top, count.Top);
|
||||
|
||||
int ArcsWith(int holes)
|
||||
{
|
||||
var box = OpenEditor(form, "HoleCount");
|
||||
box.Text = holes.ToString();
|
||||
Press(box, Keys.Enter);
|
||||
Assert.Null(form.ActiveEditor);
|
||||
return Arcs(preview);
|
||||
}
|
||||
var ten = ArcsWith(10);
|
||||
var perHole = ArcsWith(11) - ten;
|
||||
Assert.True(perHole > 0);
|
||||
Assert.Equal(ten + 2 * perHole, ArcsWith(12));
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void BlindTogglesOnClickAndDropsTheBore() => ArchUnitsTests.RunSta(() =>
|
||||
{
|
||||
using var form = new ShapeLibraryForm();
|
||||
SelectShape(form, typeof(PipeFlangeShape));
|
||||
var preview = Find<ShapePreviewControl>(form, "previewBox");
|
||||
Assert.Contains("Blind", preview.LabelKeys);
|
||||
var arcs = Arcs(preview);
|
||||
|
||||
form.EditParameter("Blind");
|
||||
|
||||
Assert.Null(form.ActiveEditor);
|
||||
Assert.True(Arcs(preview) < arcs);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
@@ -126,6 +209,19 @@ public class ShapeLibraryFormTests
|
||||
private static T Find<T>(Control form, string name) where T : Control =>
|
||||
Assert.IsType<T>(form.Controls.Find(name, true).Single());
|
||||
|
||||
private static int Arcs(ShapePreviewControl preview) =>
|
||||
preview.Plate.Parts.Single().Program.Codes.OfType<OpenNest.CNC.ArcMove>().Count();
|
||||
|
||||
private static TextBox OpenEditor(ShapeLibraryForm form, string key)
|
||||
{
|
||||
form.EditParameter(key);
|
||||
return Assert.IsAssignableFrom<TextBox>(form.ActiveEditor);
|
||||
}
|
||||
|
||||
private static void Press(Control control, Keys key) => typeof(Control)
|
||||
.GetMethod("OnKeyDown", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.Invoke(control, new object[] { new KeyEventArgs(key) });
|
||||
|
||||
private static void InvokeAdd(ShapeLibraryForm form) => typeof(ShapeLibraryForm)
|
||||
.GetMethod("AddButton_Click", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.Invoke(form, new object?[] { null, EventArgs.Empty });
|
||||
|
||||
@@ -541,26 +541,6 @@ namespace OpenNest.Actions
|
||||
|| double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
/// <summary>A text box that commits on Enter instead of letting the form handle it.</summary>
|
||||
private sealed class DimensionBox : TextBox
|
||||
{
|
||||
public event EventHandler Commit;
|
||||
|
||||
protected override bool IsInputKey(Keys keyData) =>
|
||||
keyData == Keys.Enter || base.IsInputKey(keyData);
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (e.KeyCode == Keys.Enter)
|
||||
{
|
||||
e.SuppressKeyPress = true;
|
||||
Commit?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Painting
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace OpenNest.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// The text box typed into over an on-canvas dimension. Enter raises <see cref="Commit"/>
|
||||
/// instead of reaching the form. Escape and Tab are claimed only while
|
||||
/// <see cref="Cancel"/> or <see cref="Advance"/> has a handler, so a host that leaves them
|
||||
/// unhandled keeps its own Escape and focus behavior.
|
||||
/// </summary>
|
||||
internal sealed class DimensionBox : TextBox
|
||||
{
|
||||
public event EventHandler Commit;
|
||||
|
||||
public event EventHandler Cancel;
|
||||
|
||||
/// <summary>Tab: commit and move on; the argument is true for Shift+Tab.</summary>
|
||||
public event EventHandler<bool> Advance;
|
||||
|
||||
protected override bool IsInputKey(Keys keyData) =>
|
||||
(keyData & Keys.KeyCode) switch
|
||||
{
|
||||
Keys.Enter => true,
|
||||
Keys.Escape => Cancel != null,
|
||||
Keys.Tab => Advance != null && (keyData & (Keys.Control | Keys.Alt)) == 0,
|
||||
_ => base.IsInputKey(keyData),
|
||||
};
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
switch (e.KeyCode)
|
||||
{
|
||||
case Keys.Enter:
|
||||
e.SuppressKeyPress = true;
|
||||
Commit?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
case Keys.Escape when Cancel != null:
|
||||
e.SuppressKeyPress = true;
|
||||
Cancel(this, EventArgs.Empty);
|
||||
return;
|
||||
case Keys.Tab when Advance != null:
|
||||
e.SuppressKeyPress = true;
|
||||
Advance(this, e.Shift);
|
||||
return;
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,43 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Shapes;
|
||||
|
||||
namespace OpenNest.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows one drawing zoomed to fit. With annotations set, it also draws each shape
|
||||
/// parameter as a dimension on the drawing (or as a note chip along the bottom) and
|
||||
/// raises <see cref="LabelClicked"/> when one is clicked, as Edit Clamps does on the plate.
|
||||
/// </summary>
|
||||
public class ShapePreviewControl : PlateView
|
||||
{
|
||||
// Screen sizes, in pixels.
|
||||
private const float Gap = 18f;
|
||||
private const float ExtensionOverrun = 5f;
|
||||
private const float ArrowLength = 8f;
|
||||
private const float ArrowHalfWidth = 3f;
|
||||
private const float LeaderClearance = 20f;
|
||||
private const float Shoulder = 12f;
|
||||
private const float LabelPadding = 3f;
|
||||
private const float EdgePadding = 10f;
|
||||
private const float ChipSpacing = 6f;
|
||||
|
||||
private static readonly Color DimensionColor = Color.FromArgb(235, 230, 120, 20);
|
||||
private static readonly Color HoverColor = Color.FromArgb(255, 140, 0);
|
||||
private static readonly Color ActiveColor = Color.FromArgb(220, 30, 110, 200);
|
||||
|
||||
private string[] infoLines;
|
||||
private Vector shapeOffset;
|
||||
private IReadOnlyList<Annotation> annotations = Array.Empty<Annotation>();
|
||||
private IReadOnlyList<Note> notes = Array.Empty<Note>();
|
||||
private AnnotationLayout layout;
|
||||
private string hoverKey;
|
||||
private string activeKey;
|
||||
|
||||
public ShapePreviewControl()
|
||||
{
|
||||
@@ -19,29 +50,76 @@ namespace OpenNest.Controls
|
||||
BackColor = ColorScheme.Default.LibraryBackgroundColor;
|
||||
}
|
||||
|
||||
/// <summary>Raised with a label's key when an enabled dimension or note is clicked.</summary>
|
||||
public event EventHandler<string> LabelClicked;
|
||||
|
||||
/// <summary>A dimension to draw: its text, and the count text after it when it has one.</summary>
|
||||
public sealed record Annotation(ShapeDimension Dimension, string Text, string CountText = null);
|
||||
|
||||
/// <summary>A parameter with no place on the drawing, shown as a chip along the bottom.</summary>
|
||||
public sealed record Note(string Key, string Text, bool Enabled = true);
|
||||
|
||||
/// <summary>The key of the label being edited, drawn highlighted; null for none.</summary>
|
||||
public string ActiveKey
|
||||
{
|
||||
get => activeKey;
|
||||
set
|
||||
{
|
||||
activeKey = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetInfo(params string[] lines)
|
||||
{
|
||||
infoLines = lines;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>Sets the dimensions and notes; dimension points are in the shown drawing's own coordinates.</summary>
|
||||
public void SetAnnotations(IReadOnlyList<Annotation> dimensions, IReadOnlyList<Note> chips)
|
||||
{
|
||||
annotations = dimensions ?? Array.Empty<Annotation>();
|
||||
notes = chips ?? Array.Empty<Note>();
|
||||
hoverKey = null;
|
||||
if (Plate.Parts.Count > 0)
|
||||
ZoomToFit(false);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>The keys of the labels on screen, in drawing order (dimensions, then notes).</summary>
|
||||
internal IReadOnlyList<string> LabelKeys => CurrentLayout().Labels.Select(l => l.Key).ToList();
|
||||
|
||||
/// <summary>Where the label for <paramref name="key"/> is on the control, or null when none is shown.</summary>
|
||||
public Rectangle? LabelBounds(string key)
|
||||
{
|
||||
var label = CurrentLayout().Labels.FirstOrDefault(l => l.Key == key);
|
||||
return label == null ? null : Rectangle.Round(label.Bounds);
|
||||
}
|
||||
|
||||
public void ShowDrawing(Drawing drawing)
|
||||
{
|
||||
Plate.Parts.Clear();
|
||||
Plate.Size = new Geometry.Size(0, 0);
|
||||
layout = null;
|
||||
|
||||
if (drawing?.Program != null)
|
||||
{
|
||||
AddPartFromDrawing(drawing, Geometry.Vector.Zero);
|
||||
AddPartFromDrawing(drawing, Vector.Zero);
|
||||
// The part is centered on the origin: the shift from drawing to view coordinates.
|
||||
var shown = Plate.Parts[0].BoundingBox;
|
||||
var source = drawing.Program.BoundingBox();
|
||||
shapeOffset = new Vector(shown.X - source.X, shown.Y - source.Y);
|
||||
ZoomToFit();
|
||||
}
|
||||
else
|
||||
{
|
||||
annotations = Array.Empty<Annotation>();
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnResize(System.EventArgs e)
|
||||
protected override void OnResize(EventArgs e)
|
||||
{
|
||||
base.OnResize(e);
|
||||
|
||||
@@ -49,6 +127,100 @@ namespace OpenNest.Controls
|
||||
ZoomToFit(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits the drawing with room around it for its dimensions: lays them out, measures how
|
||||
/// far they reach past the drawing and fits again until that stops changing.
|
||||
/// </summary>
|
||||
public override void ZoomToFit(bool redraw = true)
|
||||
{
|
||||
if (Plate.Parts.Count == 0 || Width <= 0 || Height <= 0)
|
||||
{
|
||||
base.ZoomToFit(redraw);
|
||||
return;
|
||||
}
|
||||
|
||||
var box = Plate.Parts[0].BoundingBox;
|
||||
if (box.Length <= 0 || box.Width <= 0)
|
||||
{
|
||||
base.ZoomToFit(redraw);
|
||||
return;
|
||||
}
|
||||
|
||||
var top = EdgePadding + InfoHeight();
|
||||
var bottom = EdgePadding + ChipRowsHeight();
|
||||
float left = EdgePadding, right = EdgePadding;
|
||||
float reachLeft = 0, reachRight = 0, reachTop = 0, reachBottom = 0;
|
||||
|
||||
for (var pass = 0; pass < 3; pass++)
|
||||
{
|
||||
var ml = left + reachLeft;
|
||||
var mr = right + reachRight;
|
||||
var mt = top + reachTop;
|
||||
var mb = bottom + reachBottom;
|
||||
var w = System.Math.Max(Width - ml - mr, 20f);
|
||||
var h = System.Math.Max(Height - mt - mb, 20f);
|
||||
var scale = (float)System.Math.Min(w / box.Length, h / box.Width);
|
||||
if (!(scale > 0) || float.IsInfinity(scale))
|
||||
break;
|
||||
|
||||
ViewScale = scale;
|
||||
origin.X = ml + (w - (float)box.Length * scale) / 2 - (float)box.X * scale;
|
||||
origin.Y = mt + (h - (float)box.Width * scale) / 2 + (float)(box.Y + box.Width) * scale;
|
||||
UpdateMatrix();
|
||||
|
||||
layout = BuildLayout();
|
||||
var shape = ShapeRect();
|
||||
var reach = layout.DimensionBounds;
|
||||
if (reach.IsEmpty)
|
||||
break;
|
||||
|
||||
reachLeft = System.Math.Max(0, shape.Left - reach.Left);
|
||||
reachRight = System.Math.Max(0, reach.Right - shape.Right);
|
||||
reachTop = System.Math.Max(0, shape.Top - reach.Top);
|
||||
reachBottom = System.Math.Max(0, reach.Bottom - shape.Bottom);
|
||||
}
|
||||
|
||||
layout = null;
|
||||
if (redraw)
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseMove(e);
|
||||
var hit = HitTest(e.Location);
|
||||
Cursor = hit != null ? Cursors.Hand : Cursors.Default;
|
||||
if (hit != hoverKey)
|
||||
{
|
||||
hoverKey = hit;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
base.OnMouseLeave(e);
|
||||
if (hoverKey != null)
|
||||
{
|
||||
hoverKey = null;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
var hit = e.Button == MouseButtons.Left ? HitTest(e.Location) : null;
|
||||
if (hit != null)
|
||||
{
|
||||
LabelClicked?.Invoke(this, hit);
|
||||
return;
|
||||
}
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
private string HitTest(Point location) =>
|
||||
CurrentLayout().Labels.FirstOrDefault(l => l.Enabled && l.Bounds.Contains(location))?.Key;
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
e.Graphics.SmoothingMode = SmoothingMode.HighQuality;
|
||||
@@ -58,6 +230,8 @@ namespace OpenNest.Controls
|
||||
Renderer.DrawLibraryParts(e.Graphics, ColorScheme.Default);
|
||||
e.Graphics.ResetTransform();
|
||||
|
||||
layout = BuildLayout();
|
||||
PaintAnnotations(e.Graphics, layout);
|
||||
PaintInfo(e.Graphics);
|
||||
}
|
||||
|
||||
@@ -78,5 +252,276 @@ namespace OpenNest.Controls
|
||||
y += lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
private float InfoHeight()
|
||||
{
|
||||
var count = infoLines?.Count(l => !string.IsNullOrEmpty(l)) ?? 0;
|
||||
return count == 0 ? 0 : 4 + count * (Font.Height + 1);
|
||||
}
|
||||
|
||||
private float ChipRowsHeight()
|
||||
{
|
||||
if (notes.Count == 0)
|
||||
return 0;
|
||||
var rows = LayoutChips().Select(c => c.Bounds.Top).Distinct().Count();
|
||||
return rows * (LabelHeight + ChipSpacing);
|
||||
}
|
||||
|
||||
#region Layout
|
||||
|
||||
private sealed class Label
|
||||
{
|
||||
public string Key;
|
||||
public string Text;
|
||||
public RectangleF Bounds;
|
||||
public bool Enabled = true;
|
||||
public bool IsNote;
|
||||
}
|
||||
|
||||
private sealed class AnnotationLayout
|
||||
{
|
||||
public readonly List<(PointF A, PointF B)> Lines = new();
|
||||
public readonly List<(PointF Tip, PointF From)> Arrows = new();
|
||||
public readonly List<Label> Labels = new();
|
||||
|
||||
/// <summary>Everything the dimensions draw (not the notes), in control pixels.</summary>
|
||||
public RectangleF DimensionBounds
|
||||
{
|
||||
get
|
||||
{
|
||||
var rects = Lines.Select(l => RectangleF.FromLTRB(
|
||||
System.Math.Min(l.A.X, l.B.X), System.Math.Min(l.A.Y, l.B.Y),
|
||||
System.Math.Max(l.A.X, l.B.X), System.Math.Max(l.A.Y, l.B.Y)))
|
||||
.Concat(Labels.Where(l => !l.IsNote).Select(l => l.Bounds))
|
||||
.ToList();
|
||||
return rects.Count == 0 ? RectangleF.Empty : rects.Aggregate(RectangleF.Union);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AnnotationLayout CurrentLayout() => layout ??= BuildLayout();
|
||||
|
||||
private float LabelHeight => Font.Height + LabelPadding;
|
||||
|
||||
private SizeF LabelSize(string text) =>
|
||||
new(TextRenderer.MeasureText(text, Font, System.Drawing.Size.Empty, TextFormatFlags.NoPadding).Width + 2 * LabelPadding,
|
||||
LabelHeight);
|
||||
|
||||
private PointF ToScreen(Vector shapePoint)
|
||||
{
|
||||
var graph = PointWorldToGraph(shapePoint.X + shapeOffset.X, shapePoint.Y + shapeOffset.Y);
|
||||
return new PointF(graph.X + origin.X, graph.Y + origin.Y);
|
||||
}
|
||||
|
||||
private RectangleF ShapeRect()
|
||||
{
|
||||
var box = Plate.Parts[0].BoundingBox;
|
||||
var graphTopLeft = PointWorldToGraph(box.X, box.Y + box.Width);
|
||||
return new RectangleF(graphTopLeft.X + origin.X, graphTopLeft.Y + origin.Y,
|
||||
LengthWorldToGui(box.Length), LengthWorldToGui(box.Width));
|
||||
}
|
||||
|
||||
private AnnotationLayout BuildLayout()
|
||||
{
|
||||
var result = new AnnotationLayout();
|
||||
if (Plate.Parts.Count == 0)
|
||||
return result;
|
||||
|
||||
var shape = ShapeRect();
|
||||
foreach (var annotation in annotations)
|
||||
{
|
||||
var d = annotation.Dimension;
|
||||
switch (d.Kind)
|
||||
{
|
||||
case ShapeDimensionKind.Linear:
|
||||
LayoutLinear(result, annotation, shape);
|
||||
break;
|
||||
case ShapeDimensionKind.Inline:
|
||||
LayoutInline(result, annotation);
|
||||
break;
|
||||
case ShapeDimensionKind.Leader:
|
||||
LayoutLeader(result, annotation, shape);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result.Labels.AddRange(LayoutChips());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static float Dot(PointF p, PointF d) => p.X * d.X + p.Y * d.Y;
|
||||
|
||||
private static PointF Along(PointF p, PointF d, float t) => new(p.X + d.X * t, p.Y + d.Y * t);
|
||||
|
||||
private void LayoutLinear(AnnotationLayout result, Annotation annotation, RectangleF shape)
|
||||
{
|
||||
var d = annotation.Dimension;
|
||||
var side = new PointF((float)d.Side.X, (float)-d.Side.Y);
|
||||
var a = ToScreen(d.Start);
|
||||
var b = ToScreen(d.End);
|
||||
var extreme = new[]
|
||||
{
|
||||
new PointF(shape.Left, shape.Top), new PointF(shape.Right, shape.Top),
|
||||
new PointF(shape.Left, shape.Bottom), new PointF(shape.Right, shape.Bottom),
|
||||
}.Max(c => Dot(c, side));
|
||||
|
||||
var lineA = Along(a, side, extreme - Dot(a, side) + Gap);
|
||||
var lineB = Along(b, side, extreme - Dot(b, side) + Gap);
|
||||
result.Lines.Add((Along(a, side, 3), Along(lineA, side, ExtensionOverrun)));
|
||||
result.Lines.Add((Along(b, side, 3), Along(lineB, side, ExtensionOverrun)));
|
||||
AddDimensionLine(result, lineA, lineB);
|
||||
AddLabels(result, annotation, new PointF((lineA.X + lineB.X) / 2, (lineA.Y + lineB.Y) / 2), 0.5f);
|
||||
}
|
||||
|
||||
private void LayoutInline(AnnotationLayout result, Annotation annotation)
|
||||
{
|
||||
var a = ToScreen(annotation.Dimension.Start);
|
||||
var b = ToScreen(annotation.Dimension.End);
|
||||
AddDimensionLine(result, a, b);
|
||||
AddLabels(result, annotation, new PointF((a.X + b.X) / 2, (a.Y + b.Y) / 2), 0.5f);
|
||||
}
|
||||
|
||||
private void LayoutLeader(AnnotationLayout result, Annotation annotation, RectangleF shape)
|
||||
{
|
||||
var d = annotation.Dimension;
|
||||
var p = ToScreen(d.Start);
|
||||
var dir = new PointF((float)d.Side.X, (float)-d.Side.Y);
|
||||
|
||||
// Run out of the drawing's bounds, then a short level shoulder to the label.
|
||||
var exitX = dir.X > 0 ? (shape.Right - p.X) / dir.X : dir.X < 0 ? (shape.Left - p.X) / dir.X : float.MaxValue;
|
||||
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;
|
||||
var end = Along(p, dir, length);
|
||||
var right = dir.X >= 0;
|
||||
var shoulder = new PointF(end.X + (right ? Shoulder : -Shoulder), end.Y);
|
||||
|
||||
result.Lines.Add((p, end));
|
||||
result.Lines.Add((end, shoulder));
|
||||
result.Arrows.Add((p, end));
|
||||
AddLabels(result, annotation, new PointF(shoulder.X + (right ? 2 : -2), shoulder.Y), right ? 0 : 1);
|
||||
}
|
||||
|
||||
private static void AddDimensionLine(AnnotationLayout result, PointF a, PointF b)
|
||||
{
|
||||
result.Lines.Add((a, b));
|
||||
result.Arrows.Add((a, b));
|
||||
result.Arrows.Add((b, a));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
var d = annotation.Dimension;
|
||||
var main = LabelSize(annotation.Text);
|
||||
var hasCount = d.CountProperty != null && annotation.CountText != null;
|
||||
var count = hasCount ? LabelSize(annotation.CountText) : SizeF.Empty;
|
||||
var total = main.Width + count.Width;
|
||||
var x = anchor.X - total * alignX;
|
||||
var y = anchor.Y - main.Height / 2;
|
||||
|
||||
result.Labels.Add(new Label { Key = d.Property, Text = annotation.Text, Bounds = new RectangleF(x, y, main.Width, main.Height) });
|
||||
if (hasCount)
|
||||
result.Labels.Add(new Label
|
||||
{
|
||||
Key = d.CountProperty,
|
||||
Text = annotation.CountText,
|
||||
Bounds = new RectangleF(x + main.Width, y, count.Width, count.Height),
|
||||
});
|
||||
}
|
||||
|
||||
private IEnumerable<Label> LayoutChips()
|
||||
{
|
||||
var height = LabelHeight;
|
||||
var rows = new List<List<Label>>();
|
||||
var x = EdgePadding;
|
||||
foreach (var note in notes)
|
||||
{
|
||||
var size = LabelSize(note.Text);
|
||||
if (rows.Count == 0 || (x + size.Width > Width - EdgePadding && rows[^1].Count > 0))
|
||||
{
|
||||
rows.Add(new List<Label>());
|
||||
x = EdgePadding;
|
||||
}
|
||||
rows[^1].Add(new Label
|
||||
{
|
||||
Key = note.Key,
|
||||
Text = note.Text,
|
||||
Enabled = note.Enabled,
|
||||
IsNote = true,
|
||||
Bounds = new RectangleF(x, 0, size.Width, height),
|
||||
});
|
||||
x += size.Width + ChipSpacing;
|
||||
}
|
||||
|
||||
// Rows stack up from the bottom edge, the first row on top.
|
||||
for (var i = 0; i < rows.Count; i++)
|
||||
{
|
||||
var y = Height - EdgePadding - (rows.Count - i) * (height + ChipSpacing) + ChipSpacing;
|
||||
foreach (var label in rows[i])
|
||||
{
|
||||
label.Bounds.Y = y;
|
||||
yield return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Painting
|
||||
|
||||
private void PaintAnnotations(Graphics g, AnnotationLayout drawn)
|
||||
{
|
||||
using var line = new Pen(DimensionColor, 1f);
|
||||
foreach (var (a, b) in drawn.Lines)
|
||||
g.DrawLine(line, a, b);
|
||||
|
||||
using var arrowBrush = new SolidBrush(DimensionColor);
|
||||
foreach (var (tip, from) in drawn.Arrows)
|
||||
FillArrow(g, arrowBrush, tip, from);
|
||||
|
||||
foreach (var label in drawn.Labels)
|
||||
PaintLabel(g, label);
|
||||
}
|
||||
|
||||
private static void FillArrow(Graphics g, Brush brush, PointF tip, PointF from)
|
||||
{
|
||||
var dx = from.X - tip.X;
|
||||
var dy = from.Y - tip.Y;
|
||||
var length = (float)System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (length < 1)
|
||||
return;
|
||||
dx /= length;
|
||||
dy /= length;
|
||||
var back = new PointF(tip.X + dx * ArrowLength, tip.Y + dy * ArrowLength);
|
||||
g.FillPolygon(brush, new[]
|
||||
{
|
||||
tip,
|
||||
new PointF(back.X - dy * ArrowHalfWidth, back.Y + dx * ArrowHalfWidth),
|
||||
new PointF(back.X + dy * ArrowHalfWidth, back.Y - dx * ArrowHalfWidth),
|
||||
});
|
||||
}
|
||||
|
||||
private void PaintLabel(Graphics g, Label label)
|
||||
{
|
||||
var active = label.Key == activeKey;
|
||||
var hot = label.Enabled && label.Key == hoverKey;
|
||||
var back = active ? Color.FromArgb(215, 232, 252)
|
||||
: hot ? Color.FromArgb(255, 236, 210)
|
||||
: label.IsNote ? Color.FromArgb(246, 246, 246)
|
||||
: Color.White;
|
||||
var rect = label.Bounds;
|
||||
using (var brush = new SolidBrush(back))
|
||||
g.FillRectangle(brush, rect);
|
||||
using (var border = new Pen(active ? ActiveColor : hot ? HoverColor : DimensionColor, active || hot ? 1.5f : 1f))
|
||||
g.DrawRectangle(border, rect.X, rect.Y, rect.Width, rect.Height);
|
||||
TextRenderer.DrawText(g, label.Text, Font, Rectangle.Round(rect),
|
||||
label.Enabled ? Color.Black : SystemColors.GrayText,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+1
-14
@@ -34,7 +34,6 @@ namespace OpenNest.Forms
|
||||
configComboBox = new System.Windows.Forms.ComboBox();
|
||||
contentPanel = new System.Windows.Forms.Panel();
|
||||
previewBox = new OpenNest.Controls.ShapePreviewControl();
|
||||
parametersPanel = new System.Windows.Forms.Panel();
|
||||
buttonPanel = new System.Windows.Forms.Panel();
|
||||
addButton = new System.Windows.Forms.Button();
|
||||
closeButton = new System.Windows.Forms.Button();
|
||||
@@ -189,7 +188,6 @@ namespace OpenNest.Forms
|
||||
// contentPanel
|
||||
//
|
||||
contentPanel.Controls.Add(previewBox);
|
||||
contentPanel.Controls.Add(parametersPanel);
|
||||
contentPanel.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
contentPanel.Location = new System.Drawing.Point(9, 110);
|
||||
contentPanel.Name = "contentPanel";
|
||||
@@ -240,21 +238,11 @@ namespace OpenNest.Forms
|
||||
previewBox.Plate = plate1;
|
||||
previewBox.RotateIncrementAngle = 10D;
|
||||
previewBox.ShowBendLines = false;
|
||||
previewBox.Size = new System.Drawing.Size(318, 363);
|
||||
previewBox.Size = new System.Drawing.Size(578, 363);
|
||||
previewBox.Status = "Select";
|
||||
previewBox.TabIndex = 4;
|
||||
previewBox.TabStop = false;
|
||||
//
|
||||
// parametersPanel
|
||||
//
|
||||
parametersPanel.AutoScroll = true;
|
||||
parametersPanel.Dock = System.Windows.Forms.DockStyle.Right;
|
||||
parametersPanel.Location = new System.Drawing.Point(318, 0);
|
||||
parametersPanel.Name = "parametersPanel";
|
||||
parametersPanel.Padding = new System.Windows.Forms.Padding(8, 0, 0, 0);
|
||||
parametersPanel.Size = new System.Drawing.Size(260, 363);
|
||||
parametersPanel.TabIndex = 5;
|
||||
//
|
||||
// buttonPanel
|
||||
//
|
||||
buttonPanel.Controls.Add(addButton);
|
||||
@@ -328,7 +316,6 @@ namespace OpenNest.Forms
|
||||
private System.Windows.Forms.ComboBox configComboBox;
|
||||
private System.Windows.Forms.Panel contentPanel;
|
||||
private Controls.ShapePreviewControl previewBox;
|
||||
private System.Windows.Forms.Panel parametersPanel;
|
||||
private System.Windows.Forms.Panel buttonPanel;
|
||||
private System.Windows.Forms.Button addButton;
|
||||
private System.Windows.Forms.Button closeButton;
|
||||
|
||||
+295
-222
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
@@ -9,6 +10,8 @@ using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Controls;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.Shapes;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
@@ -37,11 +40,13 @@ namespace OpenNest.Forms
|
||||
|
||||
private readonly List<Drawing> addedDrawings = new List<Drawing>();
|
||||
private readonly List<ShapeEntry> shapeEntries = new List<ShapeEntry>();
|
||||
private readonly List<ParameterBinding> parameterBindings = new List<ParameterBinding>();
|
||||
private readonly List<PropertyInfo> parameters = new List<PropertyInfo>();
|
||||
private readonly Dictionary<string, object> values = new Dictionary<string, object>();
|
||||
private readonly HashSet<string> existingNames;
|
||||
|
||||
private ShapeEntry selectedEntry;
|
||||
private bool suppressPreview;
|
||||
private Control editor;
|
||||
|
||||
public ShapeLibraryForm(IEnumerable<string> existingDrawingNames = null)
|
||||
{
|
||||
@@ -61,6 +66,7 @@ namespace OpenNest.Forms
|
||||
|
||||
shapeListView.SelectedIndexChanged += ShapeListView_SelectedIndexChanged;
|
||||
configComboBox.SelectedIndexChanged += ConfigComboBox_SelectedIndexChanged;
|
||||
previewBox.LabelClicked += PreviewBox_LabelClicked;
|
||||
addButton.Click += AddButton_Click;
|
||||
closeButton.Click += (s, e) => Close();
|
||||
|
||||
@@ -207,6 +213,7 @@ namespace OpenNest.Forms
|
||||
|
||||
private void ShowShape(ShapeEntry entry)
|
||||
{
|
||||
CloseEditor();
|
||||
selectedEntry = entry;
|
||||
suppressPreview = true;
|
||||
|
||||
@@ -227,7 +234,7 @@ namespace OpenNest.Forms
|
||||
nameTextBox.Text = selectedEntry.DisplayName;
|
||||
var defaults = (ShapeDefinition)Activator.CreateInstance(selectedEntry.ShapeType);
|
||||
defaults.SetPreviewDefaults();
|
||||
BuildParameterControls(selectedEntry.ShapeType, defaults);
|
||||
LoadParameters(selectedEntry.ShapeType, defaults);
|
||||
}
|
||||
|
||||
suppressPreview = false;
|
||||
@@ -243,143 +250,78 @@ namespace OpenNest.Forms
|
||||
nameTextBox.Text = config.Name;
|
||||
|
||||
suppressPreview = true;
|
||||
BuildParameterControls(selectedEntry.ShapeType, config);
|
||||
LoadParameters(selectedEntry.ShapeType, config);
|
||||
suppressPreview = false;
|
||||
UpdatePreview();
|
||||
}
|
||||
|
||||
private void BuildParameterControls(Type shapeType, ShapeDefinition sourceValues)
|
||||
private void LoadParameters(Type shapeType, ShapeDefinition sourceValues)
|
||||
{
|
||||
parametersPanel.SuspendLayout();
|
||||
parametersPanel.Controls.Clear();
|
||||
parameterBindings.Clear();
|
||||
CloseEditor();
|
||||
parameters.Clear();
|
||||
values.Clear();
|
||||
|
||||
var props = shapeType
|
||||
.GetProperties(
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly
|
||||
)
|
||||
.Where(p => p.CanRead && p.CanWrite && p.Name != "Name")
|
||||
.ToArray();
|
||||
parameters.AddRange(shapeType
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Where(p => p.CanRead && p.CanWrite && p.Name != "Name"));
|
||||
|
||||
var panelWidth = parametersPanel.ClientSize.Width - parametersPanel.Padding.Horizontal;
|
||||
var y = 4;
|
||||
|
||||
foreach (var prop in props)
|
||||
{
|
||||
var label = new Label
|
||||
{
|
||||
Text = FriendlyName(prop.Name),
|
||||
Location = new Point(parametersPanel.Padding.Left, y),
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
y += 18;
|
||||
|
||||
Control editor;
|
||||
if (prop.PropertyType == typeof(bool))
|
||||
{
|
||||
var cb = new CheckBox
|
||||
{
|
||||
Location = new Point(parametersPanel.Padding.Left, y),
|
||||
AutoSize = true,
|
||||
Checked = sourceValues != null && (bool)prop.GetValue(sourceValues),
|
||||
};
|
||||
cb.CheckedChanged += (s, ev) => UpdatePreview();
|
||||
editor = cb;
|
||||
}
|
||||
else if (prop.PropertyType == typeof(string) && prop.Name == "PipeSize")
|
||||
{
|
||||
var combo = new ComboBox
|
||||
{
|
||||
Location = new Point(parametersPanel.Padding.Left, y),
|
||||
Width = panelWidth,
|
||||
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
};
|
||||
|
||||
// Initial population: every entry; the filter runs on first UpdatePreview.
|
||||
foreach (var entry in PipeSizes.All)
|
||||
combo.Items.Add(entry.Label);
|
||||
|
||||
var initial = sourceValues != null ? (string)prop.GetValue(sourceValues) : null;
|
||||
if (!string.IsNullOrEmpty(initial) && combo.Items.Contains(initial))
|
||||
combo.SelectedItem = initial;
|
||||
else if (combo.Items.Count > 0)
|
||||
combo.SelectedIndex = 0;
|
||||
|
||||
combo.SelectedIndexChanged += (s, ev) => UpdatePreview();
|
||||
editor = combo;
|
||||
}
|
||||
else
|
||||
{
|
||||
var tb = new TextBox
|
||||
{
|
||||
Location = new Point(parametersPanel.Padding.Left, y),
|
||||
Width = panelWidth,
|
||||
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
|
||||
};
|
||||
|
||||
if (sourceValues != null)
|
||||
{
|
||||
if (prop.PropertyType == typeof(int))
|
||||
tb.Text = ((int)prop.GetValue(sourceValues)).ToString();
|
||||
else
|
||||
tb.Text = ((double)prop.GetValue(sourceValues)).ToString("G");
|
||||
}
|
||||
|
||||
tb.TextChanged += (s, ev) => UpdatePreview();
|
||||
editor = tb;
|
||||
}
|
||||
|
||||
parameterBindings.Add(new ParameterBinding { Property = prop, Control = editor });
|
||||
|
||||
parametersPanel.Controls.Add(label);
|
||||
parametersPanel.Controls.Add(editor);
|
||||
|
||||
y += 30;
|
||||
}
|
||||
|
||||
parametersPanel.ResumeLayout(true);
|
||||
foreach (var prop in parameters)
|
||||
values[prop.Name] = sourceValues != null ? prop.GetValue(sourceValues) : DefaultOf(prop.PropertyType);
|
||||
}
|
||||
|
||||
private void UpdatePreview()
|
||||
private static object DefaultOf(Type type) => type.IsValueType ? Activator.CreateInstance(type) : null;
|
||||
|
||||
private PropertyInfo Parameter(string name) => parameters.FirstOrDefault(p => p.Name == name);
|
||||
|
||||
private void UpdatePreview(string error = null)
|
||||
{
|
||||
if (suppressPreview || selectedEntry == null)
|
||||
return;
|
||||
|
||||
UpdatePipeSizeFilter();
|
||||
UpdatePipeSize();
|
||||
|
||||
try
|
||||
{
|
||||
var shape = CreateShapeFromInputs();
|
||||
if (shape == null)
|
||||
var drawing = shape.GetDrawing();
|
||||
if (drawing?.Program == null)
|
||||
{
|
||||
InvalidatePreview();
|
||||
return;
|
||||
}
|
||||
|
||||
var drawing = shape.GetDrawing();
|
||||
addButton.Enabled = drawing?.Program != null;
|
||||
addButton.Enabled = true;
|
||||
nameTextBox.Text = shape.GenerateName();
|
||||
previewBox.ShowDrawing(drawing);
|
||||
|
||||
if (drawing?.Program != null)
|
||||
var dimensions = shape.GetDimensions()
|
||||
.Where(d => Parameter(d.Property) != null)
|
||||
.ToList();
|
||||
var placed = dimensions.Select(d => d.Property)
|
||||
.Concat(dimensions.Select(d => d.CountProperty).Where(p => p != null))
|
||||
.ToHashSet();
|
||||
previewBox.SetAnnotations(
|
||||
dimensions.Select(d => new ShapePreviewControl.Annotation(d,
|
||||
d.Prefix + FormatLength((double)values[d.Property]),
|
||||
d.CountProperty != null && values.TryGetValue(d.CountProperty, out var count)
|
||||
? $"x {count} {d.CountNoun}"
|
||||
: null)).ToList(),
|
||||
parameters.Where(p => !placed.Contains(p.Name)).Select(NoteFor).ToList());
|
||||
|
||||
var bb = drawing.Program.BoundingBox();
|
||||
var info = string.Format("{0:F3} x {1:F3}", bb.Size.Length, bb.Size.Width);
|
||||
|
||||
if (
|
||||
shape is PipeFlangeShape flange
|
||||
&& !flange.Blind
|
||||
&& !string.IsNullOrEmpty(flange.PipeSize)
|
||||
&& !PipeSizes.TryGetOD(flange.PipeSize, out _)
|
||||
)
|
||||
{
|
||||
var bb = drawing.Program.BoundingBox();
|
||||
var info = string.Format("{0:F3} x {1:F3}", bb.Size.Length, bb.Size.Width);
|
||||
|
||||
if (
|
||||
shape is PipeFlangeShape flange
|
||||
&& !flange.Blind
|
||||
&& !string.IsNullOrEmpty(flange.PipeSize)
|
||||
&& !PipeSizes.TryGetOD(flange.PipeSize, out _)
|
||||
)
|
||||
{
|
||||
info += " — Invalid pipe size, no bore cut";
|
||||
}
|
||||
|
||||
previewBox.SetInfo(nameTextBox.Text, info);
|
||||
info += " — Invalid pipe size, no bore cut";
|
||||
}
|
||||
|
||||
previewBox.SetInfo(nameTextBox.Text, info, error);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -387,140 +329,277 @@ namespace OpenNest.Forms
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No drawing to dimension: every parameter becomes a note so the input can still be
|
||||
/// corrected.
|
||||
/// </summary>
|
||||
private void InvalidatePreview()
|
||||
{
|
||||
addButton.Enabled = false;
|
||||
previewBox.ShowDrawing(null);
|
||||
previewBox.SetAnnotations(null, parameters.Select(NoteFor).ToList());
|
||||
previewBox.SetInfo("Invalid shape input");
|
||||
}
|
||||
|
||||
private void UpdatePipeSizeFilter()
|
||||
private ShapePreviewControl.Note NoteFor(PropertyInfo prop)
|
||||
{
|
||||
// Find the PipeSize combo and the numeric inputs it depends on.
|
||||
ComboBox pipeCombo = null;
|
||||
double holePattern = 0,
|
||||
holeDia = 0,
|
||||
clearance = 0;
|
||||
bool blind = false;
|
||||
|
||||
foreach (var binding in parameterBindings)
|
||||
var value = values[prop.Name];
|
||||
var text = value switch
|
||||
{
|
||||
var name = binding.Property.Name;
|
||||
if (name == "PipeSize" && binding.Control is ComboBox cb)
|
||||
pipeCombo = cb;
|
||||
else if (name == "HolePatternDiameter" && binding.Control is TextBox tb1)
|
||||
double.TryParse(tb1.Text, out holePattern);
|
||||
else if (name == "HoleDiameter" && binding.Control is TextBox tb2)
|
||||
double.TryParse(tb2.Text, out holeDia);
|
||||
else if (name == "PipeClearance" && binding.Control is TextBox tb3)
|
||||
double.TryParse(tb3.Text, out clearance);
|
||||
else if (name == "Blind" && binding.Control is CheckBox chk)
|
||||
blind = chk.Checked;
|
||||
}
|
||||
|
||||
if (pipeCombo == null)
|
||||
return;
|
||||
|
||||
// Disable when blind, but keep visible with the selection preserved.
|
||||
pipeCombo.Enabled = !blind;
|
||||
|
||||
// Compute filter: pipeOD + clearance < HolePatternDiameter - HoleDiameter.
|
||||
var maxPipeOD = holePattern - holeDia - clearance;
|
||||
var fittingLabels = PipeSizes.GetFittingSizes(maxPipeOD).Select(e => e.Label).ToList();
|
||||
|
||||
// Sequence-equal on existing items — no-op if unchanged (avoids flicker).
|
||||
var currentLabels = pipeCombo.Items.Cast<string>().ToList();
|
||||
if (currentLabels.SequenceEqual(fittingLabels))
|
||||
return;
|
||||
|
||||
var previousSelection = pipeCombo.SelectedItem as string;
|
||||
|
||||
pipeCombo.BeginUpdate();
|
||||
try
|
||||
{
|
||||
pipeCombo.Items.Clear();
|
||||
foreach (var label in fittingLabels)
|
||||
pipeCombo.Items.Add(label);
|
||||
|
||||
if (fittingLabels.Count == 0)
|
||||
{
|
||||
// No pipe fits — leave unselected.
|
||||
}
|
||||
else if (previousSelection != null && fittingLabels.Contains(previousSelection))
|
||||
{
|
||||
pipeCombo.SelectedItem = previousSelection;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Select the largest (last, since PipeSizes.All is sorted ascending).
|
||||
pipeCombo.SelectedIndex = fittingLabels.Count - 1;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
pipeCombo.EndUpdate();
|
||||
}
|
||||
bool b => b ? "Yes" : "No",
|
||||
double d => FormatLength(d),
|
||||
null => "None",
|
||||
_ => value.ToString(),
|
||||
};
|
||||
var enabled = prop.Name != nameof(PipeFlangeShape.PipeSize) || !Equals(values.GetValueOrDefault(nameof(PipeFlangeShape.Blind)), true);
|
||||
return new ShapePreviewControl.Note(prop.Name, $"{FriendlyName(prop.Name)}: {text}", enabled);
|
||||
}
|
||||
|
||||
private ShapeDefinition CreateShapeFromInputs()
|
||||
internal static string FormatLength(double inches) => Fraction.Format(inches) + "\"";
|
||||
|
||||
/// <summary>The pipe sizes that fit inside the bolt holes: pipe OD + clearance < pattern - hole.</summary>
|
||||
private List<string> FittingPipeSizes()
|
||||
{
|
||||
double Length(string name) => values.TryGetValue(name, out var v) && v is double d ? d : 0;
|
||||
var maxPipeOD = Length(nameof(PipeFlangeShape.HolePatternDiameter))
|
||||
- Length(nameof(PipeFlangeShape.HoleDiameter))
|
||||
- Length(nameof(PipeFlangeShape.PipeClearance));
|
||||
return PipeSizes.GetFittingSizes(maxPipeOD).Select(e => e.Label).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Keeps the pipe size one that fits, else the largest that does (none when none fits).</summary>
|
||||
private void UpdatePipeSize()
|
||||
{
|
||||
const string key = nameof(PipeFlangeShape.PipeSize);
|
||||
if (Parameter(key)?.PropertyType != typeof(string))
|
||||
return;
|
||||
|
||||
var fitting = FittingPipeSizes();
|
||||
if (values[key] is string current && fitting.Contains(current))
|
||||
return;
|
||||
values[key] = fitting.Count > 0 ? fitting[^1] : null;
|
||||
}
|
||||
|
||||
private ShapeDefinition CreateShapeFromInputs(IReadOnlyDictionary<string, object> source = null)
|
||||
{
|
||||
source ??= values;
|
||||
var shape = (ShapeDefinition)Activator.CreateInstance(selectedEntry.ShapeType);
|
||||
shape.Name = nameTextBox.Text;
|
||||
foreach (var prop in parameters)
|
||||
prop.SetValue(shape, source[prop.Name]);
|
||||
return shape;
|
||||
}
|
||||
|
||||
foreach (var binding in parameterBindings)
|
||||
#region On-drawing editors
|
||||
|
||||
private void PreviewBox_LabelClicked(object sender, string key) => EditParameter(key);
|
||||
|
||||
/// <summary>
|
||||
/// Opens the editor for a parameter over its label: a text box for a number, a list for
|
||||
/// the pipe size; a yes/no parameter toggles at once.
|
||||
/// </summary>
|
||||
internal void EditParameter(string key)
|
||||
{
|
||||
var prop = Parameter(key);
|
||||
if (prop == null)
|
||||
return;
|
||||
|
||||
CloseEditor();
|
||||
if (prop.PropertyType == typeof(bool))
|
||||
{
|
||||
if (binding.Property.PropertyType == typeof(bool))
|
||||
{
|
||||
var cb = (CheckBox)binding.Control;
|
||||
binding.Property.SetValue(shape, cb.Checked);
|
||||
continue;
|
||||
}
|
||||
values[key] = !(bool)values[key];
|
||||
UpdatePreview();
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.Control is ComboBox combo)
|
||||
{
|
||||
binding.Property.SetValue(shape, combo.SelectedItem?.ToString());
|
||||
continue;
|
||||
}
|
||||
var bounds = previewBox.LabelBounds(key);
|
||||
if (bounds == null)
|
||||
return;
|
||||
|
||||
var tb = (TextBox)binding.Control;
|
||||
if (prop.PropertyType == typeof(string))
|
||||
OpenListEditor(key, bounds.Value);
|
||||
else
|
||||
OpenTextEditor(key, bounds.Value);
|
||||
}
|
||||
|
||||
if (binding.Property.PropertyType == typeof(int))
|
||||
{
|
||||
if (int.TryParse(tb.Text, out var intVal))
|
||||
{
|
||||
binding.Property.SetValue(shape, intVal);
|
||||
tb.ForeColor = SystemColors.WindowText;
|
||||
}
|
||||
else
|
||||
{
|
||||
tb.ForeColor = Color.Red;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var val = ArchUnits.GetLengthInches(tb);
|
||||
if (double.IsNaN(val))
|
||||
return null;
|
||||
internal Control ActiveEditor => editor;
|
||||
|
||||
binding.Property.SetValue(shape, val);
|
||||
private void OpenTextEditor(string key, Rectangle bounds)
|
||||
{
|
||||
var value = values[key];
|
||||
var box = new DimensionBox
|
||||
{
|
||||
Font = previewBox.Font,
|
||||
Text = value is double d ? Fraction.Format(d) : Convert.ToString(value, CultureInfo.CurrentCulture),
|
||||
Location = bounds.Location,
|
||||
Width = System.Math.Max(bounds.Width + 24, 80),
|
||||
};
|
||||
box.Commit += (_, _) => TryCommit(key, box.Text);
|
||||
box.Cancel += (_, _) => CloseEditor();
|
||||
box.Advance += (_, back) =>
|
||||
{
|
||||
if (TryCommit(key, box.Text))
|
||||
EditNext(key, back);
|
||||
};
|
||||
box.LostFocus += (_, _) =>
|
||||
{
|
||||
// Clicking away keeps a valid entry and drops an invalid one.
|
||||
if (editor == box && !TryCommit(key, box.Text))
|
||||
CloseEditor();
|
||||
};
|
||||
Show(key, box);
|
||||
box.SelectAll();
|
||||
}
|
||||
|
||||
private void OpenListEditor(string key, Rectangle bounds)
|
||||
{
|
||||
var combo = new ComboBox
|
||||
{
|
||||
Font = previewBox.Font,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
Location = bounds.Location,
|
||||
Width = System.Math.Max(bounds.Width + 40, 110),
|
||||
};
|
||||
foreach (var label in FittingPipeSizes())
|
||||
combo.Items.Add(label);
|
||||
if (values[key] is string current && combo.Items.Contains(current))
|
||||
combo.SelectedItem = current;
|
||||
|
||||
combo.SelectionChangeCommitted += (_, _) =>
|
||||
{
|
||||
values[key] = combo.SelectedItem as string;
|
||||
CloseEditor();
|
||||
UpdatePreview();
|
||||
};
|
||||
combo.DropDownClosed += (_, _) => BeginInvoke(() =>
|
||||
{
|
||||
if (editor == combo)
|
||||
CloseEditor();
|
||||
});
|
||||
combo.LostFocus += (_, _) =>
|
||||
{
|
||||
if (editor == combo && !combo.DroppedDown)
|
||||
CloseEditor();
|
||||
};
|
||||
Show(key, combo);
|
||||
combo.DroppedDown = combo.Items.Count > 0;
|
||||
}
|
||||
|
||||
private void Show(string key, Control control)
|
||||
{
|
||||
editor = control;
|
||||
previewBox.Controls.Add(control);
|
||||
previewBox.ActiveKey = key;
|
||||
control.Focus();
|
||||
}
|
||||
|
||||
private void CloseEditor()
|
||||
{
|
||||
var control = editor;
|
||||
if (control == null)
|
||||
return;
|
||||
editor = null; // first: removing the control raises LostFocus again
|
||||
previewBox.ActiveKey = null;
|
||||
previewBox.Controls.Remove(control);
|
||||
control.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Tab moves to the next number on the drawing, Shift+Tab to the previous.</summary>
|
||||
private void EditNext(string key, bool back)
|
||||
{
|
||||
var keys = previewBox.LabelKeys
|
||||
.Where(k => Parameter(k) is { } p && (p.PropertyType == typeof(double) || p.PropertyType == typeof(int)))
|
||||
.ToList();
|
||||
if (keys.Count == 0)
|
||||
return;
|
||||
var index = keys.IndexOf(key);
|
||||
var next = index < 0 ? 0 : (index + (back ? keys.Count - 1 : 1)) % keys.Count;
|
||||
EditParameter(keys[next]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the typed text to <paramref name="key"/> when it is a valid value that still
|
||||
/// makes a shape; otherwise leaves the editor open with the text in red.
|
||||
/// </summary>
|
||||
private bool TryCommit(string key, string text)
|
||||
{
|
||||
var prop = Parameter(key);
|
||||
var error = ParseValue(prop, text, out var value);
|
||||
if (error == null)
|
||||
{
|
||||
var candidate = new Dictionary<string, object>(values) { [key] = value };
|
||||
error = ShapeError(candidate);
|
||||
if (error == null)
|
||||
{
|
||||
values[key] = value;
|
||||
CloseEditor();
|
||||
UpdatePreview();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return shape;
|
||||
if (editor != null)
|
||||
editor.ForeColor = Color.Red;
|
||||
UpdatePreviewInfoError(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void UpdatePreviewInfoError(string error) =>
|
||||
previewBox.SetInfo(nameTextBox.Text, error);
|
||||
|
||||
private static string ParseValue(PropertyInfo prop, string text, out object value)
|
||||
{
|
||||
value = null;
|
||||
if (prop.PropertyType == typeof(int))
|
||||
{
|
||||
if (!int.TryParse(text?.Trim(), NumberStyles.Integer, CultureInfo.CurrentCulture, out var count))
|
||||
return $"'{text}' is not a whole number.";
|
||||
if (count < 0)
|
||||
return "The value cannot be negative.";
|
||||
value = count;
|
||||
return null;
|
||||
}
|
||||
|
||||
double length;
|
||||
try
|
||||
{
|
||||
length = double.TryParse(text, NumberStyles.Float, CultureInfo.CurrentCulture, out var plain)
|
||||
? plain
|
||||
: ArchUnits.ParseToInches(text);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return $"'{text}' is not a length.";
|
||||
}
|
||||
if (!double.IsFinite(length))
|
||||
return $"'{text}' is not a length.";
|
||||
if (length < 0)
|
||||
return "The value cannot be negative.";
|
||||
value = length;
|
||||
return null;
|
||||
}
|
||||
|
||||
private string ShapeError(IReadOnlyDictionary<string, object> candidate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var program = CreateShapeFromInputs(candidate).GetDrawing()?.Program;
|
||||
var box = program?.BoundingBox();
|
||||
return box != null && box.Length > 0 && box.Width > 0 ? null : "That value does not make a shape.";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "That value does not make a shape.";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void AddButton_Click(object sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var shape = CreateShapeFromInputs();
|
||||
if (shape == null)
|
||||
{
|
||||
InvalidatePreview();
|
||||
return;
|
||||
}
|
||||
|
||||
var drawing = shape.GetDrawing();
|
||||
drawing.Name = GetUniqueName(drawing.Name);
|
||||
drawing.Color = Drawing.GetNextColor();
|
||||
@@ -589,11 +668,5 @@ namespace OpenNest.Forms
|
||||
|
||||
public override string ToString() => DisplayName;
|
||||
}
|
||||
|
||||
private class ParameterBinding
|
||||
{
|
||||
public PropertyInfo Property { get; set; }
|
||||
public Control Control { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user