From 96d6d7fdf0a75f7bb9be5bf43d538632cd3e46b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 18:24:39 +0000 Subject: [PATCH] [no-new-version] Math tools: a solver of triangles Three of the sides and angles, at least one of them a side, give the others (SSS, SAS, SSA with its two triangles, one or none, ASA, AAS), the area, the perimeter and the radii of the circles, with the laws used written in UnicodeMath and the triangle drawn to scale. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyVio8afJsGwHW9i5v5HDs --- AGENTS.md | 2 +- README.md | 2 +- .../MathToolsView.cs | 9 +- .../TrianglePage.cs | 294 ++++++++++++++++++ .../Triangles.cs | 206 ++++++++++++ .../MathTools/MathToolsWindowTests.cs | 28 ++ .../MathTools/TrianglesTests.cs | 154 +++++++++ 7 files changed, 691 insertions(+), 4 deletions(-) create mode 100644 samples/SharpEquations.Avalonia.MathTools.Demo/TrianglePage.cs create mode 100644 samples/SharpEquations.Avalonia.MathTools.Demo/Triangles.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/MathTools/TrianglesTests.cs 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