diff --git a/AGENTS.md b/AGENTS.md index baa5468..6e79901 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ generated parser, on netstandard2.0): keep it so, and keep containers and other | `src/SharpEquations.Avalonia/` | The controls for Avalonia: `MathEditBox` (the editor), `MathView`; embeds Libertinus Math | | `samples/SharpEquations.Avalonia.Demo/` | A window with the editor (`DemoView`, shared with the web demo) | | `samples/SharpEquations.Avalonia.Browser.Demo/` | The same demo on WebAssembly (needs the `wasm-tools` workload, so not in the solution; `.github/workflows/demo.yml` publishes it to GitHub Pages) | -| `samples/SharpEquations.Avalonia.MathTools.Demo/` | A second app, math tools around the editor (`MathToolsView`, a page each: the scientific keypad, the library of formulas, the unit converter) | +| `samples/SharpEquations.Avalonia.MathTools.Demo/` | A second app, math tools around the editor (`MathToolsView`, a page each: the scientific keypad, the library of formulas, the unit converter, the solver of triangles) | | `samples/SharpEquations.Avalonia.Browser.MathTools.Demo/` | The math tools on WebAssembly (as the web demo, not in the solution build; `demo.yml` publishes them under `math/`) | | `benchmarks/SharpEquations.Benchmarks/` | BenchmarkDotNet benchmarks of reading, writing and converting the corpora | | `tests/SharpEquations.Tests/` | TUnit tests, in folders by subject (PublicApi, MathML, Omml, Mtef, UnicodeMath, LaTeX, Property, Regression) | diff --git a/README.md b/README.md index 08a6a42..d177249 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ UnicodeMath and LaTeX, and `samples/SharpEquations.Avalonia.Browser.Demo` the sa (published to GitHub Pages from `main`; to run it locally: `dotnet workload install wasm-tools`, then `dotnet run --project samples/SharpEquations.Avalonia.Browser.Demo`). `samples/SharpEquations.Avalonia.MathTools.Demo` is a second app around the editor, a set of math tools (a scientific keypad that types into the editor, a library of -formulas, a unit converter), with +formulas, a unit converter, a solver of triangles), with `samples/SharpEquations.Avalonia.Browser.MathTools.Demo` in the browser (published under `math/` of the same site). ## Architecture diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs index 72aff30..ba76d3e 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs @@ -9,8 +9,8 @@ namespace SharpEquations.Avalonia.MathTools using SharpEquations; /// - /// The math tools, a page each: the scientific keypad around the equation editor, the library of formulas and the - /// unit converter. The desktop window and the WebAssembly page show the same view. + /// The math tools, a page each: the scientific keypad around the equation editor, the library of formulas, the + /// unit converter and the solver of triangles. The desktop window and the WebAssembly page show the same view. /// public sealed class MathToolsView : UserControl { @@ -21,6 +21,7 @@ public MathToolsView() Ui.AddBrushes(Resources); Calculator = new CalculatorPage(); Converter = new UnitsPage(); + TriangleSolver = new TrianglePage(); Tabs = new TabControl { Margin = new Thickness(20, 12, 20, 0) }; FormulaLibrary = new FormulasPage(equation => { @@ -33,6 +34,7 @@ public MathToolsView() Tab("Calculator", Calculator), Tab("Formulas", FormulaLibrary), Tab("Units", Converter), + Tab("Triangles", TriangleSolver), }; var root = new DockPanel { Children = { Ui.Docked(Header(), Dock.Top), Tabs } }; @@ -49,6 +51,9 @@ public MathToolsView() /// The page of the unit converter. public UnitsPage Converter { get; } + /// The page of the solver of triangles. + public TrianglePage TriangleSolver { get; } + /// The tabs of the pages. public TabControl Tabs { get; } diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/TrianglePage.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/TrianglePage.cs new file mode 100644 index 0000000..33df727 --- /dev/null +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/TrianglePage.cs @@ -0,0 +1,294 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using System; + using System.Collections.Generic; + using System.Linq; + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Controls.Shapes; + using global::Avalonia.Layout; + using global::Avalonia.Media; + using SharpEquations; + + /// + /// The solver of triangles: three of the sides and angles give the others, the area, the perimeter and the radii of + /// the circles, with the laws used, written in UnicodeMath and drawn by s, and the triangle + /// drawn to scale. + /// + public sealed class TrianglePage : UserControl + { + private const double DrawingWidth = 300; + private const double DrawingHeight = 210; + + private static readonly Color Accent = Color.FromRgb(0x0D, 0x94, 0x88); + + private static readonly (string Name, string[] Values)[] Examples = + { + ("3, 4, 5", new[] { "3", "4", "5", "", "", "" }), + ("SAS", new[] { "7", "", "9", "", "60", "" }), + ("ASA", new[] { "", "", "10", "45", "75", "" }), + ("Two triangles", new[] { "6", "8", "", "40", "", "" }), + ("No triangle", new[] { "1", "2", "4", "", "", "" }), + }; + + private static readonly string[] Empty = { "", "", "", "", "", "" }; + + private readonly StackPanel results = new StackPanel { Spacing = 18 }; + private readonly StackPanel steps = new StackPanel { Spacing = 10 }; + private bool updating; + + public TrianglePage() + { + Inputs = Triangles.SideNames.Concat(Triangles.AngleNames).Select(_ => new TextBox { Width = 92, PlaceholderText = "?" }).ToArray(); + foreach (var input in Inputs) + { + input.PropertyChanged += (_, e) => + { + if (e.Property == TextBox.TextProperty) + Solve(); + }; + } + + var clear = new Button { Content = "Clear", CornerRadius = new CornerRadius(8) }; + clear.Click += (_, _) => Fill(Empty); + var examples = new List { Ui.Muted("Examples", 12.5) }; + foreach (var (name, values) in Examples) + { + var button = new Button { Content = name, CornerRadius = new CornerRadius(8), Padding = new Thickness(10, 4) }; + button.Click += (_, _) => Fill(values); + examples.Add(button); + } + examples.Add(clear); + + var sides = Ui.Row(Enumerable.Range(0, 3).Select(i => Field(Triangles.SideNames[i], Inputs[i], "")).ToArray()); + var angles = Ui.Row(Enumerable.Range(0, 3).Select(i => Field(Triangles.AngleNames[i], Inputs[i + 3], "°")).ToArray()); + + Content = new StackPanel + { + Spacing = 20, + Children = + { + Ui.Card("Triangles", "Three of the sides and angles, at least one of them a side; each angle is opposite the side of its letter", new StackPanel + { + Spacing = 14, + Children = + { + new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + RowDefinitions = new RowDefinitions("Auto,Auto"), + ColumnSpacing = 14, + RowSpacing = 10, + Children = { Label("Sides", 0), At(sides, 0, 1), Label("Angles", 1), At(angles, 1, 1) }, + }, + Ui.Row(examples.ToArray()), + }, + }), + Ui.Card("Solution", "The values found, the area, the perimeter and the radii of the circles through the vertices and inside", results), + Ui.Card("How", "The laws that give the values found", steps), + }, + }; + Fill(Examples[0].Values); + } + + /// The fields of a, b, c, α, β and γ. + public IReadOnlyList Inputs { get; } + + /// The solution of the values in the fields. + public TriangleSolution? Solution { get; private set; } + + /// Fills the fields, as the examples do. + public void Fill(IReadOnlyList values) + { + updating = true; + for (var i = 0; i < Inputs.Count; i++) + Inputs[i].Text = values[i]; + updating = false; + Solve(); + } + + /// The values of a triangle as UnicodeMath: a=3, b=4, c=5 and α=36.8699°, β=53.1301°, γ=90°. + public static (string Sides, string Angles) Describe(Triangle triangle) => ( + string.Join(", ", Enumerable.Range(0, 3).Select(i => Triangles.SideNames[i] + "=" + Units.Format(triangle.Side(i)))), + string.Join(", ", Enumerable.Range(0, 3).Select(i => Triangles.AngleNames[i] + "=" + Units.Format(triangle.Angle(i)) + "°"))); + + private void Solve() + { + if (updating) + return; + + var values = new double?[Inputs.Count]; + for (var i = 0; i < Inputs.Count; i++) + { + var text = Inputs[i].Text; + if (string.IsNullOrWhiteSpace(text)) + continue; + if (!Units.TryParse(text!.TrimEnd('°', ' '), out var value)) + { + Show(new TriangleSolution(null, Array.Empty(), Array.Empty(), "“" + text + "” is not a number.")); + return; + } + values[i] = value; + } + Show(Triangles.Solve(values[0], values[1], values[2], values[3], values[4], values[5])); + } + + private void Show(TriangleSolution solution) + { + Solution = solution; + results.Children.Clear(); + steps.Children.Clear(); + + if (solution.Error != null) + results.Children.Add(Ui.Muted(solution.Error, 14)); + if (solution.Triangles.Count == 2) + results.Children.Add(Ui.Muted("The angle given is opposite the shorter side: two triangles have these values.", 13)); + foreach (var (triangle, index) in solution.Triangles.Select((t, i) => (t, i))) + results.Children.Add(Result(triangle, solution.Triangles.Count == 2 ? "Triangle " + (index + 1) : null)); + + if (solution.Case != null) + steps.Children.Add(Ui.Muted("Case " + solution.Case + " (" + CaseName(solution.Case) + ")", 13)); + foreach (var step in solution.Steps) + { + var math = Ui.Math(step, 19); + math.HorizontalAlignment = HorizontalAlignment.Left; + steps.Children.Add(math); + } + if (solution.Steps.Count == 0) + steps.Children.Add(Ui.Muted("Give three values, at least one of them a side.", 13)); + } + + private static Grid Result(Triangle triangle, string? title) + { + var (sides, angles) = Describe(triangle); + var values = new StackPanel { Spacing = 10, VerticalAlignment = VerticalAlignment.Center }; + if (title != null) + values.Children.Add(Ui.Small(title)); + values.Children.Add(Left(Ui.Math(sides, 21))); + values.Children.Add(Left(Ui.Math(angles, 21))); + values.Children.Add(Measures(triangle)); + + var drawing = Drawing(triangle); + Grid.SetColumn(values, 1); + return new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*"), ColumnSpacing = 24, Children = { drawing, values } }; + } + + private static Grid Measures(Triangle triangle) + { + var rows = new (string Name, string Value)[] + { + ("Area", Units.Format(triangle.Area)), + ("Perimeter", Units.Format(triangle.Perimeter)), + ("Circumradius", "R=" + Units.Format(triangle.Circumradius)), + ("Inradius", "r=" + Units.Format(triangle.Inradius)), + }; + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("130,*"), RowSpacing = 8, Margin = new Thickness(0, 6, 0, 0) }; + foreach (var (row, (name, value)) in rows.Select((r, i) => (i, r))) + { + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + var label = Ui.Muted(name, 13); + label.VerticalAlignment = VerticalAlignment.Center; + grid.Children.Add(At(label, row, 0)); + grid.Children.Add(At(Left(Ui.Math(value, 17)), row, 1)); + } + return grid; + } + + /// The triangle to scale: A at the left of the base c, B at its right, C above. + private static Border Drawing(Triangle triangle) + { + var alpha = Triangles.Radians(triangle.Alpha); + var points = new[] { (X: 0.0, Y: 0.0), (X: triangle.C, Y: 0.0), (X: triangle.B * Math.Cos(alpha), Y: triangle.B * Math.Sin(alpha)) }; + double minX = points.Min(p => p.X), maxX = points.Max(p => p.X), maxY = points.Max(p => p.Y); + const double margin = 26; + var scale = Math.Min((DrawingWidth - 2 * margin) / (maxX - minX), (DrawingHeight - 2 * margin) / maxY); + var offsetX = (DrawingWidth - (maxX - minX) * scale) / 2; + var offsetY = (DrawingHeight + maxY * scale) / 2; + var screen = points.Select(p => new Point(offsetX + (p.X - minX) * scale, offsetY - p.Y * scale)).ToArray(); + + var canvas = new Canvas { Width = DrawingWidth, Height = DrawingHeight }; + var shape = new Polygon + { + Points = screen, + StrokeThickness = 2, + StrokeJoin = PenLineJoin.Round, + Stroke = new SolidColorBrush(Accent), + Fill = new SolidColorBrush(Accent, 0.14), + }; + canvas.Children.Add(shape); + + var center = new Point(screen.Average(p => p.X), screen.Average(p => p.Y)); + for (var i = 0; i < 3; i++) + { + // the vertex, away from the center, and the side opposite it, at its middle, away from the center too + Label(canvas, ((char)('A' + i)).ToString(), Away(screen[i], center, 14), FontWeight.SemiBold); + var middle = (screen[(i + 1) % 3] + screen[(i + 2) % 3]) / 2; + Label(canvas, Triangles.SideNames[i], Away(middle, center, 12), FontWeight.Normal); + } + + var frame = new Border { CornerRadius = new CornerRadius(12), Child = canvas, VerticalAlignment = VerticalAlignment.Top }; + frame.Bind(Border.BackgroundProperty, frame.GetResourceObservable("CodeBrush")); + return frame; + } + + private static Point Away(Point point, Point center, double distance) + { + var direction = point - center; + var length = Math.Sqrt(direction.X * direction.X + direction.Y * direction.Y); + return length < 1e-9 ? point : point + direction * (distance / length); + } + + private static void Label(Canvas canvas, string text, Point at, FontWeight weight) + { + var label = new TextBlock { Text = text, FontStyle = FontStyle.Italic, FontSize = 15, FontWeight = weight }; + label.Measure(Size.Infinity); + Canvas.SetLeft(label, at.X - label.DesiredSize.Width / 2); + Canvas.SetTop(label, at.Y - label.DesiredSize.Height / 2); + canvas.Children.Add(label); + } + + private static string CaseName(string kind) => kind switch + { + "SSS" => "three sides", + "SAS" => "two sides and the angle between them", + "SSA" => "two sides and an angle not between them", + "ASA" => "two angles and the side between them", + _ => "two angles and a side not between them", + }; + + private static StackPanel Field(string name, TextBox input, string unit) + { + var label = Ui.Math(name, 19); + label.VerticalAlignment = VerticalAlignment.Center; + label.Width = 18; + var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { label, input } }; + if (unit.Length > 0) + panel.Children.Add(new TextBlock { Text = unit, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(-4, 0, 6, 0) }); + else + panel.Margin = new Thickness(0, 0, 12, 0); + return panel; + } + + private static TextBlock Label(string text, int row) + { + var label = Ui.Small(text); + label.VerticalAlignment = VerticalAlignment.Center; + return At(label, row, 0); + } + + private static T Left(T control) + where T : Control + { + control.HorizontalAlignment = HorizontalAlignment.Left; + return control; + } + + private static T At(T control, int row, int column) + where T : Control + { + Grid.SetRow(control, row); + Grid.SetColumn(control, column); + return control; + } + } +} diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/Triangles.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/Triangles.cs new file mode 100644 index 0000000..3bf6d60 --- /dev/null +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/Triangles.cs @@ -0,0 +1,206 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// A triangle: its sides a, b, c and the angles opposite them, in degrees. + public sealed record Triangle(double A, double B, double C, double Alpha, double Beta, double Gamma) + { + public double Perimeter => A + B + C; + + public double Area => 0.5 * B * C * Math.Sin(Triangles.Radians(Alpha)); + + /// The radius of the circle through the vertices. + public double Circumradius => A / (2 * Math.Sin(Triangles.Radians(Alpha))); + + /// The radius of the circle inside, tangent to the sides. + public double Inradius => 2 * Area / Perimeter; + + public double Side(int i) => i switch { 0 => A, 1 => B, _ => C }; + + public double Angle(int i) => i switch { 0 => Alpha, 1 => Beta, _ => Gamma }; + } + + /// + /// What three values of a triangle give: the case (SSS, SAS, SSA, ASA, AAS), the + /// triangles (none, one, or two in the ambiguous case SSA), the laws used to find them, as UnicodeMath, and + /// the reason when there is no triangle. + /// + public sealed record TriangleSolution(string? Case, IReadOnlyList Triangles, IReadOnlyList Steps, string? Error); + + /// The solver of triangles: three of the sides and angles, at least one of them a side, give the others. + public static class Triangles + { + private const double Epsilon = 1e-9; + + /// The names of the sides, as UnicodeMath. + public static readonly IReadOnlyList SideNames = new[] { "a", "b", "c" }; + + /// The names of the angles, each opposite the side of its index, as UnicodeMath. + public static readonly IReadOnlyList AngleNames = new[] { "α", "β", "γ" }; + + public static double Radians(double degrees) => degrees * Math.PI / 180; + + public static double Degrees(double radians) => radians * 180 / Math.PI; + + /// Solves the triangle of the values given (sides, and angles in degrees; null for the unknown ones). + public static TriangleSolution Solve(double? a, double? b, double? c, double? alpha, double? beta, double? gamma) + { + var sides = new[] { a, b, c }; + var angles = new[] { alpha, beta, gamma }; + var known = sides.Concat(angles).Count(v => v.HasValue); + if (sides.Any(s => s is <= 0) || angles.Any(t => t is <= 0)) + return Fail("Sides and angles are greater than zero."); + if (angles.Any(t => t >= 180)) + return Fail("An angle of a triangle is less than 180°."); + if (known != 3) + return Fail(known < 3 ? "Give three values: " + (3 - known) + " more." : "Give three values, not " + known + ": clear the others."); + + switch (sides.Count(s => s.HasValue)) + { + case 0: + return Fail("Three angles give the shape of a triangle but not its size: give a side."); + case 3: + return SideSideSide(sides.Select(s => s!.Value).ToArray()); + case 2: + var unknown = Array.FindIndex(sides, s => !s.HasValue); + return angles[unknown].HasValue + ? SideAngleSide(sides, angles[unknown]!.Value, unknown) + : SideSideAngle(sides, angles); + default: + return AngleSide(sides, angles); + } + } + + private static TriangleSolution SideSideSide(double[] s) + { + for (var i = 0; i < 3; i++) + { + if (s[i] >= s[(i + 1) % 3] + s[(i + 2) % 3] - Epsilon * s[i]) + return Fail("No triangle: " + SideNames[i] + " is not shorter than the other two sides together.", "SSS"); + } + var steps = new List(); + for (var i = 0; i < 2; i++) + steps.Add(LawOfCosinesForAngle(i)); + steps.Add(AngleSum(2)); + return One("SSS", FromSides(s), steps); + } + + private static TriangleSolution SideAngleSide(double?[] sides, double angle, int k) + { + int i = (k + 1) % 3, j = (k + 2) % 3; + var s = new double[3]; + s[i] = sides[i]!.Value; + s[j] = sides[j]!.Value; + s[k] = Math.Sqrt(s[i] * s[i] + s[j] * s[j] - 2 * s[i] * s[j] * Math.Cos(Radians(angle))); + var (x, y) = Ordered(i, j); + var steps = new List + { + SideNames[k] + "=√(" + SideNames[x] + "^2+" + SideNames[y] + "^2-2" + SideNames[x] + SideNames[y] + " cos " + AngleNames[k] + ")", + LawOfCosinesForAngle(i), + AngleSum(j), + }; + return One("SAS", FromSides(s), steps); + } + + private static TriangleSolution SideSideAngle(double?[] sides, double?[] angles) + { + var m = Array.FindIndex(angles, t => t.HasValue); + var n = sides[(m + 1) % 3].HasValue ? (m + 1) % 3 : (m + 2) % 3; + var k = 3 - m - n; + double sm = sides[m]!.Value, sn = sides[n]!.Value, tm = angles[m]!.Value; + var sine = sn * Math.Sin(Radians(tm)) / sm; + var steps = new List + { + "sin " + AngleNames[n] + "=(" + SideNames[n] + " sin " + AngleNames[m] + ")/" + SideNames[m], + AngleSum(k), + LawOfSines(k, m), + }; + if (sine > 1 + Epsilon) + return Fail("No triangle: " + SideNames[n] + " sin " + AngleNames[m] + " is greater than " + SideNames[m] + ", so " + SideNames[m] + " cannot reach the other side.", "SSA", steps); + + // a sine of 1 within rounding is the right angle, where the two triangles are one (asin is steep there) + var right = sine >= 1 - Epsilon; + var first = right ? 90 : Degrees(Math.Asin(sine)); + var triangles = new List(); + foreach (var tn in right ? new[] { first } : new[] { first, 180 - first }) + { + var tk = 180 - tm - tn; + if (tk <= Epsilon) + continue; + var s = new double[3]; + var t = new double[3]; + s[m] = sm; + s[n] = sn; + t[m] = tm; + t[n] = tn; + t[k] = tk; + s[k] = sm * Math.Sin(Radians(tk)) / Math.Sin(Radians(tm)); + triangles.Add(new Triangle(s[0], s[1], s[2], t[0], t[1], t[2])); + } + if (triangles.Count == 0) + return Fail("No triangle: the angles " + AngleNames[m] + " and " + AngleNames[n] + " make 180° or more.", "SSA", steps); + if (triangles.Count == 2) + steps.Insert(1, AngleNames[n] + "'=180°-" + AngleNames[n]); + return new TriangleSolution("SSA", triangles, steps, null); + } + + private static TriangleSolution AngleSide(double?[] sides, double?[] angles) + { + var i = Array.FindIndex(sides, s => s.HasValue); + var k = Array.FindIndex(angles, t => !t.HasValue); + var t = angles.Select(v => v ?? 0).ToArray(); + t[k] = 180 - t.Sum(); + if (t[k] <= Epsilon) + return Fail("No triangle: the two angles make 180° or more.", k == i ? "ASA" : "AAS"); + var s = new double[3]; + s[i] = sides[i]!.Value; + var steps = new List { AngleSum(k) }; + foreach (var j in new[] { (i + 1) % 3, (i + 2) % 3 }) + { + s[j] = s[i] * Math.Sin(Radians(t[j])) / Math.Sin(Radians(t[i])); + steps.Add(LawOfSines(j, i)); + } + return One(k == i ? "ASA" : "AAS", new Triangle(s[0], s[1], s[2], t[0], t[1], t[2]), steps); + } + + private static Triangle FromSides(double[] s) + { + var t = new double[3]; + for (var i = 0; i < 2; i++) + { + double x = s[(i + 1) % 3], y = s[(i + 2) % 3]; + t[i] = Degrees(Math.Acos(Math.Max(-1, Math.Min(1, (x * x + y * y - s[i] * s[i]) / (2 * x * y))))); + } + t[2] = 180 - t[0] - t[1]; + return new Triangle(s[0], s[1], s[2], t[0], t[1], t[2]); + } + + // cos α=(b^2+c^2-a^2)/2bc + private static string LawOfCosinesForAngle(int i) + { + var (x, y) = Ordered((i + 1) % 3, (i + 2) % 3); + return "cos " + AngleNames[i] + "=(" + SideNames[x] + "^2+" + SideNames[y] + "^2-" + SideNames[i] + "^2)/(2" + SideNames[x] + SideNames[y] + ")"; + } + + // b=(a sin β)/(sin α) + private static string LawOfSines(int j, int i) => + SideNames[j] + "=(" + SideNames[i] + " sin " + AngleNames[j] + ")/(sin " + AngleNames[i] + ")"; + + // γ=180°-α-β + private static string AngleSum(int k) + { + var (x, y) = Ordered((k + 1) % 3, (k + 2) % 3); + return AngleNames[k] + "=180°-" + AngleNames[x] + "-" + AngleNames[y]; + } + + private static (int, int) Ordered(int i, int j) => i < j ? (i, j) : (j, i); + + private static TriangleSolution One(string kind, Triangle triangle, IReadOnlyList steps) => + new TriangleSolution(kind, new[] { triangle }, steps, null); + + private static TriangleSolution Fail(string error, string? kind = null, IReadOnlyList? steps = null) => + new TriangleSolution(kind, Array.Empty(), steps ?? Array.Empty(), error); + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs b/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs index 095fe49..4d05ea3 100644 --- a/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs +++ b/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs @@ -102,6 +102,34 @@ public async Task UnitsConvert() await Assert.That(celsius).IsEqualTo("98.6 \"\u00A0°F\"=37 \"\u00A0°C\""); await Assert.That(invalid).IsNull(); } + + [Test] + public async Task TrianglesSolve() + { + var (right, two, error) = await Headless.Run(() => + { + var window = new MainWindow(); + window.Show(); + var view = window.GetVisualDescendants().OfType().Single(); + view.Tabs.SelectedIndex = 3; + var page = view.TriangleSolver; + window.UpdateLayout(); + var first = page.Solution; + page.Fill(new[] { "6", "8", "", "40", "", "" }); + window.UpdateLayout(); + Save(window, "math-tools-triangles"); + var second = page.Solution; + page.Inputs[2].Text = "x"; + var third = page.Solution; + window.Close(); + return (first, second, third); + }); + await Assert.That(right!.Case).IsEqualTo("SSS"); + await Assert.That(right.Triangles.Single().Gamma).IsEqualTo(90).Within(1e-9); + await Assert.That(two!.Case).IsEqualTo("SSA"); + await Assert.That(two.Triangles.Count).IsEqualTo(2); + await Assert.That(error!.Error).IsEqualTo("“x” is not a number."); + } } } #endif diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/TrianglesTests.cs b/tests/SharpEquations.Avalonia.Tests/MathTools/TrianglesTests.cs new file mode 100644 index 0000000..6fe287d --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/MathTools/TrianglesTests.cs @@ -0,0 +1,154 @@ +#if NET10_0_OR_GREATER +namespace SharpEquations.Avalonia.Tests.MathTools +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using SharpEquations; + using SharpEquations.Avalonia.MathTools; + + /// The cases of the solver of triangles, and the laws it writes as UnicodeMath. + public class TrianglesTests + { + private const double Tolerance = 1e-4; + + [Test] + public async Task ThreeSides() + { + var solution = Triangles.Solve(3, 4, 5, null, null, null); + await Assert.That(solution.Case).IsEqualTo("SSS"); + var t = solution.Triangles.Single(); + await Assert.That(t.Alpha).IsEqualTo(36.8699).Within(Tolerance); + await Assert.That(t.Beta).IsEqualTo(53.1301).Within(Tolerance); + await Assert.That(t.Gamma).IsEqualTo(90).Within(Tolerance); + await Assert.That(t.Area).IsEqualTo(6).Within(Tolerance); + await Assert.That(t.Perimeter).IsEqualTo(12).Within(Tolerance); + await Assert.That(t.Circumradius).IsEqualTo(2.5).Within(Tolerance); + await Assert.That(t.Inradius).IsEqualTo(1).Within(Tolerance); + } + + [Test] + public async Task TwoSidesAndTheAngleBetween() + { + var solution = Triangles.Solve(7, null, 9, null, 60, null); + await Assert.That(solution.Case).IsEqualTo("SAS"); + var t = solution.Triangles.Single(); + await Assert.That(t.B).IsEqualTo(Math.Sqrt(67)).Within(Tolerance); + await Assert.That(t.Beta).IsEqualTo(60).Within(Tolerance); + await Assert.That(t.Alpha + t.Beta + t.Gamma).IsEqualTo(180).Within(Tolerance); + } + + [Test] + public async Task TwoAnglesAndASide() + { + var asa = Triangles.Solve(null, null, 10, 45, 75, null); + await Assert.That(asa.Case).IsEqualTo("ASA"); + var t = asa.Triangles.Single(); + await Assert.That(t.Gamma).IsEqualTo(60).Within(Tolerance); + await Assert.That(t.A).IsEqualTo(8.16497).Within(Tolerance); + await Assert.That(t.B).IsEqualTo(11.15355).Within(Tolerance); + + var aas = Triangles.Solve(5, null, null, 30, 60, null); + await Assert.That(aas.Case).IsEqualTo("AAS"); + t = aas.Triangles.Single(); + await Assert.That(t.B).IsEqualTo(5 * Math.Sqrt(3)).Within(Tolerance); + await Assert.That(t.C).IsEqualTo(10).Within(Tolerance); + } + + /// Two sides and an angle opposite one of them: two triangles, one, or none. + [Test] + [Arguments(6, 8, 40, 2)] + [Arguments(8, 6, 40, 1)] + [Arguments(4, 8, 30, 1)] + [Arguments(3, 8, 30, 0)] + public async Task TheAmbiguousCase(double a, double b, double alpha, int count) + { + var solution = Triangles.Solve(a, b, null, alpha, null, null); + await Assert.That(solution.Case).IsEqualTo("SSA"); + await Assert.That(solution.Triangles.Count).IsEqualTo(count); + await Assert.That(solution.Error is null).IsEqualTo(count > 0); + foreach (var t in solution.Triangles) + { + await Assert.That(t.A).IsEqualTo(a); + await Assert.That(t.Alpha).IsEqualTo(alpha); + await Assert.That(t.Alpha + t.Beta + t.Gamma).IsEqualTo(180).Within(Tolerance); + await Assert.That(t.A / Math.Sin(Triangles.Radians(t.Alpha))).IsEqualTo(t.C / Math.Sin(Triangles.Radians(t.Gamma))).Within(Tolerance); + } + if (count == 2) + await Assert.That(solution.Triangles[0].Beta + solution.Triangles[1].Beta).IsEqualTo(180).Within(Tolerance); + } + + [Test] + [Arguments(1d, 2d, 4d, null, null, null)] + [Arguments(1d, 2d, 3d, null, null, null)] + [Arguments(null, null, null, 60d, 60d, 60d)] + [Arguments(1d, null, null, 100d, 80d, null)] + [Arguments(1d, 2d, null, null, null, null)] + [Arguments(1d, 2d, 2d, 60d, null, null)] + [Arguments(1d, 2d, null, 180d, null, null)] + [Arguments(-1d, 2d, 2d, null, null, null)] + public async Task NoTriangle(double? a, double? b, double? c, double? alpha, double? beta, double? gamma) + { + var solution = Triangles.Solve(a, b, c, alpha, beta, gamma); + await Assert.That(solution.Triangles).IsEmpty(); + await Assert.That(solution.Error).IsNotNull(); + } + + /// Every three values of a triangle, at least one a side, give it back (among two, in the ambiguous case). + [Test] + [Arguments(5, 7, 9)] + [Arguments(3, 4, 5)] + [Arguments(10, 10, 1)] + public async Task AnyThreeValuesGiveTheTriangle(double a, double b, double c) + { + var triangle = Triangles.Solve(a, b, c, null, null, null).Triangles.Single(); + var all = new[] { triangle.A, triangle.B, triangle.C, triangle.Alpha, triangle.Beta, triangle.Gamma }; + var cases = new HashSet(); + for (var mask = 0; mask < 64; mask++) + { + if (System.Numerics.BitOperations.PopCount((uint)mask) != 3 || (mask & 7) == 0) + continue; + var v = Enumerable.Range(0, 6).Select(i => (mask >> i & 1) != 0 ? all[i] : (double?)null).ToArray(); + var solution = Triangles.Solve(v[0], v[1], v[2], v[3], v[4], v[5]); + cases.Add(solution.Case!); + var found = solution.Triangles.Any(t => Enumerable.Range(0, 3).All(i => + Math.Abs(t.Side(i) - triangle.Side(i)) < Tolerance && Math.Abs(t.Angle(i) - triangle.Angle(i)) < Tolerance)); + await Assert.That(found).IsTrue().Because("values " + mask); + } + await Assert.That(cases).IsEquivalentTo(new[] { "SSS", "SAS", "SSA", "ASA", "AAS" }); + } + + /// The laws and the values written read as equations, without errors. + [Test] + public async Task StepsAndValuesParse() + { + var texts = new List(); + foreach (var solution in new[] + { + Triangles.Solve(3, 4, 5, null, null, null), + Triangles.Solve(7, null, 9, null, 60, null), + Triangles.Solve(6, 8, null, 40, null, null), + Triangles.Solve(null, null, 10, 45, 75, null), + Triangles.Solve(5, null, null, 30, 60, null), + }) + { + texts.AddRange(solution.Steps); + foreach (var t in solution.Triangles) + { + var (sides, angles) = TrianglePage.Describe(t); + texts.Add(sides); + texts.Add(angles); + } + } + foreach (var text in texts) + { + var equation = Equation.Parse(text, EquationFormat.UnicodeMath); + await Assert.That(equation.ToMathML().Descendants().Any(e => e.Name.LocalName == "merror")).IsFalse().Because(text); + await Assert.That(Equation.Parse(equation.ToString(EquationFormat.UnicodeMath), EquationFormat.UnicodeMath)).IsEqualTo(equation).Because(text); + } + await Assert.That(TrianglePage.Describe(Triangles.Solve(3, 4, 5, null, null, null).Triangles[0])).IsEqualTo(("a=3, b=4, c=5", "α=36.8699°, β=53.1301°, γ=90°")); + } + } +} +#endif