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