diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 97ff85c..722cb16 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -39,7 +39,7 @@ jobs: - name: Test timeout-minutes: 30 - run: dotnet test -c Release --no-restore --logger "console;verbosity=detailed" --blame-hang --blame-hang-timeout 2m --blame-hang-dump-type full + run: dotnet test -c Release --no-build --no-restore --logger "console;verbosity=detailed" --blame-hang --blame-hang-timeout 2m --blame-hang-dump-type full build-test-crossplatform: timeout-minutes: 60 @@ -74,4 +74,4 @@ jobs: - name: Test timeout-minutes: 30 - run: dotnet test -c Release --no-restore -f net10.0 --logger "console;verbosity=detailed" --blame-hang --blame-hang-timeout 2m --blame-hang-dump-type full + run: dotnet test -c Release --no-build --no-restore -f net10.0 --logger "console;verbosity=detailed" --blame-hang --blame-hang-timeout 2m --blame-hang-dump-type full diff --git a/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs b/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs new file mode 100644 index 0000000..4b277f9 --- /dev/null +++ b/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace LTRData.FunctionPlotting; + +#pragma warning disable CS1591 + +public struct CanvasSize : IEquatable +{ + public CanvasSize(double width, double height) + { + if (!NumericRange.IsFinite(width) || width <= 0d) + { + throw new ArgumentOutOfRangeException(nameof(width), + "Canvas width must be finite and positive."); + } + + if (!NumericRange.IsFinite(height) || height <= 0d) + { + throw new ArgumentOutOfRangeException(nameof(height), + "Canvas height must be finite and positive."); + } + + Width = width; + Height = height; + } + + public double Width { get; } + + public double Height { get; } + + internal bool IsValid => NumericRange.IsFinite(Width) && Width > 0d && + NumericRange.IsFinite(Height) && Height > 0d; + + public bool Equals(CanvasSize other) => + Width.Equals(other.Width) && Height.Equals(other.Height); + + public override bool Equals(object? obj) => obj is CanvasSize other && Equals(other); + + public override int GetHashCode() => + unchecked((Width.GetHashCode() * 397) ^ Height.GetHashCode()); +} + +public sealed class PlotViewport +{ + public PlotViewport(NumericRange xRange, NumericRange yRange, CanvasSize canvas) + { + if (!xRange.IsValid) + { + throw new ArgumentException("A valid X range is required.", nameof(xRange)); + } + + if (!yRange.IsValid) + { + throw new ArgumentException("A valid Y range is required.", nameof(yRange)); + } + + if (!canvas.IsValid) + { + throw new ArgumentException("A valid canvas size is required.", nameof(canvas)); + } + + XRange = xRange; + YRange = yRange; + Canvas = canvas; + } + + public NumericRange XRange { get; } + + public NumericRange YRange { get; } + + public CanvasSize Canvas { get; } +} + +public struct CanvasPoint : IEquatable +{ + public CanvasPoint(double x, double y) + { + X = x; + Y = y; + } + + public double X { get; } + + public double Y { get; } + + public bool Equals(CanvasPoint other) => X.Equals(other.X) && Y.Equals(other.Y); + + public override bool Equals(object? obj) => obj is CanvasPoint other && Equals(other); + + public override int GetHashCode() => + unchecked((X.GetHashCode() * 397) ^ Y.GetHashCode()); +} + +public sealed class Polyline +{ + internal Polyline(IEnumerable points) + { + Points = new List(points).AsReadOnly(); + } + + public ReadOnlyCollection Points { get; } +} + +public sealed class CurveGeometry +{ + internal CurveGeometry(IEnumerable polylines) + { + Polylines = new List(polylines).AsReadOnly(); + } + + public ReadOnlyCollection Polylines { get; } +} + +public static class CartesianTransform +{ + public static CanvasPoint ToCanvas(double x, double y, PlotViewport viewport) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(viewport); +#else + if (viewport is null) + { + throw new ArgumentNullException(nameof(viewport)); + } +#endif + + return new CanvasPoint( + (x - viewport.XRange.Minimum) / viewport.XRange.Length * + viewport.Canvas.Width, + viewport.Canvas.Height - + (y - viewport.YRange.Minimum) / viewport.YRange.Length * + viewport.Canvas.Height); + } + + public static CanvasPoint ToData(double canvasX, double canvasY, + PlotViewport viewport) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(viewport); +#else + if (viewport is null) + { + throw new ArgumentNullException(nameof(viewport)); + } +#endif + + return new CanvasPoint( + canvasX / viewport.Canvas.Width * viewport.XRange.Length + + viewport.XRange.Minimum, + (viewport.Canvas.Height - canvasY) / viewport.Canvas.Height * + viewport.YRange.Length + viewport.YRange.Minimum); + } +} + +public static class CurveGeometryBuilder +{ + public static CurveGeometry Build(SampleSeries series, PlotViewport viewport) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(series); +#else + if (series is null) + { + throw new ArgumentNullException(nameof(series)); + } +#endif + +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(viewport); +#else + if (viewport is null) + { + throw new ArgumentNullException(nameof(viewport)); + } +#endif + + var pointLists = new List>(); + List? current = null; + + for (var index = 1; index < series.Count; index++) + { + var first = series[index - 1]; + var second = series[index]; + + if (first.Status != FunctionSampleStatus.Finite || + second.Status != FunctionSampleStatus.Finite) + { + current = null; + continue; + } + + var x0 = first.X; + var y0 = first.Y; + var x1 = second.X; + var y1 = second.Y; + + if (!TryClip(viewport.XRange, viewport.YRange, + ref x0, ref y0, ref x1, ref y1)) + { + current = null; + continue; + } + + var start = CartesianTransform.ToCanvas(x0, y0, viewport); + var end = CartesianTransform.ToCanvas(x1, y1, viewport); + + if (current is null || !NearlyEqual(current[current.Count - 1], start)) + { + current = new List { start }; + pointLists.Add(current); + } + + if (!NearlyEqual(current[current.Count - 1], end)) + { + current.Add(end); + } + } + + var polylines = new List(pointLists.Count); + + foreach (var points in pointLists) + { + if (points.Count >= 2) + { + polylines.Add(new Polyline(points)); + } + } + + return new CurveGeometry(polylines); + } + + private static bool TryClip(NumericRange xRange, NumericRange yRange, + ref double x0, ref double y0, ref double x1, ref double y1) + { + if (!NumericRange.IsFinite(x0) || !NumericRange.IsFinite(y0) || + !NumericRange.IsFinite(x1) || !NumericRange.IsFinite(y1)) return false; + + // Cohen-Sutherland clipping sets the intersected boundary coordinate exactly. + // This matters when the visible part is tiny relative to finite endpoint values: + // reconstructing both coordinates from a rounded interpolation fraction can + // collapse the segment, and subtracting opposite large values can overflow. + for (var attempt = 0; attempt < 8; attempt++) + { + var first = OutCode(x0, y0, xRange, yRange); + var second = OutCode(x1, y1, xRange, yRange); + if ((first | second) == 0) return true; + if ((first & second) != 0) return false; + var outside = first != 0 ? first : second; + double x, y; + if ((outside & 12) != 0) + { + y = (outside & 8) != 0 ? yRange.Maximum : yRange.Minimum; + x = Interpolate(x0, x1, Fraction(y, y0, y1)); + } + else + { + x = (outside & 2) != 0 ? xRange.Maximum : xRange.Minimum; + y = Interpolate(y0, y1, Fraction(x, x0, x1)); + } + if (outside == first) { x0 = x; y0 = y; } + else { x1 = x; y1 = y; } + } + return false; + } + + private static int OutCode(double x, double y, NumericRange xRange, NumericRange yRange) => + (x < xRange.Minimum ? 1 : x > xRange.Maximum ? 2 : 0) | + (y < yRange.Minimum ? 4 : y > yRange.Maximum ? 8 : 0); + + private static double Fraction(double value, double start, double end) + { + var delta = end - start; + var distance = value - start; + double result; + if (NumericRange.IsFinite(delta) && NumericRange.IsFinite(distance)) result = distance / delta; + else + { + var scale = Math.Max(Math.Abs(start), Math.Abs(end)); + result = (value / scale - start / scale) / (end / scale - start / scale); + } + return Math.Max(0, Math.Min(1, result)); + } + + private static double Interpolate(double start, double end, double fraction) + { + var delta = end - start; + return NumericRange.IsFinite(delta) + ? start + fraction * delta + : (1 - fraction) * start + fraction * end; + } + + private static bool NearlyEqual(CanvasPoint left, CanvasPoint right) + { + const double tolerance = 1e-10; + + return Math.Abs(left.X - right.X) <= tolerance && + Math.Abs(left.Y - right.Y) <= tolerance; + } +} diff --git a/LTRData.FunctionPlotting/FunctionPlotting/NumericRange.cs b/LTRData.FunctionPlotting/FunctionPlotting/NumericRange.cs new file mode 100644 index 0000000..2d0da0a --- /dev/null +++ b/LTRData.FunctionPlotting/FunctionPlotting/NumericRange.cs @@ -0,0 +1,59 @@ +using System; + +namespace LTRData.FunctionPlotting; + +#pragma warning disable CS1591 + +public struct NumericRange : IEquatable +{ + public NumericRange(double minimum, double maximum) + { + if (!IsFinite(minimum)) + { + throw new ArgumentOutOfRangeException(nameof(minimum), + "Range minimum must be finite."); + } + + if (!IsFinite(maximum)) + { + throw new ArgumentOutOfRangeException(nameof(maximum), + "Range maximum must be finite."); + } + + if (maximum <= minimum) + { + throw new ArgumentOutOfRangeException(nameof(maximum), + "Range maximum must be greater than its minimum."); + } + + Minimum = minimum; + Maximum = maximum; + } + + public double Minimum { get; } + + public double Maximum { get; } + + public double Length => Maximum - Minimum; + + internal bool IsValid => IsFinite(Minimum) && IsFinite(Maximum) && Maximum > Minimum; + + public bool Contains(double value) => value >= Minimum && value <= Maximum; + + public bool Equals(NumericRange other) => + Minimum.Equals(other.Minimum) && Maximum.Equals(other.Maximum); + + public override bool Equals(object? obj) => obj is NumericRange other && Equals(other); + + public override int GetHashCode() => + unchecked((Minimum.GetHashCode() * 397) ^ Maximum.GetHashCode()); + + public override string ToString() => $"{Minimum}..{Maximum}"; + + public static bool operator ==(NumericRange left, NumericRange right) => left.Equals(right); + + public static bool operator !=(NumericRange left, NumericRange right) => !left.Equals(right); + + internal static bool IsFinite(double value) => + !double.IsNaN(value) && !double.IsInfinity(value); +} diff --git a/LTRData.FunctionPlotting/FunctionPlotting/SampleCalculus.cs b/LTRData.FunctionPlotting/FunctionPlotting/SampleCalculus.cs new file mode 100644 index 0000000..2559821 --- /dev/null +++ b/LTRData.FunctionPlotting/FunctionPlotting/SampleCalculus.cs @@ -0,0 +1,146 @@ +using System; + +namespace LTRData.FunctionPlotting; + +/// Numerical calculus on ordered samples in data coordinates. +public static class SampleCalculus +{ + /// + /// Differentiates each finite run using three-point differences, including + /// one-sided endpoint differences. Two-point runs use their secant slope; + /// isolated points have no derivative. Unequal X spacing is supported. + /// + /// Invalid samples split runs; no difference crosses a gap. + public static SampleSeries Differentiate(SampleSeries series) + { + Validate(series); + var result = new FunctionSample[series.Count]; + var start = 0; + + while (start < series.Count) + { + if (!IsFinite(series[start])) + { + result[start] = Gap(series[start]); + start++; + continue; + } + + var end = start + 1; + while (end < series.Count && IsFinite(series[end])) end++; + + for (var index = start; index < end; index++) + { + double derivative; + if (end - start == 1) derivative = double.NaN; + else if (end - start == 2) derivative = Slope(series[start], series[end - 1]); + else + { + var middle = Math.Max(start + 1, Math.Min(index, end - 2)); + var left = series[middle - 1]; + var center = series[middle]; + var right = series[middle + 1]; + var leftStep = center.X - left.X; + var rightStep = right.X - center.X; + var scale = Math.Max(leftStep, rightStep); + var leftWeight = (leftStep / scale) / (leftStep / scale + rightStep / scale); + var rightWeight = 1d - leftWeight; + var leftSlope = Slope(left, center); + var rightSlope = Slope(center, right); + + if (index == start) + derivative = leftSlope + leftWeight * (leftSlope - rightSlope); + else if (index == end - 1) + derivative = rightSlope + rightWeight * (rightSlope - leftSlope); + else + derivative = rightWeight * leftSlope + leftWeight * rightSlope; + } + + result[index] = Value(series[index].X, derivative); + } + start = end; + } + + return new SampleSeries(result); + } + + /// + /// Computes cumulative trapezoidal integrals. Each finite run starts at + /// at its leftmost sample, including after a gap. + /// + /// + /// No area is inferred across missing samples. Values are independent of the + /// viewport and never wrap for display. Overflow remains non-finite until a + /// new finite run begins. This does not evaluate improper integrals. + /// + public static SampleSeries IntegrateFiniteRuns(SampleSeries series, double initialValue = 0d) + { + Validate(series); + if (!NumericRange.IsFinite(initialValue)) + throw new ArgumentOutOfRangeException(nameof(initialValue), "The initial value must be finite."); + + var result = new FunctionSample[series.Count]; + var inRun = false; + var integral = initialValue; + + for (var index = 0; index < series.Count; index++) + { + var sample = series[index]; + if (!IsFinite(sample)) + { + result[index] = Gap(sample); + inRun = false; + continue; + } + + if (!inRun) integral = initialValue; + else + { + var previous = series[index - 1]; + // Halving first avoids overflowing a finite endpoint average. + var average = previous.Y * 0.5d + sample.Y * 0.5d; + integral += average * (sample.X - previous.X); + } + + result[index] = Value(sample.X, integral); + inRun = true; + } + + return new SampleSeries(result); + } + + private static double Slope(FunctionSample first, FunctionSample second) + { + var difference = second.Y - first.Y; + var step = second.X - first.X; + return NumericRange.IsFinite(difference) + ? difference / step + : (second.Y * 0.5d - first.Y * 0.5d) / (step * 0.5d); + } + + private static bool IsFinite(FunctionSample sample) => + sample.Status == FunctionSampleStatus.Finite && NumericRange.IsFinite(sample.Y); + + private static FunctionSample Gap(FunctionSample sample) => new(sample.X, double.NaN, + sample.Status == FunctionSampleStatus.EvaluationError + ? FunctionSampleStatus.EvaluationError : FunctionSampleStatus.NotANumber); + + private static FunctionSample Value(double x, double y) => + new(x, y, FunctionSampler.Classify(y)); + + private static void Validate(SampleSeries series) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(series); +#else + if (series is null) throw new ArgumentNullException(nameof(series)); +#endif + for (var index = 0; index < series.Count; index++) + { + var x = series[index].X; + if (!NumericRange.IsFinite(x) || (index > 0 && + (x <= series[index - 1].X || !NumericRange.IsFinite(x - series[index - 1].X)))) + throw new ArgumentException("Sample X coordinates must be finite and strictly increasing with finite spacing.", nameof(series)); + } + } +} diff --git a/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs b/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs new file mode 100644 index 0000000..6ea6082 --- /dev/null +++ b/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace LTRData.FunctionPlotting; + +#pragma warning disable CS1591 + +public delegate double ScalarFunction(double x); + +public enum FunctionSampleStatus +{ + Finite, + NotANumber, + PositiveInfinity, + NegativeInfinity, + EvaluationError +} + +public struct FunctionSample : IEquatable +{ + public FunctionSample(double x, double y, FunctionSampleStatus status) + { + X = x; + Y = y; + Status = status; + } + + public double X { get; } + + public double Y { get; } + + public FunctionSampleStatus Status { get; } + + public bool Equals(FunctionSample other) => + X.Equals(other.X) && Y.Equals(other.Y) && Status == other.Status; + + public override bool Equals(object? obj) => obj is FunctionSample other && Equals(other); + + public override int GetHashCode() => + unchecked((((X.GetHashCode() * 397) ^ Y.GetHashCode()) * 397) ^ + (int)Status); + + public static bool operator ==(FunctionSample left, FunctionSample right) => + left.Equals(right); + + public static bool operator !=(FunctionSample left, FunctionSample right) => + !left.Equals(right); +} + +public sealed class SampleSeries +{ + private readonly ReadOnlyCollection samples; + + public SampleSeries(IEnumerable samples) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(samples); +#else + if (samples is null) + { + throw new ArgumentNullException(nameof(samples)); + } +#endif + + this.samples = new List(samples).AsReadOnly(); + } + + internal SampleSeries(FunctionSample[] samples) + { + this.samples = Array.AsReadOnly(samples); + } + + public ReadOnlyCollection Samples => samples; + + public int Count => samples.Count; + + public FunctionSample this[int index] => samples[index]; +} + +public static class FunctionSampler +{ + public static SampleSeries Sample(ScalarFunction function, NumericRange domain, + int sampleCount) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(function); +#else + if (function is null) + { + throw new ArgumentNullException(nameof(function)); + } +#endif + + if (!domain.IsValid) + { + throw new ArgumentException("A valid sampling domain is required.", + nameof(domain)); + } + + if (sampleCount < 2) + { + throw new ArgumentOutOfRangeException(nameof(sampleCount), + "At least two samples are required."); + } + + var samples = new FunctionSample[sampleCount]; + var step = domain.Length / (sampleCount - 1); + + for (var index = 0; index < samples.Length; index++) + { + var x = index == samples.Length - 1 + ? domain.Maximum + : domain.Minimum + step * index; + + double y; + FunctionSampleStatus status; + + try + { + y = function(x); + status = Classify(y); + } + catch (Exception exception) + { + if (IsFatal(exception)) + { + throw; + } + + y = double.NaN; + status = FunctionSampleStatus.EvaluationError; + } + + samples[index] = new FunctionSample(x, y, status); + } + + return new SampleSeries(samples); + } + + internal static FunctionSampleStatus Classify(double value) + { + if (double.IsNaN(value)) + { + return FunctionSampleStatus.NotANumber; + } + + if (double.IsPositiveInfinity(value)) + { + return FunctionSampleStatus.PositiveInfinity; + } + + if (double.IsNegativeInfinity(value)) + { + return FunctionSampleStatus.NegativeInfinity; + } + + return FunctionSampleStatus.Finite; + } + + private static bool IsFatal(Exception exception) => + exception is OutOfMemoryException || + exception is StackOverflowException || + exception is AccessViolationException || + exception is AppDomainUnloadedException || + exception is BadImageFormatException || + exception is CannotUnloadAppDomainException || + exception is InvalidProgramException; +} diff --git a/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj b/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj new file mode 100644 index 0000000..1fcbee6 --- /dev/null +++ b/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj @@ -0,0 +1,9 @@ + + + + 1.2.0 + 1.2.0 + $(Version) + + + diff --git a/LTRData.Graphics.SkiaSharp/LTRData.Graphics.SkiaSharp.csproj b/LTRData.Graphics.SkiaSharp/LTRData.Graphics.SkiaSharp.csproj new file mode 100644 index 0000000..42011b0 --- /dev/null +++ b/LTRData.Graphics.SkiaSharp/LTRData.Graphics.SkiaSharp.csproj @@ -0,0 +1,14 @@ + + + net8.0;net9.0;net10.0 + 1.1.0 + 1.1.0 + $(Version) + SkiaSharp renderers for function plots and mathematical expression diagrams. + + + + + + + diff --git a/LTRData.Graphics.SkiaSharp/SkiaDiagramRenderer.cs b/LTRData.Graphics.SkiaSharp/SkiaDiagramRenderer.cs new file mode 100644 index 0000000..62499b3 --- /dev/null +++ b/LTRData.Graphics.SkiaSharp/SkiaDiagramRenderer.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using LTRData.MathExpression; +using LTRData.MathExpression.Diagrams; +using SkiaSharp; + +namespace LTRData.Graphics.SkiaSharp; + +/// Skia-specific node appearance and measurement padding. +public sealed record SkiaDiagramTheme +{ + /// Default diagram styling. + public static SkiaDiagramTheme Default { get; } = new(); + /// Text color. + public SKColor TextColor { get; init; } = new(24, 37, 57); + /// Node outline and connector color. + public SKColor LineColor { get; init; } = new(129, 146, 169); + /// Operand node fill. + public SKColor OperandColor { get; init; } = new(245, 247, 250); + /// Operator and function node fill. + public SKColor OperationColor { get; init; } = new(225, 238, 255); + /// Horizontal padding on each side of a label. + public float HorizontalPadding { get; init; } = 16; + /// Vertical padding on each side of a label. + public float VerticalPadding { get; init; } = 10; +} + +/// Measures and renders expression labels using the caller's actual Skia font. +public static class SkiaDiagramRenderer +{ + /// Measures rectangles for portable layout, indexed by diagram node ID. + public static ReadOnlyCollection MeasureNodes(ExpressionDiagram diagram, + SKFont font, SkiaDiagramTheme? theme = null) + { + ArgumentNullException.ThrowIfNull(diagram); + ArgumentNullException.ThrowIfNull(font); + theme ??= SkiaDiagramTheme.Default; + if (!float.IsFinite(theme.HorizontalPadding) || theme.HorizontalPadding < 0 || + !float.IsFinite(theme.VerticalPadding) || theme.VerticalPadding < 0 || + !float.IsFinite(font.Size) || font.Size <= 0) + throw new ArgumentOutOfRangeException(nameof(theme), "Font size and padding must be finite and valid."); + var metrics = font.Metrics; + var sizes = new List(diagram.Nodes.Count); + foreach (var node in diagram.Nodes) + { + var advance = font.MeasureText(node.Label, out var bounds); + sizes.Add(new DiagramSize( + Math.Max(1, Math.Max(advance, bounds.Width)) + 2 * theme.HorizontalPadding, + Math.Max(1, Math.Max(metrics.Descent - metrics.Ascent, bounds.Height)) + 2 * theme.VerticalPadding)); + } + return sizes.AsReadOnly(); + } + + /// + /// Draws at the layout's native size; the caller may transform/clip its canvas to fit. + /// Use the same font for measurement and rendering. The caller owns all supplied objects. + /// + public static void Render(SKCanvas canvas, TreeDiagramLayout layout, SKFont font, + SkiaDiagramTheme? theme = null) + { + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(font); + if (layout.Size.Width > float.MaxValue || layout.Size.Height > float.MaxValue) + throw new ArgumentOutOfRangeException(nameof(layout)); + theme ??= SkiaDiagramTheme.Default; + using var line = new SKPaint { Color = theme.LineColor, Style = SKPaintStyle.Stroke, StrokeWidth = 1.25f, IsAntialias = true }; + using var fill = new SKPaint { IsAntialias = true }; + using var text = new SKPaint { Color = theme.TextColor, IsAntialias = true }; + using var path = new SKPathBuilder(); + foreach (var connector in layout.Connectors) + { + var middle = (float)((connector.Start.Y + connector.End.Y) / 2); + path.Reset(); + path.MoveTo((float)connector.Start.X, (float)connector.Start.Y); + path.LineTo((float)connector.Start.X, middle); + path.LineTo((float)connector.End.X, middle); + path.LineTo((float)connector.End.X, (float)connector.End.Y); + using var connectorPath = path.Detach(); + canvas.DrawPath(connectorPath, line); + } + foreach (var node in layout.Nodes) + { + var rect = SKRect.Create((float)node.X, (float)node.Y, + (float)node.Size.Width, (float)node.Size.Height); + fill.Color = node.Node.Kind is MathSyntaxKind.Number or MathSyntaxKind.Name + ? theme.OperandColor : theme.OperationColor; + canvas.DrawRoundRect(rect, 6, 6, fill); + canvas.DrawRoundRect(rect, 6, 6, line); + font.MeasureText(node.Node.Label, out var bounds); + canvas.DrawText(node.Node.Label, + rect.MidX - (bounds.Left + bounds.Right) / 2, + rect.MidY - (bounds.Top + bounds.Bottom) / 2, + SKTextAlign.Left, font, text); + } + } +} diff --git a/LTRData.Graphics.SkiaSharp/SkiaPlotRenderer.cs b/LTRData.Graphics.SkiaSharp/SkiaPlotRenderer.cs new file mode 100644 index 0000000..a7952a9 --- /dev/null +++ b/LTRData.Graphics.SkiaSharp/SkiaPlotRenderer.cs @@ -0,0 +1,75 @@ +using System; +using LTRData.FunctionPlotting; +using SkiaSharp; + +namespace LTRData.Graphics.SkiaSharp; + +/// Backend-specific styling for Cartesian axes and a curve. +public sealed record SkiaPlotTheme +{ + /// Default plot styling. + public static SkiaPlotTheme Default { get; } = new(); + /// Curve stroke color. + public SKColor CurveColor { get; init; } = new(30, 88, 175); + /// Axis stroke color. + public SKColor AxisColor { get; init; } = new(155, 163, 175); + /// Positive curve stroke width in canvas units. + public float StrokeWidth { get; init; } = 2; +} + +/// Draws existing plot geometry without sampling, clearing, or retaining paths. +public static class SkiaPlotRenderer +{ + /// Draws one curve. Call repeatedly to compose explicit overlays. + public static void Render(SKCanvas canvas, CurveGeometry geometry, SkiaPlotTheme? theme = null) + { + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(geometry); + theme ??= SkiaPlotTheme.Default; + if (!float.IsFinite(theme.StrokeWidth) || theme.StrokeWidth <= 0) + throw new ArgumentOutOfRangeException(nameof(theme)); + using var paint = new SKPaint + { + Color = theme.CurveColor, Style = SKPaintStyle.Stroke, + StrokeWidth = theme.StrokeWidth, IsAntialias = true + }; + using var path = new SKPathBuilder(); + foreach (var line in geometry.Polylines) + { + path.Reset(); + for (var i = 0; i < line.Points.Count; i++) + { + var point = line.Points[i]; + if (!double.IsFinite(point.X) || !double.IsFinite(point.Y) || + Math.Abs(point.X) > float.MaxValue || Math.Abs(point.Y) > float.MaxValue) + throw new ArgumentException("Geometry contains an unrenderable coordinate.", nameof(geometry)); + if (i == 0) path.MoveTo((float)point.X, (float)point.Y); + else path.LineTo((float)point.X, (float)point.Y); + } + using var curve = path.Detach(); + canvas.DrawPath(curve, paint); + } + } + + /// Draws only axes that intersect the viewport, without changing canvas state. + public static void RenderAxes(SKCanvas canvas, PlotViewport viewport, SkiaPlotTheme? theme = null) + { + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(viewport); + if (viewport.Canvas.Width > float.MaxValue || viewport.Canvas.Height > float.MaxValue) + throw new ArgumentOutOfRangeException(nameof(viewport)); + theme ??= SkiaPlotTheme.Default; + using var paint = new SKPaint { Color = theme.AxisColor, StrokeWidth = 1, IsAntialias = true }; + // Compute each axis independently: an offscreen other coordinate need not be representable. + if (viewport.XRange.Minimum <= 0 && viewport.XRange.Maximum >= 0) + { + var x = (float)(-viewport.XRange.Minimum / viewport.XRange.Length * viewport.Canvas.Width); + canvas.DrawLine(x, 0, x, (float)viewport.Canvas.Height, paint); + } + if (viewport.YRange.Minimum <= 0 && viewport.YRange.Maximum >= 0) + { + var y = (float)(viewport.YRange.Maximum / viewport.YRange.Length * viewport.Canvas.Height); + canvas.DrawLine(0, y, (float)viewport.Canvas.Width, y, paint); + } + } +} diff --git a/LTRData.Graphics.SkiaSharp/SkiaPngEncoder.cs b/LTRData.Graphics.SkiaSharp/SkiaPngEncoder.cs new file mode 100644 index 0000000..0390143 --- /dev/null +++ b/LTRData.Graphics.SkiaSharp/SkiaPngEncoder.cs @@ -0,0 +1,20 @@ +using System; +using System.IO; +using SkiaSharp; + +namespace LTRData.Graphics.SkiaSharp; + +/// PNG encoding separate from diagram/plot rendering. +public static class SkiaPngEncoder +{ + /// Writes a PNG at the destination's current position, leaving both inputs open. + public static void Encode(SKImage image, Stream destination) + { + ArgumentNullException.ThrowIfNull(image); + ArgumentNullException.ThrowIfNull(destination); + if (!destination.CanWrite) throw new ArgumentException("The destination must be writable.", nameof(destination)); + using var data = image.Encode(SKEncodedImageFormat.Png, 100) + ?? throw new InvalidOperationException("Skia could not encode the image as PNG."); + data.SaveTo(destination); + } +} diff --git a/LTRData.MathExpression/Diagrams/ExpressionDiagram.cs b/LTRData.MathExpression/Diagrams/ExpressionDiagram.cs new file mode 100644 index 0000000..2506076 --- /dev/null +++ b/LTRData.MathExpression/Diagrams/ExpressionDiagram.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace LTRData.MathExpression.Diagrams; + +/// An ordered child occurrence in an expression diagram. +public sealed class ExpressionDiagramChild +{ + internal ExpressionDiagramChild(int nodeId, string role) + { + NodeId = nodeId; + Role = role; + } + + /// Index of the child in . + public int NodeId { get; } + + /// The operand or argument's role, independent of its visual position. + public string Role { get; } +} + +/// Backend-independent content for one occurrence of a syntax node. +public sealed class ExpressionDiagramNode +{ + internal ExpressionDiagramNode(int id, MathSyntax syntax, string label, + IList children) + { + Id = id; + Kind = syntax.Kind; + Span = syntax.Span; + Label = label; + Children = new List(children).AsReadOnly(); + } + + /// Stable zero-based preorder index within this diagram. + public int Id { get; } + /// Original syntax category. + public MathSyntaxKind Kind { get; } + /// Original source location. + public SourceSpan Span { get; } + /// Node label, without renderer markup. + public string Label { get; } + /// Children in mathematical operand/argument order. + public ReadOnlyCollection Children { get; } +} + +/// Immutable expression-tree content, independent of layout and graphics. +public sealed class ExpressionDiagram +{ + private ExpressionDiagram(List nodes) => Nodes = nodes.AsReadOnly(); + + /// Nodes in preorder; the root is always node zero. + public ReadOnlyCollection Nodes { get; } + + /// + /// Projects syntax into a tree. Names and numeric spelling are preserved; operators + /// use conventional labels. Parenthesis nodes may be retained explicitly. This is + /// a syntax projection, not algebraic simplification or a bound semantic diagram. + /// + /// The root syntax. + /// Whether to show grouping as separate nodes. + /// Maximum visited syntax occurrences, including omitted grouping. + public static ExpressionDiagram FromSyntax(MathSyntax syntax, + bool includeParentheses = false, int maxNodes = 4096) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(syntax); +#else + if (syntax is null) throw new ArgumentNullException(nameof(syntax)); +#endif + if (maxNodes < 1) throw new ArgumentOutOfRangeException(nameof(maxNodes)); + + var syntaxNodes = new List(); + var labels = new List(); + var children = new List>(); + var pending = new Stack(); + pending.Push(new PendingNode(syntax, -1, string.Empty)); + var visited = 0; + + // Iterative traversal also handles deeply nested, manually constructed syntax. + while (pending.Count != 0) + { + var next = pending.Pop(); + var current = next.Syntax; + if (++visited > maxNodes) + throw new ArgumentException("The expression exceeds the diagram node limit.", nameof(syntax)); + + if (!includeParentheses && current is ParenthesizedSyntax grouping) + { + pending.Push(new PendingNode(grouping.Expression, next.Parent, next.Role)); + continue; + } + + var id = syntaxNodes.Count; + syntaxNodes.Add(current); + children.Add(new List()); + if (next.Parent >= 0) + children[next.Parent].Add(new ExpressionDiagramChild(id, next.Role)); + + switch (current) + { + case NumberSyntax number: + labels.Add(number.Lexeme); + break; + case NameSyntax name: + labels.Add(name.Name); + break; + case ParenthesizedSyntax parenthesized: + labels.Add("( )"); + pending.Push(new PendingNode(parenthesized.Expression, id, "expression")); + break; + case PrefixSyntax prefix: + labels.Add(prefix.Operator == MathPrefixOperator.Negate ? "−" : "+"); + pending.Push(new PendingNode(prefix.Operand, id, "operand")); + break; + case PostfixSyntax postfix: + labels.Add("!"); + pending.Push(new PendingNode(postfix.Operand, id, "operand")); + break; + case BinarySyntax binary: + labels.Add(BinaryLabel(binary.Operator)); + pending.Push(new PendingNode(binary.Right, id, "right")); + pending.Push(new PendingNode(binary.Left, id, "left")); + break; + case CallSyntax call: + labels.Add(call.Name); + for (var i = call.Arguments.Count - 1; i >= 0; i--) + pending.Push(new PendingNode(call.Arguments[i], id, + "argument " + (i + 1).ToString(System.Globalization.CultureInfo.InvariantCulture))); + break; + default: + throw new ArgumentException("Unsupported syntax node.", nameof(syntax)); + } + } + + var nodes = new List(syntaxNodes.Count); + for (var i = 0; i < syntaxNodes.Count; i++) + nodes.Add(new ExpressionDiagramNode(i, syntaxNodes[i], labels[i], children[i])); + return new ExpressionDiagram(nodes); + } + + private static string BinaryLabel(MathBinaryOperator op) => op switch + { + MathBinaryOperator.Add => "+", + MathBinaryOperator.Subtract => "−", + MathBinaryOperator.Multiply => "×", + MathBinaryOperator.Divide => "÷", + MathBinaryOperator.Remainder => "mod", + MathBinaryOperator.Power => "^", + _ => throw new ArgumentOutOfRangeException(nameof(op)) + }; + + private sealed class PendingNode + { + public PendingNode(MathSyntax syntax, int parent, string role) + { + Syntax = syntax; + Parent = parent; + Role = role; + } + public MathSyntax Syntax { get; } + public int Parent { get; } + public string Role { get; } + } +} diff --git a/LTRData.MathExpression/Diagrams/TreeDiagramLayout.cs b/LTRData.MathExpression/Diagrams/TreeDiagramLayout.cs new file mode 100644 index 0000000..f3dc5ee --- /dev/null +++ b/LTRData.MathExpression/Diagrams/TreeDiagramLayout.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace LTRData.MathExpression.Diagrams; + +/// A finite positive measured node or diagram size, in continuous canvas units. +public struct DiagramSize +{ + /// Creates a validated measured size. + public DiagramSize(double width, double height) + { + if (!IsPositiveFinite(width)) throw new ArgumentOutOfRangeException(nameof(width)); + if (!IsPositiveFinite(height)) throw new ArgumentOutOfRangeException(nameof(height)); + Width = width; + Height = height; + } + /// Width in canvas units. + public double Width { get; } + /// Height in canvas units. + public double Height { get; } + internal static bool IsPositiveFinite(double value) => value > 0 && !double.IsInfinity(value); +} + +/// A point in a diagram's continuous coordinate space. +public struct DiagramPoint +{ + /// Creates a point. + public DiagramPoint(double x, double y) { X = x; Y = y; } + /// Horizontal coordinate. + public double X { get; } + /// Vertical coordinate, increasing downward. + public double Y { get; } +} + +/// The rectangle reserved for a measured node. +public sealed class DiagramNodeLayout +{ + internal DiagramNodeLayout(ExpressionDiagramNode node, double x, double y, DiagramSize size) + { Node = node; X = x; Y = y; Size = size; } + /// Content being positioned. + public ExpressionDiagramNode Node { get; } + /// Left edge. + public double X { get; } + /// Top edge. + public double Y { get; } + /// Measured rectangle size. + public DiagramSize Size { get; } +} + +/// A connector from a parent's bottom center to a child's top center. +public sealed class DiagramConnector +{ + internal DiagramConnector(int parentId, ExpressionDiagramChild child, + DiagramPoint start, DiagramPoint end) + { ParentId = parentId; Child = child; Start = start; End = end; } + /// Parent node ID. + public int ParentId { get; } + /// Child node ID and ordered operand role. + public ExpressionDiagramChild Child { get; } + /// Start point on the parent. + public DiagramPoint Start { get; } + /// End point on the child. + public DiagramPoint End { get; } +} + +/// Immutable, backend-independent rectangles and connectors for a tree. +public sealed class TreeDiagramLayout +{ + private TreeDiagramLayout(DiagramSize size, List nodes, + List connectors) + { Size = size; Nodes = nodes.AsReadOnly(); Connectors = connectors.AsReadOnly(); } + /// Total extent, including margins. + public DiagramSize Size { get; } + /// Positioned nodes indexed by node ID. + public ReadOnlyCollection Nodes { get; } + /// Connectors in parent and child order. + public ReadOnlyCollection Connectors { get; } + + /// + /// Lays out measured nodes in non-overlapping subtrees. Measurement is supplied by + /// the caller's font backend. Runs iteratively in linear time and preserves child order. + /// + /// Tree content. + /// Measured sizes indexed by node ID; copied during layout. + /// Space between adjacent subtree extents. + /// Space between levels. + /// Space around the diagram. + public static TreeDiagramLayout Create(ExpressionDiagram diagram, IList nodeSizes, + double siblingSpacing = 24, double levelSpacing = 40, double margin = 16) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(diagram); + ArgumentNullException.ThrowIfNull(nodeSizes); +#else + if (diagram is null) throw new ArgumentNullException(nameof(diagram)); + if (nodeSizes is null) throw new ArgumentNullException(nameof(nodeSizes)); +#endif + ValidateSpacing(siblingSpacing, nameof(siblingSpacing)); + ValidateSpacing(levelSpacing, nameof(levelSpacing)); + ValidateSpacing(margin, nameof(margin)); + var count = diagram.Nodes.Count; + if (nodeSizes.Count != count) + throw new ArgumentException("One measured size per node is required.", nameof(nodeSizes)); + + var sizes = new DiagramSize[count]; + var widths = new double[count]; + var depth = new int[count]; + var levelHeights = new double[count]; + var levelTops = new double[count]; + var lefts = new double[count]; + var maxDepth = 0; + for (var i = 0; i < count; i++) + { + var size = nodeSizes[i]; + if (!DiagramSize.IsPositiveFinite(size.Width) || !DiagramSize.IsPositiveFinite(size.Height)) + throw new ArgumentException("Node sizes must be finite and positive.", nameof(nodeSizes)); + sizes[i] = size; + levelHeights[depth[i]] = Math.Max(levelHeights[depth[i]], size.Height); + maxDepth = Math.Max(maxDepth, depth[i]); + foreach (var child in diagram.Nodes[i].Children) depth[child.NodeId] = depth[i] + 1; + } + + for (var i = count - 1; i >= 0; i--) + { + var children = diagram.Nodes[i].Children; + var childWidth = 0d; + foreach (var child in children) childWidth += widths[child.NodeId]; + if (children.Count > 1) childWidth += siblingSpacing * (children.Count - 1); + widths[i] = Math.Max(sizes[i].Width, childWidth); + } + + levelTops[0] = margin; + for (var i = 1; i <= maxDepth; i++) + levelTops[i] = levelTops[i - 1] + levelHeights[i - 1] + levelSpacing; + + // Constructor detects overflow before any coordinates are given to a backend. + var extent = new DiagramSize(widths[0] + 2 * margin, + levelTops[maxDepth] + levelHeights[maxDepth] + margin); + lefts[0] = margin; + var placed = new List(count); + for (var i = 0; i < count; i++) + { + placed.Add(new DiagramNodeLayout(diagram.Nodes[i], + lefts[i] + (widths[i] - sizes[i].Width) / 2, levelTops[depth[i]], sizes[i])); + var children = diagram.Nodes[i].Children; + var total = 0d; + foreach (var child in children) total += widths[child.NodeId]; + if (children.Count > 1) total += siblingSpacing * (children.Count - 1); + var nextLeft = lefts[i] + (widths[i] - total) / 2; + foreach (var child in children) + { + lefts[child.NodeId] = nextLeft; + nextLeft += widths[child.NodeId] + siblingSpacing; + } + } + + var connectors = new List(count - 1); + foreach (var parent in placed) + foreach (var child in parent.Node.Children) + { + var target = placed[child.NodeId]; + connectors.Add(new DiagramConnector(parent.Node.Id, child, + new DiagramPoint(parent.X + parent.Size.Width / 2, parent.Y + parent.Size.Height), + new DiagramPoint(target.X + target.Size.Width / 2, target.Y))); + } + return new TreeDiagramLayout(extent, placed, connectors); + } + + private static void ValidateSpacing(double value, string name) + { + if (double.IsNaN(value) || double.IsInfinity(value) || value < 0) + throw new ArgumentOutOfRangeException(name); + } +} diff --git a/LTRData.MathExpression/LTRData.MathExpression.csproj b/LTRData.MathExpression/LTRData.MathExpression.csproj index 5e62963..8eecec2 100644 --- a/LTRData.MathExpression/LTRData.MathExpression.csproj +++ b/LTRData.MathExpression/LTRData.MathExpression.csproj @@ -1,8 +1,9 @@ - 1.0.23 - $(Version) + 1.1.0 + 1.1.0 + $(Version) diff --git a/LTRData.MathExpression/MathExpression/MathBinding.cs b/LTRData.MathExpression/MathExpression/MathBinding.cs new file mode 100644 index 0000000..3d408ad --- /dev/null +++ b/LTRData.MathExpression/MathExpression/MathBinding.cs @@ -0,0 +1,479 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace LTRData.MathExpression; + +#pragma warning disable CS1591 + +public sealed class MathVariable +{ + internal MathVariable(string name, int slot) + { + Name = name; + Slot = slot; + } + + public string Name { get; } + + public int Slot { get; } +} + +public sealed class MathBindingResult +{ + internal MathBindingResult(BoundMathExpression? expression, + IEnumerable diagnostics) + { + Expression = expression; + Diagnostics = new List(diagnostics).AsReadOnly(); + } + + public BoundMathExpression? Expression { get; } + + public ReadOnlyCollection Diagnostics { get; } + + public bool Success => Expression is not null && Diagnostics.Count == 0; +} + +public static class MathBinder +{ + public static MathBindingResult Bind(MathSyntax syntax, + MathSymbolCatalog symbolCatalog) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(syntax); +#else + if (syntax is null) + { + throw new ArgumentNullException(nameof(syntax)); + } +#endif + +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(symbolCatalog); +#else + if (symbolCatalog is null) + { + throw new ArgumentNullException(nameof(symbolCatalog)); + } +#endif + + var binder = new Binder(symbolCatalog); + var root = binder.Bind(syntax); + + if (binder.Diagnostics.Count != 0) + { + return new MathBindingResult(null, binder.Diagnostics); + } + + return new MathBindingResult( + new BoundMathExpression(root, binder.Variables), binder.Diagnostics); + } + + private sealed class Binder + { + private readonly MathSymbolCatalog symbolCatalog; + private readonly Dictionary variablesByName = + new(StringComparer.OrdinalIgnoreCase); + private readonly List variables = new(); + private readonly List diagnostics = new(); + + public Binder(MathSymbolCatalog symbolCatalog) + { + this.symbolCatalog = symbolCatalog; + } + + public List Variables => variables; + + public List Diagnostics => diagnostics; + + public BoundNode Bind(MathSyntax syntax) + { + if (syntax is NumberSyntax number) + { + return new BoundConstantNode(number.Value); + } + + if (syntax is NameSyntax name) + { + if (symbolCatalog.TryGetConstant(name.Name, out var value)) + { + return new BoundConstantNode(value); + } + + if (!variablesByName.TryGetValue(name.Name, out var variable)) + { + variable = new MathVariable(name.Name, variables.Count); + variablesByName.Add(name.Name, variable); + variables.Add(variable); + } + + return new BoundVariableNode(variable.Slot); + } + + if (syntax is ParenthesizedSyntax parenthesized) + { + return Bind(parenthesized.Expression); + } + + if (syntax is PrefixSyntax prefix) + { + return new BoundPrefixNode(prefix.Operator, Bind(prefix.Operand)); + } + + if (syntax is PostfixSyntax postfix) + { + return new BoundPostfixNode(postfix.Operator, Bind(postfix.Operand)); + } + + if (syntax is BinarySyntax binary) + { + return new BoundBinaryNode(binary.Operator, + Bind(binary.Left), Bind(binary.Right)); + } + + if (syntax is CallSyntax call) + { + var arguments = new BoundNode[call.Arguments.Count]; + + for (var index = 0; index < arguments.Length; index++) + { + arguments[index] = Bind(call.Arguments[index]); + } + + if (!symbolCatalog.TryGetFunction(call.Name, arguments.Length, + out var function) || function is null) + { + diagnostics.Add(new MathDiagnostic("MATH300", + $"Unknown function or unsupported arity: '{call.Name}' with " + + $"{arguments.Length} argument(s).", call.NameSpan)); + return new BoundConstantNode(double.NaN); + } + + return new BoundCallNode(function, arguments); + } + + throw new NotSupportedException( + $"Unsupported syntax node '{syntax.GetType().FullName}'."); + } + } +} + +public sealed class BoundMathExpression +{ + private readonly BoundNode root; + + internal BoundMathExpression(BoundNode root, IEnumerable variables) + { + this.root = root; + Variables = new List(variables).AsReadOnly(); + } + + public ReadOnlyCollection Variables { get; } + + public double Evaluate(params double[] values) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(values); +#else + if (values is null) + { + throw new ArgumentNullException(nameof(values)); + } +#endif + + if (values.Length != Variables.Count) + { + throw new ArgumentException( + $"Expected {Variables.Count} variable value(s), but received {values.Length}.", + nameof(values)); + } + + return root.Evaluate(MathEvaluationContext.ForValues(values)); + } + + public double Evaluate(IDictionary values) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(values); +#else + if (values is null) + { + throw new ArgumentNullException(nameof(values)); + } +#endif + + var valuesBySlot = new double[Variables.Count]; + + foreach (var variable in Variables) + { + if (!TryGetValue(values, variable.Name, out valuesBySlot[variable.Slot])) + { + throw new KeyNotFoundException( + $"No value was supplied for variable '{variable.Name}'."); + } + } + + return root.Evaluate(MathEvaluationContext.ForValues(valuesBySlot)); + } + + public UnaryMathFunction BindUnary(string variableName) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(variableName); +#else + if (variableName is null) + { + throw new ArgumentNullException(nameof(variableName)); + } +#endif + + if (Variables.Count == 0) + { + return new UnaryMathFunction(root, -1); + } + + if (Variables.Count != 1 || + !string.Equals(Variables[0].Name, variableName, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Expression cannot be bound as a unary function of '{variableName}'."); + } + + return new UnaryMathFunction(root, Variables[0].Slot); + } + + private static bool TryGetValue(IDictionary values, + string name, out double value) + { + if (values.TryGetValue(name, out value)) + { + return true; + } + + foreach (var entry in values) + { + if (string.Equals(entry.Key, name, StringComparison.OrdinalIgnoreCase)) + { + value = entry.Value; + return true; + } + } + + value = 0d; + return false; + } +} + +public sealed class UnaryMathFunction +{ + private readonly BoundNode root; + private readonly int variableSlot; + + internal UnaryMathFunction(BoundNode root, int variableSlot) + { + this.root = root; + this.variableSlot = variableSlot; + } + + public double Evaluate(double value) => + root.Evaluate(MathEvaluationContext.ForUnary(variableSlot, value)); +} + +internal struct MathEvaluationContext +{ + private readonly double[]? values; + private readonly int unarySlot; + private readonly double unaryValue; + + private MathEvaluationContext(double[]? values, int unarySlot, double unaryValue) + { + this.values = values; + this.unarySlot = unarySlot; + this.unaryValue = unaryValue; + } + + public static MathEvaluationContext ForValues(double[] values) => + new(values, -1, 0d); + + public static MathEvaluationContext ForUnary(int variableSlot, double value) => + new(null, variableSlot, value); + + public double GetVariable(int slot) + { + if (slot == unarySlot) + { + return unaryValue; + } + + if (values is null) + { + throw new InvalidOperationException("No value is available for this variable."); + } + + return values[slot]; + } +} + +internal abstract class BoundNode +{ + public abstract double Evaluate(MathEvaluationContext context); +} + +internal sealed class BoundConstantNode : BoundNode +{ + private readonly double value; + + public BoundConstantNode(double value) + { + this.value = value; + } + + public override double Evaluate(MathEvaluationContext context) => value; +} + +internal sealed class BoundVariableNode : BoundNode +{ + private readonly int slot; + + public BoundVariableNode(int slot) + { + this.slot = slot; + } + + public override double Evaluate(MathEvaluationContext context) => + context.GetVariable(slot); +} + +internal sealed class BoundPrefixNode : BoundNode +{ + private readonly MathPrefixOperator @operator; + private readonly BoundNode operand; + + public BoundPrefixNode(MathPrefixOperator @operator, BoundNode operand) + { + this.@operator = @operator; + this.operand = operand; + } + + public override double Evaluate(MathEvaluationContext context) + { + var value = operand.Evaluate(context); + + return @operator == MathPrefixOperator.Negate ? -value : value; + } +} + +internal sealed class BoundPostfixNode : BoundNode +{ + private readonly MathPostfixOperator @operator; + private readonly BoundNode operand; + + public BoundPostfixNode(MathPostfixOperator @operator, BoundNode operand) + { + this.@operator = @operator; + this.operand = operand; + } + + public override double Evaluate(MathEvaluationContext context) + { + var value = operand.Evaluate(context); + + switch (@operator) + { + case MathPostfixOperator.Factorial: + return Factorial(value); + default: + throw new NotSupportedException($"Unsupported postfix operator '{@operator}'."); + } + } + + private static double Factorial(double value) + { + if (double.IsNaN(value) || double.IsInfinity(value) || + value < 0d || value != Math.Floor(value)) + { + return double.NaN; + } + + if (value > 170d) + { + return double.PositiveInfinity; + } + + var result = 1d; + + for (var factor = 2; factor <= (int)value; factor++) + { + result *= factor; + } + + return result; + } +} + +internal sealed class BoundBinaryNode : BoundNode +{ + private readonly MathBinaryOperator @operator; + private readonly BoundNode left; + private readonly BoundNode right; + + public BoundBinaryNode(MathBinaryOperator @operator, BoundNode left, BoundNode right) + { + this.@operator = @operator; + this.left = left; + this.right = right; + } + + public override double Evaluate(MathEvaluationContext context) + { + var leftValue = left.Evaluate(context); + var rightValue = right.Evaluate(context); + + switch (@operator) + { + case MathBinaryOperator.Add: + return leftValue + rightValue; + case MathBinaryOperator.Subtract: + return leftValue - rightValue; + case MathBinaryOperator.Multiply: + return leftValue * rightValue; + case MathBinaryOperator.Divide: + return leftValue / rightValue; + case MathBinaryOperator.Remainder: + return leftValue % rightValue; + case MathBinaryOperator.Power: + return Math.Pow(leftValue, rightValue); + default: + throw new NotSupportedException($"Unsupported binary operator '{@operator}'."); + } + } +} + +internal sealed class BoundCallNode : BoundNode +{ + private readonly MathFunctionDefinition function; + private readonly BoundNode[] arguments; + + public BoundCallNode(MathFunctionDefinition function, BoundNode[] arguments) + { + this.function = function; + this.arguments = arguments; + } + + public override double Evaluate(MathEvaluationContext context) + { + switch (function.Arity) + { + case 1: + return function.Invoke(arguments[0].Evaluate(context)); + case 2: + return function.Invoke(arguments[0].Evaluate(context), + arguments[1].Evaluate(context)); + default: + throw new NotSupportedException( + $"Unsupported function arity {function.Arity}."); + } + } +} diff --git a/LTRData.MathExpression/MathExpression/MathDiagnostics.cs b/LTRData.MathExpression/MathExpression/MathDiagnostics.cs new file mode 100644 index 0000000..0f7118b --- /dev/null +++ b/LTRData.MathExpression/MathExpression/MathDiagnostics.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace LTRData.MathExpression; + +#pragma warning disable CS1591 + +public sealed class MathDiagnostic +{ + public MathDiagnostic(string code, string message, SourceSpan span) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(code); +#else + if (code is null) throw new ArgumentNullException(nameof(code)); +#endif + Code = code; +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(message); +#else + if (message is null) throw new ArgumentNullException(nameof(message)); +#endif + Message = message; + Span = span; + } + + public string Code { get; } + + public string Message { get; } + + public SourceSpan Span { get; } + + public override string ToString() => $"{Code} at {Span}: {Message}"; +} + +public sealed class MathParseResult +{ + internal MathParseResult(string sourceText, MathSyntax? root, + IEnumerable diagnostics) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(sourceText); +#else + if (sourceText is null) throw new ArgumentNullException(nameof(sourceText)); +#endif + SourceText = sourceText; + Root = root; + Diagnostics = new List(diagnostics).AsReadOnly(); + } + + public string SourceText { get; } + + public MathSyntax? Root { get; } + + public ReadOnlyCollection Diagnostics { get; } + + public bool Success => Root is not null && Diagnostics.Count == 0; +} diff --git a/LTRData.MathExpression/MathExpression/MathParser.cs b/LTRData.MathExpression/MathExpression/MathParser.cs new file mode 100644 index 0000000..4616370 --- /dev/null +++ b/LTRData.MathExpression/MathExpression/MathParser.cs @@ -0,0 +1,500 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace LTRData.MathExpression; + +#pragma warning disable CS1591 + +public sealed class MathParser +{ + public static MathParser Default { get; } = new(); + + public MathParseResult Parse(string sourceText) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(sourceText); +#else + if (sourceText is null) + { + throw new ArgumentNullException(nameof(sourceText)); + } +#endif + + var diagnostics = new List(); + var lexer = new MathLexer(sourceText, diagnostics); + var tokens = lexer.Lex(); + + if (diagnostics.Count != 0) + { + return new MathParseResult(sourceText, null, diagnostics); + } + + var parser = new SyntaxParser(tokens, diagnostics); + var root = parser.ParseRoot(); + + return new MathParseResult(sourceText, diagnostics.Count == 0 ? root : null, + diagnostics); + } + + private sealed class SyntaxParser + { + private readonly List tokens; + private readonly List diagnostics; + private int position; + + public SyntaxParser(List tokens, List diagnostics) + { + this.tokens = tokens; + this.diagnostics = diagnostics; + } + + private MathToken Current => tokens[position]; + + private MathToken NextToken() + { + var current = Current; + + if (current.Kind != MathTokenKind.End) + { + position++; + } + + return current; + } + + public MathSyntax? ParseRoot() + { + try + { + var expression = ParseAdditive(); + + if (Current.Kind != MathTokenKind.End) + { + Report("MATH203", $"Unexpected token '{Current.Text}'.", Current.Span); + return null; + } + + return expression; + } + catch (ParseAbortException) + { + return null; + } + } + + private MathSyntax ParseAdditive() + { + var left = ParseMultiplicative(); + + while (Current.Kind == MathTokenKind.Plus || + Current.Kind == MathTokenKind.Minus) + { + var operatorToken = NextToken(); + var right = ParseMultiplicative(); + var @operator = operatorToken.Kind == MathTokenKind.Plus + ? MathBinaryOperator.Add + : MathBinaryOperator.Subtract; + + left = new BinarySyntax(left, @operator, operatorToken.Span, right, + false, SourceSpan.FromBounds(left.Span.Start, right.Span.End)); + } + + return left; + } + + private MathSyntax ParseMultiplicative() + { + var left = ParseUnary(); + + for (; ; ) + { + MathBinaryOperator @operator; + MathToken operatorToken; + var isImplicit = false; + + switch (Current.Kind) + { + case MathTokenKind.Star: + operatorToken = NextToken(); + @operator = MathBinaryOperator.Multiply; + break; + + case MathTokenKind.Slash: + operatorToken = NextToken(); + @operator = MathBinaryOperator.Divide; + break; + + case MathTokenKind.Percent: + operatorToken = NextToken(); + @operator = MathBinaryOperator.Remainder; + break; + + case MathTokenKind.Identifier when + string.Equals(Current.Text, "mod", StringComparison.OrdinalIgnoreCase): + operatorToken = NextToken(); + @operator = MathBinaryOperator.Remainder; + break; + + case MathTokenKind.Identifier: + case MathTokenKind.OpenParenthesis: + operatorToken = new MathToken(MathTokenKind.ImplicitMultiply, + string.Empty, + SourceSpan.FromBounds(left.Span.End, Current.Span.Start), 0d); + @operator = MathBinaryOperator.Multiply; + isImplicit = true; + break; + + default: + return left; + } + + var right = ParseUnary(); + left = new BinarySyntax(left, @operator, operatorToken.Span, right, + isImplicit, SourceSpan.FromBounds(left.Span.Start, right.Span.End)); + } + } + + private MathSyntax ParseUnary() + { + if (Current.Kind == MathTokenKind.Plus || + Current.Kind == MathTokenKind.Minus) + { + var operatorToken = NextToken(); + var operand = ParseUnary(); + var @operator = operatorToken.Kind == MathTokenKind.Plus + ? MathPrefixOperator.Identity + : MathPrefixOperator.Negate; + + return new PrefixSyntax(@operator, operatorToken.Span, operand, + SourceSpan.FromBounds(operatorToken.Span.Start, operand.Span.End)); + } + + return ParsePower(); + } + + private MathSyntax ParsePower() + { + var left = ParsePostfix(); + + if (Current.Kind == MathTokenKind.Caret || + Current.Kind == MathTokenKind.DoubleStar) + { + var operatorToken = NextToken(); + var right = ParseUnary(); + + return new BinarySyntax(left, MathBinaryOperator.Power, + operatorToken.Span, right, false, + SourceSpan.FromBounds(left.Span.Start, right.Span.End)); + } + + return left; + } + + private MathSyntax ParsePostfix() + { + var expression = ParsePrimary(); + + while (Current.Kind == MathTokenKind.Bang) + { + var operatorToken = NextToken(); + expression = new PostfixSyntax(expression, + MathPostfixOperator.Factorial, operatorToken.Span, + SourceSpan.FromBounds(expression.Span.Start, operatorToken.Span.End)); + } + + return expression; + } + + private MathSyntax ParsePrimary() + { + if (Current.Kind == MathTokenKind.Number) + { + var number = NextToken(); + return new NumberSyntax(number.Number, number.Text, number.Span); + } + + if (Current.Kind == MathTokenKind.Identifier) + { + var name = NextToken(); + + if (Current.Kind != MathTokenKind.OpenParenthesis) + { + return new NameSyntax(name.Text, name.Span); + } + + NextToken(); + var arguments = new List(); + + if (Current.Kind != MathTokenKind.CloseParenthesis) + { + for (; ; ) + { + arguments.Add(ParseAdditive()); + + if (Current.Kind != MathTokenKind.Comma) + { + break; + } + + NextToken(); + } + } + + var close = Expect(MathTokenKind.CloseParenthesis, + "MATH202", "Expected ',' or ')' after function argument."); + + return new CallSyntax(name.Text, name.Span, arguments, + SourceSpan.FromBounds(name.Span.Start, close.Span.End)); + } + + if (Current.Kind == MathTokenKind.OpenParenthesis) + { + var open = NextToken(); + var expression = ParseAdditive(); + var close = Expect(MathTokenKind.CloseParenthesis, + "MATH201", "Expected ')' to close parenthesized expression."); + + return new ParenthesizedSyntax(expression, + SourceSpan.FromBounds(open.Span.Start, close.Span.End)); + } + + Report("MATH200", "Expected an expression.", Current.Span); + throw new ParseAbortException(); + } + + private MathToken Expect(MathTokenKind kind, string code, string message) + { + if (Current.Kind == kind) + { + return NextToken(); + } + + Report(code, message, Current.Span); + throw new ParseAbortException(); + } + + private void Report(string code, string message, SourceSpan span) + { + diagnostics.Add(new MathDiagnostic(code, message, span)); + } + } + + private sealed class ParseAbortException : Exception + { + } + + private sealed class MathLexer + { + private readonly string sourceText; + private readonly List diagnostics; + private int position; + + public MathLexer(string sourceText, List diagnostics) + { + this.sourceText = sourceText; + this.diagnostics = diagnostics; + } + + public List Lex() + { + var tokens = new List(); + + while (position < sourceText.Length) + { + if (char.IsWhiteSpace(sourceText[position])) + { + position++; + continue; + } + + var start = position; + var current = sourceText[position]; + + if (char.IsDigit(current) || + (current == '.' && position + 1 < sourceText.Length && + char.IsDigit(sourceText[position + 1]))) + { + tokens.Add(LexNumber()); + continue; + } + + if (char.IsLetter(current) || current == '_') + { + position++; + + while (position < sourceText.Length && + (char.IsLetterOrDigit(sourceText[position]) || + sourceText[position] == '_')) + { + position++; + } + + tokens.Add(CreateToken(MathTokenKind.Identifier, start, position)); + continue; + } + + position++; + + switch (current) + { + case '+': + tokens.Add(CreateToken(MathTokenKind.Plus, start, position)); + break; + case '-': + tokens.Add(CreateToken(MathTokenKind.Minus, start, position)); + break; + case '*': + if (position < sourceText.Length && sourceText[position] == '*') + { + position++; + tokens.Add(CreateToken(MathTokenKind.DoubleStar, start, position)); + } + else + { + tokens.Add(CreateToken(MathTokenKind.Star, start, position)); + } + break; + case '/': + tokens.Add(CreateToken(MathTokenKind.Slash, start, position)); + break; + case '%': + tokens.Add(CreateToken(MathTokenKind.Percent, start, position)); + break; + case '^': + tokens.Add(CreateToken(MathTokenKind.Caret, start, position)); + break; + case '!': + tokens.Add(CreateToken(MathTokenKind.Bang, start, position)); + break; + case '(': + tokens.Add(CreateToken(MathTokenKind.OpenParenthesis, start, position)); + break; + case ')': + tokens.Add(CreateToken(MathTokenKind.CloseParenthesis, start, position)); + break; + case ',': + tokens.Add(CreateToken(MathTokenKind.Comma, start, position)); + break; + default: + diagnostics.Add(new MathDiagnostic("MATH100", + $"Invalid character '{current}'.", + new SourceSpan(start, 1))); + tokens.Add(CreateToken(MathTokenKind.Invalid, start, position)); + break; + } + } + + tokens.Add(new MathToken(MathTokenKind.End, string.Empty, + new SourceSpan(sourceText.Length, 0), 0d)); + + return tokens; + } + + private MathToken LexNumber() + { + var start = position; + + while (position < sourceText.Length && char.IsDigit(sourceText[position])) + { + position++; + } + + if (position < sourceText.Length && sourceText[position] == '.') + { + position++; + + while (position < sourceText.Length && char.IsDigit(sourceText[position])) + { + position++; + } + } + + if (position < sourceText.Length && + (sourceText[position] == 'e' || sourceText[position] == 'E')) + { + position++; + + if (position < sourceText.Length && + (sourceText[position] == '+' || sourceText[position] == '-')) + { + position++; + } + + var exponentStart = position; + + while (position < sourceText.Length && char.IsDigit(sourceText[position])) + { + position++; + } + + if (position == exponentStart) + { + var invalidText = sourceText.Substring(start, position - start); + var invalidSpan = new SourceSpan(start, position - start); + diagnostics.Add(new MathDiagnostic("MATH101", + $"Invalid numeric literal '{invalidText}'.", invalidSpan)); + return new MathToken(MathTokenKind.Invalid, invalidText, + invalidSpan, 0d); + } + } + + var text = sourceText.Substring(start, position - start); + + if (!double.TryParse(text, NumberStyles.Float, + CultureInfo.InvariantCulture, out var number)) + { + var invalidSpan = new SourceSpan(start, position - start); + diagnostics.Add(new MathDiagnostic("MATH101", + $"Invalid numeric literal '{text}'.", invalidSpan)); + return new MathToken(MathTokenKind.Invalid, text, invalidSpan, 0d); + } + + return new MathToken(MathTokenKind.Number, text, + new SourceSpan(start, position - start), number); + } + + private MathToken CreateToken(MathTokenKind kind, int start, int end) => + new(kind, sourceText.Substring(start, end - start), + new SourceSpan(start, end - start), 0d); + } + + private enum MathTokenKind + { + Invalid, + End, + Number, + Identifier, + Plus, + Minus, + Star, + Slash, + Percent, + Caret, + DoubleStar, + Bang, + OpenParenthesis, + CloseParenthesis, + Comma, + ImplicitMultiply + } + + private struct MathToken + { + public MathToken(MathTokenKind kind, string text, SourceSpan span, double number) + { + Kind = kind; + Text = text; + Span = span; + Number = number; + } + + public MathTokenKind Kind { get; } + + public string Text { get; } + + public SourceSpan Span { get; } + + public double Number { get; } + } +} diff --git a/LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs b/LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs new file mode 100644 index 0000000..c160fe4 --- /dev/null +++ b/LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; + +namespace LTRData.MathExpression; + +#pragma warning disable CS1591 + +public sealed class MathSymbolCatalog +{ + private static readonly MathSymbolCatalog standard = CreateStandard(); + + private readonly Dictionary constants; + private readonly Dictionary> functions; + + internal MathSymbolCatalog(Dictionary constants, + Dictionary> functions) + { + this.constants = new Dictionary(constants, + StringComparer.OrdinalIgnoreCase); + this.functions = new Dictionary>( + StringComparer.OrdinalIgnoreCase); + + foreach (var entry in functions) + { + this.functions.Add(entry.Key, + new Dictionary(entry.Value)); + } + } + + public static MathSymbolCatalog Standard => standard; + + public static MathSymbolCatalogBuilder CreateBuilder() => new(); + + public MathSymbolCatalogBuilder ToBuilder() => new(constants, functions); + + internal bool TryGetConstant(string name, out double value) => + constants.TryGetValue(name, out value); + + internal bool TryGetFunction(string name, int arity, + out MathFunctionDefinition? function) + { + if (functions.TryGetValue(name, out var overloads) && + overloads.TryGetValue(arity, out var result)) + { + function = result; + return true; + } + + function = null; + return false; + } + + private static MathSymbolCatalog CreateStandard() + { + return CreateBuilder() + .AddConstant("e", Math.E) + .AddConstant("pi", Math.PI) + .AddFunction("abs", x => Math.Abs(x)) + .AddFunction("acos", x => Math.Acos(x)) + .AddFunction("asin", x => Math.Asin(x)) + .AddFunction("atan", x => Math.Atan(x)) + .AddFunction("atan2", (y, x) => Math.Atan2(y, x)) + .AddFunction("ceiling", x => Math.Ceiling(x)) + .AddFunction("cos", x => Math.Cos(x)) + .AddFunction("cosh", x => Math.Cosh(x)) + .AddFunction("exp", x => Math.Exp(x)) + .AddFunction("floor", x => Math.Floor(x)) + .AddFunction("ln", x => Math.Log(x)) + .AddFunction("log", x => Math.Log(x)) + .AddFunction("log", (x, @base) => Math.Log(x, @base)) + .AddFunction("log10", x => Math.Log10(x)) + .AddFunction("max", (left, right) => Math.Max(left, right)) + .AddFunction("min", (left, right) => Math.Min(left, right)) + .AddFunction("pow", (left, right) => Math.Pow(left, right)) + .AddFunction("round", x => Math.Round(x)) + .AddFunction("sign", x => Math.Sign(x)) + .AddFunction("sin", x => Math.Sin(x)) + .AddFunction("sinh", x => Math.Sinh(x)) + .AddFunction("sqrt", x => Math.Sqrt(x)) + .AddFunction("tan", x => Math.Tan(x)) + .AddFunction("tanh", x => Math.Tanh(x)) + .AddFunction("truncate", x => Math.Truncate(x)) + .Build(); + } +} + +public sealed class MathSymbolCatalogBuilder +{ + private readonly Dictionary constants = + new(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary> functions = + new(StringComparer.OrdinalIgnoreCase); + + public MathSymbolCatalogBuilder() + { + } + + internal MathSymbolCatalogBuilder(Dictionary constants, + Dictionary> functions) + { + foreach (var constant in constants) + { + this.constants.Add(constant.Key, constant.Value); + } + + foreach (var function in functions) + { + this.functions.Add(function.Key, + new Dictionary(function.Value)); + } + } + + public MathSymbolCatalogBuilder AddConstant(string name, double value) + { + ValidateName(name); + + if (constants.ContainsKey(name)) + { + throw new ArgumentException($"A constant named '{name}' is already registered.", + nameof(name)); + } + + constants.Add(name, value); + return this; + } + + public MathSymbolCatalogBuilder AddFunction(string name, Func function) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(function); +#else + if (function is null) + { + throw new ArgumentNullException(nameof(function)); + } +#endif + + AddFunction(name, new MathFunctionDefinition(function)); + return this; + } + + public MathSymbolCatalogBuilder AddFunction(string name, + Func function) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(function); +#else + if (function is null) + { + throw new ArgumentNullException(nameof(function)); + } +#endif + + AddFunction(name, new MathFunctionDefinition(function)); + return this; + } + + public MathSymbolCatalog Build() => new(constants, functions); + + private void AddFunction(string name, MathFunctionDefinition function) + { + ValidateName(name); + + if (!functions.TryGetValue(name, out var overloads)) + { + overloads = new Dictionary(); + functions.Add(name, overloads); + } + + if (overloads.ContainsKey(function.Arity)) + { + throw new ArgumentException( + $"A function named '{name}' with arity {function.Arity} is already registered.", + nameof(name)); + } + + overloads.Add(function.Arity, function); + } + + private static void ValidateName(string name) + { + if (name is null || name.Trim().Length == 0) + { + throw new ArgumentException("A symbol name is required.", nameof(name)); + } + + if (!(char.IsLetter(name[0]) || name[0] == '_')) + { + throw new ArgumentException($"Invalid symbol name '{name}'.", nameof(name)); + } + + for (var index = 1; index < name.Length; index++) + { + if (!(char.IsLetterOrDigit(name[index]) || name[index] == '_')) + { + throw new ArgumentException($"Invalid symbol name '{name}'.", nameof(name)); + } + } + } +} + +internal sealed class MathFunctionDefinition +{ + private readonly Func? unary; + private readonly Func? binary; + + public MathFunctionDefinition(Func unary) + { + this.unary = unary; + Arity = 1; + } + + public MathFunctionDefinition(Func binary) + { + this.binary = binary; + Arity = 2; + } + + public int Arity { get; } + + public double Invoke(double argument) + { + if (unary is null) + { + throw new InvalidOperationException("Function is not unary."); + } + + return unary(argument); + } + + public double Invoke(double left, double right) + { + if (binary is null) + { + throw new InvalidOperationException("Function is not binary."); + } + + return binary(left, right); + } +} diff --git a/LTRData.MathExpression/MathExpression/MathSyntax.cs b/LTRData.MathExpression/MathExpression/MathSyntax.cs new file mode 100644 index 0000000..e6235a6 --- /dev/null +++ b/LTRData.MathExpression/MathExpression/MathSyntax.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace LTRData.MathExpression; + +#pragma warning disable CS1591 + +public struct SourceSpan : IEquatable +{ + public SourceSpan(int start, int length) + { +#if NET8_0_OR_GREATER + ArgumentOutOfRangeException.ThrowIfNegative(start); + ArgumentOutOfRangeException.ThrowIfNegative(length); +#else + if (start < 0) + { + throw new ArgumentOutOfRangeException(nameof(start)); + } + + if (length < 0) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } +#endif + + Start = start; + Length = length; + } + + public int Start { get; } + + public int Length { get; } + + public int End => Start + Length; + + public static SourceSpan FromBounds(int start, int end) + { + if (end < start) + { + throw new ArgumentOutOfRangeException(nameof(end)); + } + + return new SourceSpan(start, end - start); + } + + public bool Equals(SourceSpan other) => Start == other.Start && Length == other.Length; + + public override bool Equals(object? obj) => obj is SourceSpan other && Equals(other); + + public override int GetHashCode() => unchecked((Start * 397) ^ Length); + + public override string ToString() => $"{Start}..{End}"; + + public static bool operator ==(SourceSpan left, SourceSpan right) => left.Equals(right); + + public static bool operator !=(SourceSpan left, SourceSpan right) => !left.Equals(right); +} + +public enum MathSyntaxKind +{ + Number, + Name, + Parenthesized, + Prefix, + Postfix, + Binary, + Call +} + +public enum MathPrefixOperator +{ + Identity, + Negate +} + +public enum MathPostfixOperator +{ + Factorial +} + +public enum MathBinaryOperator +{ + Add, + Subtract, + Multiply, + Divide, + Remainder, + Power +} + +public abstract class MathSyntax +{ + internal MathSyntax(SourceSpan span) + { + Span = span; + } + + public SourceSpan Span { get; } + + public abstract MathSyntaxKind Kind { get; } +} + +public sealed class NumberSyntax : MathSyntax +{ + public NumberSyntax(double value, string lexeme, SourceSpan span) + : base(span) + { + Value = value; +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(lexeme); +#else + if (lexeme is null) throw new ArgumentNullException(nameof(lexeme)); +#endif + Lexeme = lexeme; + } + + public override MathSyntaxKind Kind => MathSyntaxKind.Number; + + public double Value { get; } + + public string Lexeme { get; } +} + +public sealed class NameSyntax : MathSyntax +{ + public NameSyntax(string name, SourceSpan span) + : base(span) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(name); +#else + if (name is null) throw new ArgumentNullException(nameof(name)); +#endif + Name = name; + } + + public override MathSyntaxKind Kind => MathSyntaxKind.Name; + + public string Name { get; } +} + +public sealed class ParenthesizedSyntax : MathSyntax +{ + public ParenthesizedSyntax(MathSyntax expression, SourceSpan span) + : base(span) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(expression); +#else + if (expression is null) throw new ArgumentNullException(nameof(expression)); +#endif + Expression = expression; + } + + public override MathSyntaxKind Kind => MathSyntaxKind.Parenthesized; + + public MathSyntax Expression { get; } +} + +public sealed class PrefixSyntax : MathSyntax +{ + public PrefixSyntax(MathPrefixOperator @operator, SourceSpan operatorSpan, + MathSyntax operand, SourceSpan span) + : base(span) + { + Operator = @operator; + OperatorSpan = operatorSpan; +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(operand); +#else + if (operand is null) throw new ArgumentNullException(nameof(operand)); +#endif + Operand = operand; + } + + public override MathSyntaxKind Kind => MathSyntaxKind.Prefix; + + public MathPrefixOperator Operator { get; } + + public SourceSpan OperatorSpan { get; } + + public MathSyntax Operand { get; } +} + +public sealed class PostfixSyntax : MathSyntax +{ + public PostfixSyntax(MathSyntax operand, MathPostfixOperator @operator, + SourceSpan operatorSpan, SourceSpan span) + : base(span) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(operand); +#else + if (operand is null) throw new ArgumentNullException(nameof(operand)); +#endif + Operand = operand; + Operator = @operator; + OperatorSpan = operatorSpan; + } + + public override MathSyntaxKind Kind => MathSyntaxKind.Postfix; + + public MathSyntax Operand { get; } + + public MathPostfixOperator Operator { get; } + + public SourceSpan OperatorSpan { get; } +} + +public sealed class BinarySyntax : MathSyntax +{ + public BinarySyntax(MathSyntax left, MathBinaryOperator @operator, + SourceSpan operatorSpan, MathSyntax right, bool isImplicit, SourceSpan span) + : base(span) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(left); +#else + if (left is null) throw new ArgumentNullException(nameof(left)); +#endif + Left = left; + Operator = @operator; + OperatorSpan = operatorSpan; +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(right); +#else + if (right is null) throw new ArgumentNullException(nameof(right)); +#endif + Right = right; + IsImplicit = isImplicit; + } + + public override MathSyntaxKind Kind => MathSyntaxKind.Binary; + + public MathSyntax Left { get; } + + public MathBinaryOperator Operator { get; } + + public SourceSpan OperatorSpan { get; } + + public MathSyntax Right { get; } + + public bool IsImplicit { get; } +} + +public sealed class CallSyntax : MathSyntax +{ + public CallSyntax(string name, SourceSpan nameSpan, + IEnumerable arguments, SourceSpan span) + : base(span) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(name); +#else + if (name is null) throw new ArgumentNullException(nameof(name)); +#endif + Name = name; + NameSpan = nameSpan; + +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(arguments); +#else + if (arguments is null) + { + throw new ArgumentNullException(nameof(arguments)); + } +#endif + + Arguments = new List(arguments).AsReadOnly(); + } + + public override MathSyntaxKind Kind => MathSyntaxKind.Call; + + public string Name { get; } + + public SourceSpan NameSpan { get; } + + public ReadOnlyCollection Arguments { get; } +} diff --git a/Library.slnx b/Library.slnx index 12cb74a..03c69d9 100644 --- a/Library.slnx +++ b/Library.slnx @@ -7,8 +7,10 @@ + + @@ -18,4 +20,5 @@ + diff --git a/Rendering.Tests/Rendering.Tests.csproj b/Rendering.Tests/Rendering.Tests.csproj new file mode 100644 index 0000000..3b1a949 --- /dev/null +++ b/Rendering.Tests/Rendering.Tests.csproj @@ -0,0 +1,17 @@ + + + net8.0;net9.0;net10.0 + + bin\$(Configuration)\ + false + true + CS1591 + + + + + + + + + diff --git a/Rendering.Tests/SkiaRenderingTests.cs b/Rendering.Tests/SkiaRenderingTests.cs new file mode 100644 index 0000000..df6e07e --- /dev/null +++ b/Rendering.Tests/SkiaRenderingTests.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +using LTRData.FunctionPlotting; +using LTRData.Graphics.SkiaSharp; +using LTRData.MathExpression; +using LTRData.MathExpression.Diagrams; +using SkiaSharp; +using Xunit; + +namespace LTRData.Rendering.Tests; + +public class SkiaRenderingTests +{ + [Fact] + public void PlotPngContainsTheCurveAndEncoderLeavesInputsOpen() + { + var viewport = new PlotViewport(new NumericRange(-1, 1), new NumericRange(-1, 1), new CanvasSize(200, 100)); + var geometry = CurveGeometryBuilder.Build(FunctionSampler.Sample(x => 0, viewport.XRange, 101), viewport); + using var surface = SKSurface.Create(new SKImageInfo(200, 100)); + surface.Canvas.Clear(SKColors.White); + SkiaPlotRenderer.Render(surface.Canvas, geometry); + using var image = surface.Snapshot(); + using var stream = new MemoryStream(); + SkiaPngEncoder.Encode(image, stream); + Assert.True(stream.CanWrite); + Assert.Equal(200, image.Width); + using var bitmap = SKBitmap.Decode(stream.ToArray()); + Assert.Equal(200, bitmap.Width); + Assert.Equal(100, bitmap.Height); + Assert.NotEqual(SKColors.White, bitmap.GetPixel(100, 50)); + Assert.Equal(SKColors.White, bitmap.GetPixel(100, 10)); + } + + [Fact] + public void InvalidSampleCreatesAVisibleGap() + { + var viewport = new PlotViewport(new NumericRange(-1, 1), new NumericRange(-1, 1), new CanvasSize(200, 100)); + var samples = FunctionSampler.Sample(x => Math.Abs(x) < .2 ? double.NaN : 0, viewport.XRange, 101); + using var surface = SKSurface.Create(new SKImageInfo(200, 100)); + surface.Canvas.Clear(SKColors.White); + SkiaPlotRenderer.Render(surface.Canvas, CurveGeometryBuilder.Build(samples, viewport)); + using var image = surface.Snapshot(); + using var bitmap = SKBitmap.FromImage(image); + Assert.Equal(SKColors.White, bitmap.GetPixel(100, 50)); + Assert.NotEqual(SKColors.White, bitmap.GetPixel(20, 50)); + } + + [Fact] + public void DiagramUsesMeasuredFontAndProducesNonemptyPng() + { + var diagram = ExpressionDiagram.FromSyntax(MathParser.Default.Parse("sin(x) + x^2").Root!); + using var font = new SKFont(SKTypeface.Default, 20); + var sizes = SkiaDiagramRenderer.MeasureNodes(diagram, font); + var layout = TreeDiagramLayout.Create(diagram, sizes); + using var surface = SKSurface.Create(new SKImageInfo((int)Math.Ceiling(layout.Size.Width), (int)Math.Ceiling(layout.Size.Height))); + surface.Canvas.Clear(SKColors.White); + SkiaDiagramRenderer.Render(surface.Canvas, layout, font); + using var image = surface.Snapshot(); + using var stream = new MemoryStream(); + SkiaPngEncoder.Encode(image, stream); + using var bitmap = SKBitmap.Decode(stream.ToArray()); + Assert.Equal(surface.Canvas.DeviceClipBounds.Width, bitmap.Width); + var darkPixels = 0; + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var color = bitmap.GetPixel(x, y); + if (color.Red < 100 && color.Green < 100 && color.Blue < 100) darkPixels++; + } + Assert.True(darkPixels > 30, "Labels must contain visible glyphs, not just node outlines."); + } + + [Fact] + public void OffscreenAxesAreOmitted() + { + using var surface = SKSurface.Create(new SKImageInfo(100, 100)); + surface.Canvas.Clear(SKColors.White); + SkiaPlotRenderer.RenderAxes(surface.Canvas, + new PlotViewport(new NumericRange(1, 2), new NumericRange(1, 2), new CanvasSize(100, 100))); + using var image = surface.Snapshot(); + using var bitmap = SKBitmap.FromImage(image); + for (var y = 0; y < 100; y++) + for (var x = 0; x < 100; x++) Assert.Equal(SKColors.White, bitmap.GetPixel(x, y)); + } +} diff --git a/Tests/CurveGeometryPrecisionTests.cs b/Tests/CurveGeometryPrecisionTests.cs new file mode 100644 index 0000000..aeb9d7d --- /dev/null +++ b/Tests/CurveGeometryPrecisionTests.cs @@ -0,0 +1,39 @@ +using System; +using LTRData.FunctionPlotting; +using Xunit; + +namespace LTRData.Extensions.Tests; + +public class CurveGeometryPrecisionTests +{ + [Fact] + public void OppositeLargeFiniteValuesClipToFiniteBoundaryCoordinates() + { + var viewport = new PlotViewport(new NumericRange(-1, 1), new NumericRange(-1, 1), new CanvasSize(200, 100)); + var series = new SampleSeries(new[] + { + new FunctionSample(-1, -1e308, FunctionSampleStatus.Finite), + new FunctionSample(1, 1e308, FunctionSampleStatus.Finite) + }); + var line = Assert.Single(CurveGeometryBuilder.Build(series, viewport).Polylines); + Assert.Equal(100d, line.Points[0].Y); + Assert.Equal(0d, line.Points[line.Points.Count - 1].Y); + foreach (var point in line.Points) + { + Assert.InRange(point.X, 0, 200); + Assert.InRange(point.Y, 0, 100); + } + } + + [Fact] + public void LargeFiniteViewportTransformsDoNotOverflowIntermediateProducts() + { + var viewport = new PlotViewport(new NumericRange(0, 1e308), new NumericRange(0, 1e308), new CanvasSize(200, 100)); + var point = CartesianTransform.ToCanvas(5e307, 5e307, viewport); + Assert.Equal(100d, point.X); + Assert.Equal(50d, point.Y); + var data = CartesianTransform.ToData(100, 50, viewport); + Assert.Equal(5e307, data.X); + Assert.Equal(5e307, data.Y); + } +} diff --git a/Tests/ExpressionDiagramTests.cs b/Tests/ExpressionDiagramTests.cs new file mode 100644 index 0000000..0e55122 --- /dev/null +++ b/Tests/ExpressionDiagramTests.cs @@ -0,0 +1,96 @@ +using LTRData.MathExpression; +using LTRData.MathExpression.Diagrams; +using System; +using System.Linq; +using Xunit; + +namespace LTRData.Extensions.Tests; + +public class ExpressionDiagramTests +{ + [Fact] + public void DiagramPreservesOperandOrderSpellingAndSourceLocations() + { + var diagram = Diagram("atan2(X, .50) - 2x"); + Assert.Equal(new[] { "−", "atan2", "X", ".50", "×", "2", "x" }, + diagram.Nodes.Select(n => n.Label)); + Assert.Equal(new[] { "left", "right" }, diagram.Nodes[0].Children.Select(c => c.Role)); + Assert.Equal(new[] { "argument 1", "argument 2" }, diagram.Nodes[1].Children.Select(c => c.Role)); + Assert.Equal(new SourceSpan(6, 1), diagram.Nodes[2].Span); + Assert.Equal(Enumerable.Range(0, diagram.Nodes.Count), diagram.Nodes.Select(n => n.Id)); + } + + [Fact] + public void ParenthesesAreAnExplicitPresentationChoice() + { + var syntax = MathParser.Default.Parse("((x))").Root!; + Assert.Single(ExpressionDiagram.FromSyntax(syntax).Nodes); + Assert.Equal(new[] { "( )", "( )", "x" }, + ExpressionDiagram.FromSyntax(syntax, true).Nodes.Select(n => n.Label)); + Assert.Throws(() => ExpressionDiagram.FromSyntax(syntax, maxNodes: 2)); + } + + [Fact] + public void LayoutRespectsMeasuredSizesAndKeepsUnbalancedSubtreesApart() + { + var diagram = Diagram("long_variable + sin(x^2 + max(y, 100))"); + var sizes = diagram.Nodes.Select((n, i) => new DiagramSize(30 + n.Label.Length * 12, 30 + i % 3 * 10)).ToArray(); + var layout = TreeDiagramLayout.Create(diagram, sizes); + Assert.Equal(diagram.Nodes.Count - 1, layout.Connectors.Count); + foreach (var node in layout.Nodes) + { + Assert.Equal(sizes[node.Node.Id].Width, node.Size.Width); + Assert.InRange(node.X, 0, layout.Size.Width - node.Size.Width); + Assert.InRange(node.Y, 0, layout.Size.Height - node.Size.Height); + foreach (var other in layout.Nodes.Where(n => n.Node.Id > node.Node.Id)) + Assert.False(node.X < other.X + other.Size.Width && other.X < node.X + node.Size.Width && + node.Y < other.Y + other.Size.Height && other.Y < node.Y + node.Size.Height); + var childNodes = node.Node.Children.Select(c => layout.Nodes[c.NodeId]).ToArray(); + for (var i = 1; i < childNodes.Length; i++) Assert.True(childNodes[i - 1].X < childNodes[i].X); + } + foreach (var connector in layout.Connectors) + { + var parent = layout.Nodes[connector.ParentId]; + var child = layout.Nodes[connector.Child.NodeId]; + Assert.Equal(parent.Y + parent.Size.Height, connector.Start.Y); + Assert.Equal(child.Y, connector.End.Y); + Assert.True(connector.End.Y > connector.Start.Y); + } + sizes[0] = new DiagramSize(1, 1); + Assert.NotEqual(1, layout.Nodes[0].Size.Width); + } + + [Fact] + public void SingleNodeExtentIncludesMargins() + { + var layout = TreeDiagramLayout.Create(Diagram("x"), new[] { new DiagramSize(40, 30) }, margin: 10); + Assert.Equal(60, layout.Size.Width); + Assert.Equal(50, layout.Size.Height); + Assert.Empty(layout.Connectors); + } + + [Fact] + public void DeepManualSyntaxDoesNotRequireRecursiveTraversalOrLayout() + { + MathSyntax syntax = new NameSyntax("x", new SourceSpan(0, 1)); + for (var i = 0; i < 2000; i++) + syntax = new PrefixSyntax(MathPrefixOperator.Negate, new SourceSpan(0, 1), syntax, syntax.Span); + var diagram = ExpressionDiagram.FromSyntax(syntax); + var layout = TreeDiagramLayout.Create(diagram, Enumerable.Repeat(new DiagramSize(20, 20), diagram.Nodes.Count).ToArray()); + Assert.Equal(2001, layout.Nodes.Count); + Assert.True(layout.Size.Height > 40000); + Assert.Throws(() => ExpressionDiagram.FromSyntax(syntax, maxNodes: 2000)); + } + + [Fact] + public void InvalidMeasurementsAndOverflowAreRejectedBeforeRendering() + { + var diagram = Diagram("x+x"); + Assert.Throws(() => TreeDiagramLayout.Create(diagram, new DiagramSize[3])); + Assert.Throws(() => TreeDiagramLayout.Create(diagram, new[] { new DiagramSize(1, 1) })); + Assert.Throws(() => TreeDiagramLayout.Create(diagram, + Enumerable.Repeat(new DiagramSize(double.MaxValue, 20), 3).ToArray())); + } + + private static ExpressionDiagram Diagram(string source) => ExpressionDiagram.FromSyntax(MathParser.Default.Parse(source).Root!); +} diff --git a/Tests/FunctionPlottingTests.cs b/Tests/FunctionPlottingTests.cs new file mode 100644 index 0000000..8d1510d --- /dev/null +++ b/Tests/FunctionPlottingTests.cs @@ -0,0 +1,141 @@ +using LTRData.FunctionPlotting; +using LTRData.MathExpression; +using System; +using Xunit; + +namespace LTRData.Extensions.Tests; + +[Trait("Contract", "Modern")] +public class FunctionPlottingTests +{ + [Fact] + public void FixedCountSamplingIncludesBothDomainEndpoints() + { + var samples = FunctionSampler.Sample(x => x * 2d, + new NumericRange(-1d, 1d), 5); + + Assert.Equal(5, samples.Count); + Assert.Equal(-1d, samples[0].X); + Assert.Equal(-0.5d, samples[1].X); + Assert.Equal(0d, samples[2].X); + Assert.Equal(0.5d, samples[3].X); + Assert.Equal(1d, samples[4].X); + Assert.Equal(2d, samples[4].Y); + } + + [Fact] + public void SamplingClassifiesEveryNonFiniteAndErrorState() + { + var samples = FunctionSampler.Sample(x => + { + if (x == -2d) + { + return double.NaN; + } + + if (x == -1d) + { + return double.NegativeInfinity; + } + + if (x == 0d) + { + throw new ArithmeticException("Deliberate sample failure."); + } + + if (x == 1d) + { + return double.PositiveInfinity; + } + + return x; + }, new NumericRange(-2d, 2d), 5); + + Assert.Equal(FunctionSampleStatus.NotANumber, samples[0].Status); + Assert.Equal(FunctionSampleStatus.NegativeInfinity, samples[1].Status); + Assert.Equal(FunctionSampleStatus.EvaluationError, samples[2].Status); + Assert.Equal(FunctionSampleStatus.PositiveInfinity, samples[3].Status); + Assert.Equal(FunctionSampleStatus.Finite, samples[4].Status); + } + + [Fact] + public void InvalidSamplesSplitCurveGeometry() + { + var samples = new SampleSeries(new[] + { + new FunctionSample(-2d, -2d, FunctionSampleStatus.Finite), + new FunctionSample(-1d, -1d, FunctionSampleStatus.Finite), + new FunctionSample(0d, double.NaN, FunctionSampleStatus.NotANumber), + new FunctionSample(1d, 1d, FunctionSampleStatus.Finite), + new FunctionSample(2d, 2d, FunctionSampleStatus.Finite) + }); + + var geometry = CurveGeometryBuilder.Build(samples, + new PlotViewport(new NumericRange(-2d, 2d), + new NumericRange(-2d, 2d), new CanvasSize(400d, 200d))); + + Assert.Equal(2, geometry.Polylines.Count); + Assert.Equal(2, geometry.Polylines[0].Points.Count); + Assert.Equal(2, geometry.Polylines[1].Points.Count); + } + + [Fact] + public void ClippingKeepsCrossingSegmentWithBothEndpointsOutside() + { + var samples = new SampleSeries(new[] + { + new FunctionSample(-1d, -2d, FunctionSampleStatus.Finite), + new FunctionSample(1d, 2d, FunctionSampleStatus.Finite) + }); + + var geometry = CurveGeometryBuilder.Build(samples, + new PlotViewport(new NumericRange(-1d, 1d), + new NumericRange(-1d, 1d), new CanvasSize(200d, 100d))); + + var line = Assert.Single(geometry.Polylines); + Assert.Equal(2, line.Points.Count); + Assert.Equal(50d, line.Points[0].X, 12); + Assert.Equal(100d, line.Points[0].Y, 12); + Assert.Equal(150d, line.Points[1].X, 12); + Assert.Equal(0d, line.Points[1].Y, 12); + } + + [Fact] + public void CartesianTransformRoundTripsContinuousCoordinates() + { + var viewport = new PlotViewport(new NumericRange(-10d, 10d), + new NumericRange(-4d, 4d), new CanvasSize(1200d, 720d)); + + var canvas = CartesianTransform.ToCanvas(2.5d, -1.25d, viewport); + var data = CartesianTransform.ToData(canvas.X, canvas.Y, viewport); + + Assert.Equal(2.5d, data.X, 12); + Assert.Equal(-1.25d, data.Y, 12); + } + + [Fact] + public void ExpressionEvaluationComposesWithParserIndependentSampler() + { + var parse = MathParser.Default.Parse("sin(x)"); + Assert.True(parse.Success); + + var binding = MathBinder.Bind(parse.Root!, MathSymbolCatalog.Standard); + Assert.True(binding.Success); + + var function = binding.Expression!.BindUnary("x"); + var samples = FunctionSampler.Sample(function.Evaluate, + new NumericRange(0d, Math.PI), 3); + + Assert.Equal(0d, samples[0].Y, 12); + Assert.Equal(1d, samples[1].Y, 12); + Assert.Equal(0d, samples[2].Y, 12); + } + + [Fact] + public void InvalidRangesAndSampleCountsAreRejected() + { + Assert.Throws(() => new NumericRange(1d, 1d)); + Assert.Throws(() => + FunctionSampler.Sample(x => x, new NumericRange(0d, 1d), 1)); + } +} diff --git a/Tests/LegacyMathExpressionCharacterizationTests.cs b/Tests/LegacyMathExpressionCharacterizationTests.cs new file mode 100644 index 0000000..aa55cda --- /dev/null +++ b/Tests/LegacyMathExpressionCharacterizationTests.cs @@ -0,0 +1,48 @@ +using LTRData.MathExpression; +using System; +using System.Globalization; +using Xunit; + +namespace LTRData.Extensions.Tests; + +/// +/// Documents selected behavior of the historical parser. These are evidence for migration, +/// not normative tests for . +/// +[Trait("Contract", "LegacyCharacterization")] +public class LegacyMathExpressionCharacterizationTests +{ + private readonly MathExpressionParser parser = new(CultureInfo.InvariantCulture); + + [Fact] + public void PowerAndMultiplicationCurrentlyGroupLeftToRightAtOnePrecedence() + { + var expression = parser.ParseExpression>("2*3^2"); + + Assert.Equal(36d, expression()); + } + + [Fact] + public void RepeatedPowerIsCurrentlyLeftAssociative() + { + var expression = parser.ParseExpression>("2^3^2"); + + Assert.Equal(64d, expression()); + } + + [Fact] + public void EmptyInputCurrentlyBecomesZero() + { + var expression = parser.ParseExpression>(string.Empty); + + Assert.Equal(0d, expression()); + } + + [Fact] + public void ScriptControlTreatsYAsASecondCallerSuppliedVariable() + { + var control = new ScriptControl(parser) { Expression = "x+y" }; + + Assert.Equal((double?)5d, control.Eval(2d, 3d)); + } +} diff --git a/Tests/ModernExpressionCorpusTests.cs b/Tests/ModernExpressionCorpusTests.cs new file mode 100644 index 0000000..38eabd2 --- /dev/null +++ b/Tests/ModernExpressionCorpusTests.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using LTRData.MathExpression; +using Xunit; + +namespace LTRData.Extensions.Tests; + +/// The applicable expressions from the legacy tests, through the modern pipeline. +public class ModernExpressionCorpusTests +{ + public static IEnumerable Expressions => new[] + { + new object[] { "sin(0.4) * 2", Math.Sin(.4) * 2 }, + new object[] { "atan2(312,2)", Math.Atan2(312, 2) }, + new object[] { "e ** 2", Math.Pow(Math.E, 2) }, + new object[] { "169 - 5 - 3 - 1", 160d }, + new object[] { "169 - (5 - 3 - 1)", 168d }, + new object[] { "169 (5 - 3 - 1)", 169d }, + new object[] { "169 - 5 * 3 - 1", 153d }, + new object[] { "169 - 5 - 3 * 1", 161d }, + new object[] { "169 * 5 - 3 - 1", 841d }, + new object[] { "2.5 + 400 / (.1 - .01) * 2", 2.5 + 400 / (.1 - .01) * 2 }, + new object[] { "-35-(-35)", 0d }, + new object[] { "-(35-(-35))", -70d }, + new object[] { "-(35)-(-35)", 0d }, + new object[] { "--35", 35d }, + new object[] { "+35", 35d }, + new object[] { "+-35", -35d }, + new object[] { "-+35", -35d }, + new object[] { "+-+35", -35d } + }; + + [Theory] + [MemberData(nameof(Expressions))] + public void ExistingMathematicalExpressionsKeepTheirExpectedValues(string source, double expected) + { + var parsed = MathParser.Default.Parse(source); + Assert.True(parsed.Success); + var binding = MathBinder.Bind(parsed.Root!, MathSymbolCatalog.Standard); + Assert.True(binding.Success); + Assert.Equal(expected, binding.Expression!.Evaluate(), 12); + } +} diff --git a/Tests/ModernMathExpressionTests.cs b/Tests/ModernMathExpressionTests.cs new file mode 100644 index 0000000..3ff9ea3 --- /dev/null +++ b/Tests/ModernMathExpressionTests.cs @@ -0,0 +1,187 @@ +using LTRData.MathExpression; +using System; +using System.Collections.Generic; +using Xunit; + +namespace LTRData.Extensions.Tests; + +[Trait("Contract", "Modern")] +public class ModernMathExpressionTests +{ + [Fact] + public void ParserPreservesSourceTextAndNodeSpan() + { + const string source = " SIN(x) "; + + var parse = MathParser.Default.Parse(source); + + Assert.True(parse.Success, FormatDiagnostics(parse.Diagnostics)); + Assert.Equal(source, parse.SourceText); + Assert.Equal(new SourceSpan(1, 6), parse.Root!.Span); + + var call = Assert.IsType(parse.Root); + Assert.Equal("SIN", call.Name); + Assert.Equal(new SourceSpan(1, 3), call.NameSpan); + } + + [Theory] + [InlineData("2*3^2", 18d)] + [InlineData("2^3^2", 512d)] + [InlineData("-2^2", -4d)] + [InlineData("2^-2", 0.25d)] + [InlineData("20 mod 6", 2d)] + [InlineData("2**3", 8d)] + public void ModernOperatorRulesAreExplicit(string source, double expected) + { + Assert.Equal(expected, Bind(source).Evaluate()); + } + + [Fact] + public void ScientificNotationWithSignedExponentsIsOneNumberToken() + { + var expression = Bind("1e-3 + 2.5E+2"); + + Assert.Equal(250.001d, expression.Evaluate(), 12); + } + + [Fact] + public void ImplicitMultiplicationUsesOrdinaryMultiplicationPrecedence() + { + var expression = Bind("2x + 3(x+1)"); + var function = expression.BindUnary("x"); + + Assert.Equal(13d, function.Evaluate(2d)); + } + + [Fact] + public void VariablesUseCaseInsensitiveStableFirstOccurrenceSlots() + { + var expression = Bind("b + A*b + a"); + + Assert.Equal(2, expression.Variables.Count); + Assert.Equal("b", expression.Variables[0].Name); + Assert.Equal(0, expression.Variables[0].Slot); + Assert.Equal("A", expression.Variables[1].Name); + Assert.Equal(1, expression.Variables[1].Slot); + Assert.Equal(11d, expression.Evaluate(2d, 3d)); + Assert.Equal(11d, expression.Evaluate(new Dictionary + { + ["B"] = 2d, + ["a"] = 3d + })); + } + + [Fact] + public void StandardSymbolsAreCaseInsensitiveAndExplicit() + { + var expression = Bind("SIN(PI/2) + log(e)"); + + Assert.Equal(2d, expression.Evaluate(), 12); + } + + [Fact] + public void StandardCatalogCanBeExtendedWithoutReflection() + { + var symbols = MathSymbolCatalog.Standard.ToBuilder() + .AddConstant("g", 9.80665d) + .AddFunction("square", x => x * x) + .Build(); + + var expression = Bind("square(g)", symbols); + + Assert.Equal(9.80665d * 9.80665d, expression.Evaluate(), 12); + } + + [Fact] + public void UnknownFunctionIsABindingDiagnosticNotAVariable() + { + var parse = MathParser.Default.Parse("missing(2)"); + Assert.True(parse.Success, FormatDiagnostics(parse.Diagnostics)); + + var binding = MathBinder.Bind(parse.Root!, MathSymbolCatalog.Standard); + + Assert.False(binding.Success); + var diagnostic = Assert.Single(binding.Diagnostics); + Assert.Equal("MATH300", diagnostic.Code); + Assert.Equal(new SourceSpan(0, 7), diagnostic.Span); + } + + [Theory] + [InlineData(0d, 1d)] + [InlineData(1d, 1d)] + [InlineData(5d, 120d)] + public void FactorialHasSpecifiedIntegerSemantics(double value, double expected) + { + var expression = Bind(value.ToString(System.Globalization.CultureInfo.InvariantCulture) + "!"); + + Assert.Equal(expected, expression.Evaluate()); + } + + [Fact] + public void InvalidFactorialInputProducesNotANumber() + { + Assert.True(double.IsNaN(Bind("(-1)!").Evaluate())); + Assert.True(double.IsNaN(Bind("2.5!").Evaluate())); + Assert.Equal(double.PositiveInfinity, Bind("171!").Evaluate()); + } + + [Fact] + public void EmptyInputIsAParseDiagnostic() + { + var parse = MathParser.Default.Parse(string.Empty); + + Assert.False(parse.Success); + var diagnostic = Assert.Single(parse.Diagnostics); + Assert.Equal("MATH200", diagnostic.Code); + Assert.Equal(new SourceSpan(0, 0), diagnostic.Span); + } + + [Fact] + public void BitwiseOperatorsAreNotPartOfTheModernLanguage() + { + var parse = MathParser.Default.Parse("1 << 10"); + + Assert.False(parse.Success); + Assert.Contains(parse.Diagnostics, diagnostic => diagnostic.Code == "MATH100"); + } + + [Fact] + public void TextualOperatorSubstringsRemainPartOfIdentifiers() + { + var expression = Bind("model + power_level + negative"); + + Assert.Equal(6d, expression.Evaluate(1d, 2d, 3d)); + } + + [Fact] + public void UnaryBindingRejectsOtherVariables() + { + var expression = Bind("x+y"); + + Assert.Throws(() => expression.BindUnary("x")); + } + + [Fact] + public void ConstantExpressionCanBeUsedAsAUnaryFunction() + { + var function = Bind("pi").BindUnary("x"); + + Assert.Equal(Math.PI, function.Evaluate(123d)); + } + + private static BoundMathExpression Bind(string source, + MathSymbolCatalog? symbols = null) + { + var parse = MathParser.Default.Parse(source); + Assert.True(parse.Success, FormatDiagnostics(parse.Diagnostics)); + + var binding = MathBinder.Bind(parse.Root!, + symbols ?? MathSymbolCatalog.Standard); + Assert.True(binding.Success, FormatDiagnostics(binding.Diagnostics)); + + return binding.Expression!; + } + + private static string FormatDiagnostics(IEnumerable diagnostics) => + string.Join(Environment.NewLine, diagnostics); +} diff --git a/Tests/SampleCalculusTests.cs b/Tests/SampleCalculusTests.cs new file mode 100644 index 0000000..c1f494c --- /dev/null +++ b/Tests/SampleCalculusTests.cs @@ -0,0 +1,106 @@ +using LTRData.FunctionPlotting; +using System; +using System.Linq; +using Xunit; + +namespace LTRData.Extensions.Tests; + +[Trait("Contract", "Modern")] +public class SampleCalculusTests +{ + [Fact] + public void QuadraticDerivativeIncludesEndpointsAndUnequalSpacing() + { + var samples = Series((-2, 4), (-1.5, 2.25), (0, 0), (3, 9), (4, 16)); + var derivative = SampleCalculus.Differentiate(samples); + for (var i = 0; i < samples.Count; i++) + { + Assert.Equal(samples[i].X, derivative[i].X); + Assert.Equal(2 * samples[i].X, derivative[i].Y, 12); + Assert.Equal(FunctionSampleStatus.Finite, derivative[i].Status); + } + } + + [Fact] + public void SineDerivativeApproximatesCosineAcrossDomain() + { + var samples = FunctionSampler.Sample(Math.Sin, new NumericRange(-Math.PI, Math.PI), 1001); + var derivative = SampleCalculus.Differentiate(samples); + foreach (var point in derivative.Samples) + Assert.InRange(Math.Abs(point.Y - Math.Cos(point.X)), 0, 0.00002); + } + + [Fact] + public void DerivativeDoesNotBridgeGapsAndHandlesShortRuns() + { + var derivative = SampleCalculus.Differentiate(Series((0, 0), (1, 2), (2, double.NaN), + (3, 100), (4, 101), (5, double.NaN), (6, 900))); + Assert.Equal(2, derivative[0].Y); + Assert.Equal(2, derivative[1].Y); + Assert.Equal(FunctionSampleStatus.NotANumber, derivative[2].Status); + Assert.Equal(1, derivative[3].Y); + Assert.Equal(1, derivative[4].Y); + Assert.Equal(FunctionSampleStatus.NotANumber, derivative[6].Status); + } + + [Fact] + public void IntegralUsesSignedTrapezoidsAndExplicitInitialValue() + { + var integral = SampleCalculus.IntegrateFiniteRuns(Series((-2, -2), (-1, -1), (1, 1), (3, 3)), 7); + Assert.Equal(new[] { 7d, 5.5d, 5.5d, 9.5d }, integral.Samples.Select(p => p.Y)); + } + + [Fact] + public void IntegralRestartsAtEachFiniteRunAndDoesNotWrap() + { + var integral = SampleCalculus.IntegrateFiniteRuns(Series((0, 100), (1, 100), + (2, double.NaN), (3, 100), (4, 100), (5, 100))); + Assert.Equal(0, integral[0].Y); + Assert.Equal(100, integral[1].Y); + Assert.Equal(FunctionSampleStatus.NotANumber, integral[2].Status); + Assert.Equal(0, integral[3].Y); + Assert.Equal(100, integral[4].Y); + Assert.Equal(200, integral[5].Y); + } + + [Fact] + public void LargeFiniteEndpointAverageDoesNotOverflowUnnecessarily() + { + var integral = SampleCalculus.IntegrateFiniteRuns(Series((0, 1e308), (0.5, 1e308))); + Assert.Equal(5e307, integral[1].Y); + Assert.Equal(FunctionSampleStatus.Finite, integral[1].Status); + var derivative = SampleCalculus.Differentiate(Series((0, -1e308), (2, 1e308))); + Assert.Equal(1e308, derivative[0].Y); + } + + [Fact] + public void IntegralOverflowIsNotReinterpretedAsNewInitialValue() + { + var integral = SampleCalculus.IntegrateFiniteRuns(Series((0, 1e308), (2, 1e308), (3, 0))); + Assert.Equal(FunctionSampleStatus.PositiveInfinity, integral[1].Status); + Assert.Equal(FunctionSampleStatus.PositiveInfinity, integral[2].Status); + } + + [Fact] + public void EvaluationErrorsAndEmptySeriesRemainExplicit() + { + var series = new SampleSeries(new[] { new FunctionSample(0, double.NaN, FunctionSampleStatus.EvaluationError) }); + Assert.Equal(FunctionSampleStatus.EvaluationError, SampleCalculus.Differentiate(series)[0].Status); + Assert.Equal(FunctionSampleStatus.EvaluationError, SampleCalculus.IntegrateFiniteRuns(series)[0].Status); + Assert.Equal(0, SampleCalculus.Differentiate(new SampleSeries(new FunctionSample[0])).Count); + } + + [Fact] + public void InvalidSampleCoordinatesAreRejected() + { + foreach (var series in new[] { Series((0, 1), (0, 2)), Series((1, 1), (0, 2)), Series((double.NaN, 1)) }) + { + Assert.Throws(() => SampleCalculus.Differentiate(series)); + Assert.Throws(() => SampleCalculus.IntegrateFiniteRuns(series)); + } + Assert.Throws(() => SampleCalculus.IntegrateFiniteRuns(Series((0, 1)), double.NaN)); + } + + private static SampleSeries Series(params (double X, double Y)[] values) => new(values.Select(p => + new FunctionSample(p.X, p.Y, double.IsNaN(p.Y) ? FunctionSampleStatus.NotANumber : FunctionSampleStatus.Finite))); +} diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 71803a6..9672e30 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -37,6 +37,7 @@ + diff --git a/docs/local-package-workflow.md b/docs/local-package-workflow.md new file mode 100644 index 0000000..bf5d0a2 --- /dev/null +++ b/docs/local-package-workflow.md @@ -0,0 +1,156 @@ +# Build and review a chain through local NuGet packages + +Use the same package boundary during local development and after publication. +Producer repositories build packages into a shared directory through +`$(LocalNuGetPath)`. Consumer repositories +restore their `PackageReference` dependencies from that directory through a local +`NuGet.Config`. NuGet selects framework assets and resolves transitive dependencies +and version constraints at every repository boundary. + +Keep `ProjectReference` relationships within a repository. Building a producer +solution in Release packs its projects; a consumer then builds against those +packages. Building just one project does not replace packing each required +dependency package. + +## Configure the shared directory + +`Library` and `ltrwebdb` already honor `LocalNuGetPath`. For example, set it in the +build environment so MSBuild imports it and NuGet can expand it in configuration: + +```powershell +$env:LocalNuGetPath = 'D:\NuGetLocal' +``` + +Or in a Unix shell: + +```sh +export LocalNuGetPath=/absolute/path/to/local-nuget +``` + +Add that directory to each repository's existing local `NuGet.Config`. A minimal +standalone configuration is: + +```xml + + + + + + + + +``` + +NuGet uses `%LocalNuGetPath%` for environment expansion on Windows and Unix. +A literal absolute directory works too. Setting only the MSBuild command-line +property `-p:LocalNuGetPath=...` does not create an environment variable for NuGet; +in that case put the same literal directory in the config. Keep machine-specific +configuration local and retain any other feeds the repository needs. + +## Build producers before consumers + +For the graph website, the order is `Library`, `ltrwebdb`, then `ltr-data.se`. +The checkouts can live anywhere; each repository uses its own project and package +references. Run these commands from the indicated repository root with the shared +directory configured: + +```sh +# Library: Release builds produce the packages for its projects. +dotnet build Library.slnx -c Release + +# ltrwebdb: produce both packages, restoring Library dependencies from the feed. +dotnet build LTR.WebDb.Entity/LTR.WebDb.Entity.csproj -c Release +dotnet build webdbcore/webdbcore.csproj -c Release + +# ltr-data.se: restore and build against the package chain. +dotnet restore coreweb/coreweb.csproj --force-evaluate +dotnet build coreweb/coreweb.csproj -c Release --no-restore +``` + +Release builds already enable `GeneratePackageOnBuild` in the producer projects. +If a build explicitly disables it, follow that build with `dotnet pack --no-build` +for each producer project, using the same configuration and framework properties. +The output still uses `LocalNuGetPath`. + +The current review uses `LTRData.MathExpression` 1.1.0, +`LTRData.FunctionPlotting` 1.2.0 and `LTRData.Graphics.SkiaSharp` 1.1.0. +Keep earlier package versions available for branches that still reference them. +Their other Library dependencies must also be in the feed. +`ltrwebdb` produces `LTR.WebDb.Entity` and `webdbcore`, currently at 1.0.4. +The website's renderer and database references remain ordinary package references. + +`GraphViewer.Review` also consumes the renderer package. After restoring from the +same configured feed, its UI and HTTP checks exercise the packaged parser, +sampling, layout, renderers, and native assets. See the companion website's +`docs/math-rendering-review.md` for commands. + +For netexpr and Windows GraphViewer the chain is shorter: build +`LTRData.Extensions`, `LTRData.MathExpression`, and `LTRData.FunctionPlotting` in +Library, then build `netexpr/netexpr.vbproj` in MathTools or +`GraphViewer/GraphViewer.vbproj` in WindowsTools. No database or SkiaSharp packages +are needed. Each consumer branch has a focused review executable and CI workflow; +see [consumer migration](math-expression-redesign/consumer-migration.md). + +## Verify the packages you actually built + +Package source order does not establish priority for `PackageReference` restores. +For a final local-chain check, use package source mappings to select the local +producer packages, together with a fresh dedicated package cache. For this chain, +add the following section alongside `packageSources` in the local config: + +```xml + + + + + + + + + + + +``` + +These mappings apply to this chain; map only the packages being built locally. +Set `NUGET_PACKAGES` to a fresh directory for the validation run, or use a dedicated +`globalPackagesFolder` in the local config. A package already extracted into the +global cache can be reused without consulting feeds or source mappings. +`--force-evaluate` refreshes dependency resolution; it does not evict cached +package contents. Prefer new package versions for successive builds. When +intentionally replacing the same local version, remove only that version from the +dedicated validation cache before restoring again. + +Check `obj/project.assets.json` for package entries, selected framework assets, +versions, and transitive dependencies. The restored package's `.nupkg.metadata` +records its source. The consumer should reference packages throughout this chain. + +The entity package includes its generated XML serializer DLL for each target. +The ltrwebdb review checks these assets after package consumption and publication +to a local directory. Local packing and validation do not publish to NuGet servers; +publication remains a later release step. + +## Verified graph website chain + +The Linux validation built and packed the 11 Library projects required by the +website, followed by both `ltrwebdb` projects. Each of the 13 packages contained +.NET 9 and .NET 10 assemblies and dependency groups. This was a focused build for +the two website targets, not a local validation of every Library project or TFM. + +Both website hosts restored with source mappings and a fresh dedicated package +cache. All local package metadata identified the shared output directory as the +source. The full site's resolved graph contained all 13 producer packages, with +matching `lib/net9.0` and `lib/net10.0` assets and no project dependencies. Public +dependencies were restored from an offline mirror of NuGet.org packages. + +`coreweb` built for both targets and `GraphViewer.Review` built for .NET 10 with +zero warnings or errors. The review host's HTTP checks passed against the packaged +assemblies, including both native PNG paths, dimensions, diagnostics, formula +limits, cache reuse, gaps and the concurrency limit. That initial run retained the +database's XML serializer warnings. The subsequent +[release checkpoint](math-expression-redesign/release-checkpoint.md) fixes generation +and verifies all 28 generated entity serializers from the package. + +References: [NuGet configuration](https://learn.microsoft.com/en-us/nuget/reference/nuget-config-file), +[package source mapping](https://learn.microsoft.com/en-us/nuget/consume-packages/package-source-mapping), +and [package caches](https://learn.microsoft.com/en-us/nuget/consume-packages/managing-the-global-packages-and-cache-folders). diff --git a/docs/math-expression-redesign/README.md b/docs/math-expression-redesign/README.md new file mode 100644 index 0000000..5ca86af --- /dev/null +++ b/docs/math-expression-redesign/README.md @@ -0,0 +1,94 @@ +# Math expression and function plotting redesign + +This directory records the decisions behind the experimental replacement APIs on +`experimental/math-expression-redesign`. + +The historical implementation is evidence, not the specification. Observed behavior is +classified before it becomes a test or a public contract: + +- **A — desired modern behavior:** normative for the new API. +- **B — legacy compatibility behavior:** relevant only to explicit compatibility code. +- **C — historical implementation artifact or bug:** intentionally not preserved. + +## Documents + +- [First-party usage corpus](usage-corpus.md) +- [Legacy behavior classification](legacy-behavior-classification.md) +- [Modern expression language specification](language-specification.md) +- [Architecture, package boundaries, and proposed APIs](architecture.md) +- [Implemented rendering slice, samples, deployment and review](rendering-slice.md) +- [netexpr and Windows GraphViewer migration](consumer-migration.md) +- [Build and review the repositories through a shared local NuGet feed](../local-package-workflow.md) +- [Stable package and final review checkpoint](release-checkpoint.md) + +## Original core slice + +The branch validates only these boundaries: + +- an immutable math-specific syntax model with source spans; +- a real lexer and parser with diagnostics; +- an explicit immutable symbol catalog; +- binding to constants, functions, and arbitrary variable slots; +- interpreted evaluation without runtime code generation; +- convenient allocation-free unary evaluation for plotting; +- parser-independent fixed-count function sampling; +- explicit sample status for non-finite values and evaluation failures; +- backend-neutral Cartesian polyline geometry with viewport clipping. + +That original slice did not implement rendering. The subsequent accepted rendering +slice adds portable diagrams/layout, one SkiaSharp edge package and the companion +website PNG integration. The consumer migration now adds numerical sample calculus +and moves netexpr and Windows GraphViewer to the modern APIs. Recurrence, adaptive +sampling and LINQ-expression compilation remain deferred. + +The existing `MathExpressionParser`, `IMathExpressionParser`, `MathFunctions`, and +`ScriptControl` remain temporarily for other legacy consumers. The migrated +applications use the modern APIs; the legacy types do not define the new language. + +## Decisions made for the experiment + +- The existing package name `LTRData.MathExpression` remains. It is a reasonable domain + name and changing it would not isolate a dependency. +- Function plotting is a separate package because it is independently useful, must not + reference the parser, and lets arbitrary numerical callers avoid the expression package + and its current `LTRData.Extensions` dependency. +- Expression-diagram content and layout are planned inside `LTRData.MathExpression` unless + a concrete dependency or independent-consumption reason emerges. +- The SkiaSharp edge package is justified because it isolates an external native + rendering dependency and different deployment requirements. +- Legacy System.Drawing compatibility remains in `LTRLib.Windows`. The migrated + GraphViewer owns a small System.Drawing renderer for portable plot geometry; + another adapter package is unnecessary for this single consumer. +- The modern expression language is invariant-culture, source-preserving, and + case-insensitive for names. +- Power is right-associative and binds more strongly than unary sign, so `2^3^2` is + `2^(3^2)` and `-2^2` is `-(2^2)`. +- The ordinary plotting contract is unary `f(x) -> y`. Previous-`y` recurrence is not in + the new primary API because no first-party formula using it was found. + +## Remaining design questions + +- The first SkiaSharp package now contains both plot and diagram renderers because + they share the same dependency and deployment boundary. +- Whether legacy function aliases should later be offered by a separately named symbol + catalog in Library or only by an LTRLib adapter. +- Whether plotting needs adaptive sampling after fixed-count sampling and correct clipping + have been evaluated against the web and Windows applications. +- Whether diagrams need a separate bound semantic mode. The first implementation is + a syntax projection with optional parenthesis nodes. +- Whether new projects should eventually drop .NET Framework 3.5/4.0. This branch keeps + the repository target matrix while compatibility policy is decided. + +## Current review checkpoint + +The architecture and object model were accepted. The first rendering slice is now +implemented: diagram content/layout inside the math package, +`LTRData.Graphics.SkiaSharp`, and both PNG paths on the companion website branch. +The subsequent [consumer migration](consumer-migration.md) covers netexpr, +GraphViewer, sample differentiation and integration, and package-based validation. +The application owner has built and tested all consuming applications and reported +that they work well. The final review prepares stable package versions, uses +framework-supported argument throw helpers, and fixes entity serializer generation +in the companion database repository. See the [release checkpoint](release-checkpoint.md). +FreeBSD native SkiaSharp work is postponed; the website will temporarily run on +Linux or Windows Server. diff --git a/docs/math-expression-redesign/architecture.md b/docs/math-expression-redesign/architecture.md new file mode 100644 index 0000000..9692eea --- /dev/null +++ b/docs/math-expression-redesign/architecture.md @@ -0,0 +1,175 @@ +# Architecture, dependency boundaries, and proposed public APIs + +This is the approved design proposal. See [the implemented rendering slice](rendering-slice.md) +for the concrete renderer APIs, sample output and current deployment requirements. + +## Intended dependency graph + +```mermaid +flowchart TD + Apps["Web / Windows / netexpr"] --> Math["LTRData.MathExpression"] + Apps --> Plot["LTRData.FunctionPlotting"] + Skia["Future SkiaSharp edge package"] --> Math + Skia --> Plot + Legacy["LTRLib.Windows adapters"] --> Math + Legacy --> Plot +``` + +There is no dependency from plotting to expressions. An expression-bound unary function +is merely one producer of `double f(double x)`. + +Within `LTRData.MathExpression`, dependencies flow in one direction: + +```text +source -> lexer/parser -> immutable syntax -> binder/catalog -> interpreted evaluator + | + +-> future diagram content/layout +``` + +`System.Linq.Expressions` may later be an export target. It is not syntax. + +## Package boundary decisions + +| Package/project | Boundary decision | Dependency isolated | Known consumers | What other consumers avoid | Different TFM/version need? | +|---|---|---|---|---|---| +| `LTRData.MathExpression` | Keep existing package; add modern APIs beside legacy types during experiment | Keeps parsing/evaluation free of graphics and future SkiaSharp | `netexpr`, GraphViewer, web | `netexpr` avoids plotting/rendering dependencies | No new version boundary; existing name remains adequate | +| `LTRData.FunctionPlotting` | Add one portable project/package | Prevents plotting from depending on parser and its current `LTRData.Extensions` reference | GraphViewer, web, arbitrary numerical callers | Expression-only consumers avoid plotting; plotting-only consumers avoid parser/extensions | Same broad repository TFMs initially; independently useful API justifies consumption boundary | +| expression diagrams | Namespace/types in `LTRData.MathExpression`, not a separate package | No external dependency to isolate in content/layout | Web first; possible educational/debug consumers | Nothing meaningful would be avoided by another package | No independent target/version need found | +| future SkiaSharp edge | One package for both plot and expression-diagram raster rendering | SkiaSharp managed/native assets, font/raster deployment, PNG encoding | Unix ASP.NET application | Windows and calculation-only consumers avoid SkiaSharp entirely | Yes: external/native dependency and modern server TFMs | +| System.Drawing edge | Small renderer in the migrated GraphViewer; legacy compatibility remains in `LTRLib.Windows` | Windows-only rendering and image encoding | Windows GraphViewer | All portable/server consumers avoid System.Drawing | Application-local for now; extract only when another consumer needs it | + +No separate packages are proposed for lexer, syntax, binding, evaluation, layout, rendering, +or PNG encoding merely because they are conceptual stages. + +## Proposed expression API + +Signatures are reviewable proposals. The experimental slice implements the central forms. + +```csharp +MathParseResult parse = MathParser.Default.Parse("sin(x) + x^2"); + +if (!parse.Success) +{ + foreach (MathDiagnostic diagnostic in parse.Diagnostics) + { + Console.Error.WriteLine($"{diagnostic.Span}: {diagnostic.Message}"); + } +} + +MathBindingResult binding = MathBinder.Bind( + parse.Root!, + MathSymbolCatalog.Standard); + +BoundMathExpression expression = binding.Expression!; +UnaryMathFunction function = expression.BindUnary("x"); +double y = function.Evaluate(1.25); +``` + +Arbitrary variables use stable slots: + +```csharp +foreach (MathVariable variable in expression.Variables) +{ + Console.WriteLine($"{variable.Slot}: {variable.Name}"); +} + +double result = expression.Evaluate(valuesBySlot); +``` + +Custom symbols are explicit: + +```csharp +MathSymbolCatalog symbols = MathSymbolCatalog.Standard.ToBuilder() + .AddConstant("g", 9.80665) + .AddFunction("square", x => x * x) + .Build(); +``` + +The builder is mutable construction state; the resulting catalog is immutable and safe to +share. No provider assembly/type scan occurs during parsing or evaluation. + +## Proposed plotting API + +```csharp +SampleSeries samples = FunctionSampler.Sample( + function.Evaluate, + new NumericRange(-10, 10), + sampleCount: 1200); + +var viewport = new PlotViewport( + new NumericRange(-10, 10), + new NumericRange(-4, 4), + new CanvasSize(1200, 720)); + +CurveGeometry geometry = CurveGeometryBuilder.Build(samples, viewport); +``` + +`SampleSeries` retains the original data coordinates and a status per sample. Geometry is +a collection of continuous-coordinate polylines; it contains no pixels, colors, pens, +paths, fonts, or renderer objects. + +The validation slice deliberately uses fixed-count endpoint-inclusive sampling. The count +is explicit and unrelated to a rectangle's pixel-loop convention. Geometry breaks at +invalid samples and performs proper rectangular clipping. It does not yet claim to detect +finite-valued asymptotes. + +## Future expression-diagram boundary + +Planned flow inside the existing math package: + +```text +MathSyntax or semantic expression + -> ExpressionDiagram (node IDs, labels, roles, ordered edges) + -> backend-measured node sizes + -> TreeDiagramLayout (rectangles and connectors) +``` + +The Skia/System.Drawing adapter measures labels using the actual font backend and supplies +sizes to portable layout. This avoids both renderer-owned layout and a general-purpose +`IGraphics.MeasureText/DrawString` abstraction. + +## Future renderer integration + +Backend-specific APIs should directly accept backend objects: + +```csharp +skiaPlotRenderer.Render(SKCanvas canvas, CurveGeometry geometry, SkiaPlotTheme theme); +skiaDiagramRenderer.Render(SKCanvas canvas, ExpressionDiagramLayout layout, + SkiaDiagramTheme theme); +skiaPngEncoder.Encode(SKImage image, Stream destination); + +systemDrawingPlotRenderer.Render(Graphics graphics, CurveGeometry geometry, + SystemDrawingPlotTheme theme); +``` + +Rendering and encoding remain separate concepts inside the edge package, but do not need +separate packages. + +## Intentional compatibility breaks + +| Legacy behavior/API | Modern decision | Reason | +|---|---|---| +| Parser returns LINQ `Expression` | Parser returns math syntax and diagnostics | Source spans, diagrams, AOT interpretation, and language ownership | +| `ScriptControl.Expression` setter compiles and mutates state | Explicit parse, bind, and optional unary binding | Predictable immutable stages | +| Only `x` and `y` can be compiled | Arbitrary bound variable slots; plotting explicitly binds unary `x` | Required by `netexpr`; no hidden special variables | +| Current culture configurable grammar | Invariant formula language | Stable storage/server semantics and unambiguous comma | +| Current power grouping | Conventional right-associative power above unary sign | Existing results are artifacts without consumer evidence | +| Empty input is zero | Parse diagnostic | Avoid silently graphing missing input | +| Reflection provider types | Explicit symbol catalog | AOT/trimming safety and deterministic public contract | +| Exceptions become nullable values | Direct evaluation propagates; sampling classifies | Distinguishes evaluation, non-finite result, and missing expression | +| Previous `y` always fed back | Unary ordinary function | No first-party use; recurrence can be explicit legacy functionality | +| Sampling tied to inclusive pixel coordinates | Explicit numerical sample count | Calculation independent of renderer | +| Integral wraps at viewport | Never alter numerical state for visibility | Mathematical correctness and separation of clipping | +| Refresh appends paths | Explicit overlay composition | Preserve the feature without accidental state mutation | + +## Compatibility placement + +The existing public classes remain untouched in the experimental slice. Later migration can: + +- implement LTRLib `ScriptControl` over the new parser/binder while retaining its nullable + result and source-normalization behavior where required; +- implement an LTRLib Windows `Surface` adapter with characterized legacy algorithms; +- leave old .NET Framework targets on the legacy implementation while modern GraphViewer + targets migrate first. + +No types named `ScriptControl`, `Surface`, or `Refresh` are added to the modern API. diff --git a/docs/math-expression-redesign/consumer-migration.md b/docs/math-expression-redesign/consumer-migration.md new file mode 100644 index 0000000..ee8ead1 --- /dev/null +++ b/docs/math-expression-redesign/consumer-migration.md @@ -0,0 +1,96 @@ +# netexpr and Windows GraphViewer migration + +The companion `experimental/math-expression-migration` branches in +[MathTools](https://github.com/LTRData/MathTools/tree/experimental/math-expression-migration) +and [WindowsTools](https://github.com/LTRData/WindowsTools/tree/experimental/math-expression-migration) +consume the modern APIs through NuGet. All existing application targets remain: +.NET Framework 3.5/4.0 and .NET 8/9/10, with Windows targets for GraphViewer. + +## netexpr + +`MathParser` produces syntax and diagnostics, `MathBinder` binds the standard +catalog and variable slots, and the bound expression evaluates directly. There +is no generated LINQ expression or runtime delegate compilation. + +The command line still joins formula arguments, prompts for invariant-culture +`name=value` assignments and prints an invariant-culture number. Names are +case-insensitive; repeated names share one slot. While other values are still +pending, assigning a parameter again updates it. Blank input is ignored and EOF +reports missing values rather than looping or failing with a null reference. + +The historical numeric exit code is deliberately retained: VB's `CInt` conversion +rounds to the nearest integer, with ties to even. Invalid input, non-finite results +and conversion overflow return -1. A successfully calculated nonzero answer is +therefore not a conventional process-success exit code; shells may truncate it. + +The modern language applies, including right-associative power and power before +unary sign. Shift/bitwise syntax is rejected. See the language specification for +the supported vocabulary; legacy aliases are not silently added to the catalog. + +## GraphViewer + +Each committed formula is a `PlotDefinition` containing a bound unary function. +`PlotSnapshot` captures the definitions, viewport and portable geometries. Explicit +overlays replace retained GDI+ paths. Redraw adds a definition when overlay mode is +enabled; repaint, visibility toggles and resize do not. Changing the viewport +recalculates all retained formulas into the same coordinate system. Invalid +formula/range input leaves the last successful snapshot intact. + +GraphViewer's small `PlotDrawing` module renders the geometry using System.Drawing +at the Windows application boundary. The same renderer serves the screen, +BMP/GIF/JPEG/PNG/TIFF export and printing. Print geometry is rebuilt for the page +size and positioned within the margins. Printing uses all committed overlays; +editing a formula without redrawing does not change the printed graph. + +The application accepts only `x` as a variable. Previous-`y` recurrence remains +legacy-only. No historical `Surface`, `ScriptControl` or `LTRLib.Windows` package +is required by this application. Other legacy consumers can still use them. + +## Numerical calculus + +`LTRData.FunctionPlotting` 1.2.0 includes `SampleCalculus`, independent of +both the expression parser and graphics. Its inputs are ordered data-coordinate +samples with finite, strictly increasing X coordinates and finite spacing. + +| Operation | Definition | +| --- | --- | +| `Differentiate` | Three-point differences, including one-sided endpoint formulas; unequal X spacing is supported. Two-point runs use their secant slope; isolated points have no derivative. | +| `IntegrateFiniteRuns` | Cumulative signed trapezoidal areas; each finite run starts at the supplied initial value (zero by default) at its leftmost sample. | +| Gaps | Neither operation crosses an invalid sample. Evaluation-error status remains explicit. Integration starts again at the initial value after a gap. | +| Overflow | A calculated non-finite value stays non-finite; it is not a display wrap or an inferred new integration origin. | + +GraphViewer samples at twice the canvas width plus one, bounded to 3–32769 points, +then transforms and clips the original curve, derivative and integral separately. +Integral values have no dependence on the Y viewport. The menu/tooltips identify +the numerical calculation and its initial-value convention. + +These are sampled approximations. An unsampled discontinuity may still be joined; +the code does not prove continuity, detect every finite asymptote, or evaluate an +improper integral. Numerical derivatives and integral values can change with +sampling density. Legacy wrapped integrals and stale derivative state across gaps +are intentionally not reproduced. + +## Package chain and review + +Build `LTRData.Extensions`, `LTRData.MathExpression` and `LTRData.FunctionPlotting` +in Release with `LocalNuGetPath` set, then restore consumers from that shared feed. +netexpr needs the first two packages; GraphViewer needs all three, with Extensions +arriving transitively. MathExpression uses 1.1.0. Use a fresh cache +and package source mapping for a reproducible check of unpublished builds, as in +the [local package workflow](../local-package-workflow.md). + +Each consumer branch includes a focused review executable and a CI workflow that +builds its producer packages first. netexpr covers variables, culture, diagnostics, +precedence and numeric exit codes. GraphViewer's Windows checks exercise GDI+ +rendering, all five image encoders, margin/graphics-state handling and form overlay, +visibility and resize behavior. Library's calculus tests cover unequal spacing, +short runs, gaps, error status, overflow and analytic comparison with sine/cosine. + +Manual Windows review should cover actual print preview/printer selection, DPI +scaling, saved preferences and the visual appearance of curves at chosen ranges. +Compiling the old framework targets does not claim execution on an old Windows +installation. The website can be hosted on Linux or Windows Server for now; +FreeBSD native SkiaSharp work and adaptive sampling remain deferred. The application +owner has now built and tested all consumers successfully. Framework-supported +argument throw helpers and entity XML serialization are addressed by the +[release checkpoint](release-checkpoint.md). diff --git a/docs/math-expression-redesign/language-specification.md b/docs/math-expression-redesign/language-specification.md new file mode 100644 index 0000000..aa6ce15 --- /dev/null +++ b/docs/math-expression-redesign/language-specification.md @@ -0,0 +1,167 @@ +# Modern mathematical expression language — experimental specification + +This document is normative for the experimental `MathParser`/`MathBinder` API. It is not a +description of `MathExpressionParser`. + +## 1. Source and names + +- Source text is preserved exactly in `MathParseResult.SourceText`. +- Every syntax node and diagnostic has a zero-based source span. +- Whitespace separates or surrounds tokens and is otherwise insignificant. +- Identifiers use a Unicode letter or `_` as the first character, followed by Unicode + letters, digits, or `_`. +- Identifier lookup is ordinal case-insensitive. Original spelling remains in syntax. +- A bare identifier resolves first as a catalog constant and otherwise becomes a variable. +- An identifier followed immediately by `(` is a function call; unknown functions and + wrong arities are binding diagnostics. + +## 2. Numeric literals and culture + +The language is culture-invariant: + +- decimal separator is `.`; +- function arguments are separated by `,`; +- group/thousands separators are not accepted; +- literals may contain digits before and/or after `.`, but at least one digit is required; +- scientific notation uses `e` or `E` and an optional `+`/`-` exponent sign. + +Examples: `12`, `12.`, `.5`, `1.25`, `1e-3`, `2.5E+6`. + +User interfaces may localize entry separately, but thread culture never changes the +formula language. + +## 3. Primary expressions and calls + +Primary expressions are: + +- numeric literals; +- names; +- function calls `name(argument, ...)`; +- parenthesized expressions `(expression)`. + +Zero-argument calls are syntactically valid so a catalog may define them. The standard +catalog currently defines unary and binary functions only. + +## 4. Operators + +From highest to lowest binding strength: + +| Level | Operators | Associativity | +|---:|---|---| +| 1 | postfix factorial `!` | left/repeated | +| 2 | power `^`, `**` | right | +| 3 | prefix identity/negation `+`, `-` | right | +| 4 | multiplication `*`, division `/`, remainder `%`/`mod`, implicit multiplication | left | +| 5 | addition `+`, subtraction `-` | left | + +Power binds more strongly than unary sign: + +- `-2^2` is `-(2^2)` and evaluates to `-4`; +- `2^-2` is `2^(-2)` and evaluates to `0.25`; +- `2^3^2` is `2^(3^2)` and evaluates to `512`; +- `2*3^2` is `2*(3^2)` and evaluates to `18`. + +`**` is an alias for `^`. `mod` is a case-insensitive alias for `%`. + +### Implicit multiplication + +Implicit multiplication is supported when an already parsed operand is followed by an +identifier or `(` without an explicit binary operator. It has exactly the precedence and +left associativity of `*`. + +Examples: + +- `2x` means `2*x`; +- `2(x+1)` means `2*(x+1)`; +- `(x+1)(x-1)` means `(x+1)*(x-1)`; +- `2sin(x)` means `2*sin(x)`. + +An identifier directly followed by `(` is always a function call. Write `x*(y)` rather +than `x(y)` when multiplication is intended. Adjacent numeric literals are rejected rather +than multiplied. + +## 5. Factorial + +Postfix factorial accepts a finite, non-negative integer-valued `double`: + +- `0!` and `1!` are `1`; +- a negative, fractional, or `NaN` operand produces `NaN`; +- an operand greater than `170` produces positive infinity because the exact factorial + exceeds the finite `double` range. + +This intentionally differs from the legacy loop, which returned one for negative inputs +and effectively truncated some fractional inputs. + +## 6. Standard symbol catalog + +The experimental standard catalog contains constants: + +- `e`; +- `pi`. + +It contains conventional functions available consistently for `double`: + +- unary: `abs`, `acos`, `asin`, `atan`, `ceiling`, `cos`, `cosh`, `exp`, `floor`, `ln`, + `log`, `log10`, `round`, `sign`, `sin`, `sinh`, `sqrt`, `tan`, `tanh`, `truncate`; +- binary: `atan2`, `log`, `max`, `min`, `pow`. + +`log(x)` and `ln(x)` are natural logarithms. `log(x, b)` uses base `b`. + +Additional constants and unary/binary functions can be supplied through an explicit +catalog builder. Catalogs are immutable after construction. Reflection is not part of the +standard lookup process. + +Historical aliases and bitwise/shift operations are not in the modern standard catalog. +They may be offered later by an explicitly named legacy catalog. + +## 7. Binding and variables + +Parsing does not decide whether a name is a constant or variable. Binding resolves names +against a supplied symbol catalog: + +1. a bare name matching a constant binds to that constant; +2. another bare name becomes a variable; +3. a call must match a function name and arity or binding fails. + +Variables receive stable zero-based slots in first-occurrence traversal order. Evaluation +can supply values by slot or by case-insensitive name. + +Unary function binding succeeds when the expression has no variables or has exactly one +variable matching the requested name. It fails explicitly when other variables remain. + +## 8. Errors and floating-point semantics + +- Lexical and syntactic errors are returned as source-span diagnostics; invalid or empty + input does not become zero. +- Binding errors such as an unknown function or wrong arity are diagnostics. +- Direct evaluation uses normal IEEE `double` arithmetic. +- Division by zero and domain behavior from `System.Math` therefore produce infinity or + `NaN` where .NET does so. +- Exceptions thrown by explicitly registered custom functions propagate from direct + evaluation. +- Function sampling classifies `NaN`, positive infinity, negative infinity, and evaluation + exceptions as distinct sample statuses. It does not confuse those states with zero. + +## 9. Formatting and diagrams + +The exact source and the syntax tree are distinct from a future canonical formatter. +`Expression.ToString()` output is not formula identity. + +Parenthesized syntax is preserved. The diagram projection can retain parenthesis nodes +or omit them because tree edges already express grouping. Binding removes syntactic +parentheses; a separate bound semantic projection remains future work. Canonical +formatting remains deferred. See [the rendering slice](rendering-slice.md) for the +implemented syntax diagrams and SkiaSharp rendering. + +## 10. Intentional omissions + +The initial modern language omits: + +- shifts `<<`/`>>`; +- bitwise `&`, `|`, `xor`, and prefix `!`; +- prefix `neg`; +- textual infix `pow`; +- reflection-discovered methods/fields; +- assignments, comparisons, booleans, conditionals, and statements. + +These can be reconsidered as language features only with a use case and explicit semantics. diff --git a/docs/math-expression-redesign/legacy-behavior-classification.md b/docs/math-expression-redesign/legacy-behavior-classification.md new file mode 100644 index 0000000..f19e565 --- /dev/null +++ b/docs/math-expression-redesign/legacy-behavior-classification.md @@ -0,0 +1,87 @@ +# Legacy behavior classification + +Each row is deliberately classified as exactly one of: + +- **A:** desired modern behavior and eligible for normative new-API tests; +- **B:** legacy compatibility only; +- **C:** discarded artifact or bug. + +“Feature” and “mechanism” are separate rows when the feature is wanted but its historical +implementation is not. + +## Expression language and evaluation + +| Observed behavior | Class | Evidence and decision | +|---|:---:|---| +| Parse ordinary arithmetic, parentheses, functions, constants, and variables | A | Required by all expression consumers. | +| Arbitrary variables | A | `netexpr` discovers and prompts for arbitrary parameters. | +| Case-insensitive names | A | Long-standing behavior and convenient for interactive formula input; source spelling is still preserved. | +| Invariant `.` decimal syntax and comma argument separator | A | Both current applications construct the parser with invariant culture; a stable language must not change with server/thread culture. | +| Leading decimal point such as `.1` | A | Explicit current test and common calculator syntax. | +| Scientific notation with signed exponent | A | Normal numeric syntax; replacement tokenization currently breaks it. | +| `+`, `-`, `*`, `/`, `%`/`mod`, `^`/`**` | A | Ordinary mathematical/calculator operations; `**` and implicit multiplication have direct tests. | +| Left associativity for addition, subtraction, multiplication, division, and remainder | A | Conventional and tested for subtraction/arithmetic. | +| Right-associative power | A | Conventional modern decision: `2^3^2` means `2^(3^2)`. | +| Power binds more strongly than unary sign | A | Conventional modern decision: `-2^2` means `-(2^2)` while `2^-2` remains valid. | +| Implicit multiplication | A | Explicit existing test. It has the same precedence/associativity as `*`; ambiguous forms should use parentheses. | +| Postfix factorial | A | Fits the mathematical domain. Modern semantics require a finite non-negative integer; invalid input yields `NaN`, and overflow yields positive infinity. | +| Preserve original source text and node spans | A | Needed for diagnostics and expression diagrams. | +| Explicit immutable constant/function catalog | A | Supports extension without reflection, trimming surprises, or mutable global/provider state. | +| IEEE `NaN` and infinity from valid floating-point operations | A | Evaluation remains `double`; sampling classifies non-finite results separately. | +| LINQ `Expression` as the parsed representation | C | Runtime-code-generation model leaks into parsing and loses lexical/source structure. | +| Runtime compilation as the baseline evaluator | C | Unnecessary for correctness and unsuitable as the AOT baseline. An optional exporter/compiler may be added later. | +| Binding every expression to exactly `x` and `y` | C | Contradicted by `netexpr`; plotting can explicitly request unary binding. | +| Previous-`y` recurrence in ordinary plotting | B | Implemented historically, but no first-party formula using it was found. If retained, it must be explicitly named. | +| Lower-case the complete input | C | Original spelling is useful source data; comparison can be case-insensitive without mutation. | +| Store `Expression.ToString()` as formula identity | C | Generated representation is neither source nor a language-level canonical format. | +| Reflection scan of mutable `ProviderTypes` including non-public members | C | Accidental extensibility mechanism; explicit catalogs are predictable and AOT-compatible. | +| Reflection-based provider import | B | May be useful as a separately named migration adapter, never as the default catalog. | +| Mutate thread culture while formatting generated expression text | C | Hidden global/thread state with unsafe restoration on failure. | +| Convert every evaluation exception to `null` | B | Legacy graph gaps depend on it, but modern evaluation and sampling have explicit error/status contracts. | +| Current equal-precedence left-to-right power/multiply scan | C | Produces `(2*3)^2` for `2*3^2`; no consumer evidence supports it. | +| Replacement-based tokenization | C | Breaks scientific notation and identifiers containing operator substrings. | +| Empty input becomes numeric zero | C | Hides missing user input; modern parser reports an expected-expression diagnostic. | +| Prefix `neg` and prefix `!` | C | No usage evidence; `!` is especially misleading alongside postfix factorial. | +| Infix textual `pow` | C | No usage evidence; `pow(a,b)`, `^`, and `**` are clear alternatives. | +| Shifts and bitwise operators | B | A shift has a parser test but no application formula; it does not belong in the initial mathematical plotting language. | +| Historical aliases such as `atn`, `sqr`, `sgn`, `cosec`, `cotan`, and `harcsin` | B | Historically exposed but absent from checked-in formulas. A legacy catalog can provide them later. | +| Private `System.Math` methods accepted as symbols | C | Implementation leakage with trimming and versioning risk. | +| Legacy factorial truncation/negative-input result of one | C | Surprising numerical artifact rather than defensible factorial semantics. | +| Apparent integer-operand reflection call bug for bitwise operations | C | A bug must not become a compatibility contract. | + +## Plot calculation and rendering + +| Observed behavior | Class | Evidence and decision | +|---|:---:|---| +| Plot an arbitrary scalar function independently of its origin | A | Required architectural boundary and useful beyond expression parsing. | +| Explicit finite/non-finite/evaluation-error samples | A | Necessary for robust geometry and server rendering. | +| Explicit overlay composition | A | GraphViewer intentionally lets users retain old graphs. | +| Derivative display as a feature | A | Enabled by default in GraphViewer, but deferred from the validation slice. | +| Numerical integral display as a feature | A | Exposed by GraphViewer, but deferred and disabled by default. | +| Backend-neutral continuous plot geometry | A | Required by both System.Drawing and Unix PNG rendering. | +| Exactly `width + 1` samples tied to rectangle pixels | C | Rectangle-loop artifact. New sampling takes an explicit count independent of rendering. | +| Pass the prior result as the next `y` automatically | B | Same recurrence decision as above. | +| Draw a segment only when an endpoint is strictly within Y bounds | C | Omits crossing segments and conflates visibility with numerical continuity. | +| Clamp coordinates to one pixel outside the viewport | C | Renderer/path workaround. New geometry uses continuous transforms and clipping. | +| Treat exceptions as gaps but let `NaN`/infinity reach conversion/path code | C | Inconsistent accidental behavior; statuses are explicit. | +| Silently swallow path-construction exceptions | C | Hides defects and mixes rendering failures with calculation. | +| Forward-difference derivative between adjacent samples | B | Exact output may matter only to legacy rendering. A future modern derivative should be specified independently. | +| Carry stale derivative state across an invalid gap | C | Clear bug. | +| Right-rectangle cumulative integration | B | Possible compatibility mode if exact old output matters; not selected as modern default. | +| Wrap the integral to the opposite Y boundary | C | Viewport changes mathematical state; clipping must never alter the numerical integral. | +| Reset integral to zero after invalid samples | B | Legacy behavior only; future modern integration needs explicit segment/initial-value semantics. | +| Append paths on `Refresh` until `Clear` | C | Mutation artifact. The desired overlay feature is represented explicitly instead. | +| Always draw axes and clamp off-range axes outside the area | C | Visibility decision should be explicit in plot composition/rendering. | +| `Surface` owns disposable backend paths and a finalizer | C | Portable samples and geometry require no disposal; backend resources are short-lived at the edge. | +| System.Drawing in the Windows adapter | B | Valid compatibility/backend choice, but not part of portable APIs. | +| SkiaSharp in a Unix PNG edge package | A | Selected first backend direction; it must not shape core models. | + +## Test policy + +New tests use explicit naming/categories: + +- `ModernMathExpressionTests` and plotting tests are normative category A tests. +- `LegacyMathExpressionCharacterizationTests` document B/C observations against the + existing parser only. +- A legacy characterization failure is evidence to revisit documentation; it must not be + “fixed” by changing the new parser to reproduce the old result. diff --git a/docs/math-expression-redesign/release-checkpoint.md b/docs/math-expression-redesign/release-checkpoint.md new file mode 100644 index 0000000..7eb82d6 --- /dev/null +++ b/docs/math-expression-redesign/release-checkpoint.md @@ -0,0 +1,39 @@ +# Stable package review checkpoint + +The architecture and object model have been accepted, and the application owner +has built and tested all consuming applications successfully. This checkpoint +prepares the branches for review; it does not publish packages or deploy a site. + +| Package | Version | +| --- | --- | +| LTRData.MathExpression | 1.1.0 | +| LTRData.FunctionPlotting | 1.2.0 | +| LTRData.Graphics.SkiaSharp | 1.1.0 | +| LTR.WebDb.Entity (ltrwebdb) | 1.0.4 | +| webdbcore (ltrwebdb) | 1.0.4 | + +The math and plotting APIs use `ArgumentNullException.ThrowIfNull` on .NET 6 or +later, selected by `NET6_0_OR_GREATER`. Existing explicit checks remain for older +targets. Non-negative source-span checks similarly use +`ArgumentOutOfRangeException.ThrowIfNegative` with `NET8_0_OR_GREATER`. Renderer +projects already target supported runtimes. Exception parameter names and public +contracts stay the same. + +The database repository confines XML serializer generation to `LTR.WebDb.Entity`, +pins both the generator build package and CLI registration, verifies generation, +and packages a serializer DLL with each framework's entity assembly. Its review +application consumes the real package and checks published XML round trips. The +website consumes database package 1.0.4 and no longer suppresses serialization +assembly generation globally. + +Review and merge Library first, then ltrwebdb, then the website. MathTools and +WindowsTools depend only on Library. Each consumer review workflow builds a pinned +Library revision into a shared local NuGet feed before restoring the consumer. +The [local package workflow](../local-package-workflow.md) remains the normal way +to validate the complete chain, including internal-only packages. + +FreeBSD compatibility remains deferred. Other deferred features include previous-y +recurrence, adaptive sampling, automatic detection of every finite asymptote, +semantic diagrams and optional expression compilation. The website's UI still +offers the function and expression tree; adding numerical derivative/integral +controls there is a separate feature. diff --git a/docs/math-expression-redesign/rendering-slice.md b/docs/math-expression-redesign/rendering-slice.md new file mode 100644 index 0000000..3bd3332 --- /dev/null +++ b/docs/math-expression-redesign/rendering-slice.md @@ -0,0 +1,145 @@ +# First rendering slice + +This implements the accepted architecture through two server-side PNG compositions. +The companion consumer changes are on `LTRData/ltr-data.se` branch +`experimental/math-expression-rendering`. + +## Public boundaries + +- `LTRData.MathExpression.Diagrams.ExpressionDiagram.FromSyntax` creates immutable + node content, source spans, and ordered child roles. Names and numeric spelling + survive. Operators have conventional labels. Parenthesis nodes are optional. +- This is a **syntax projection**, not a bound semantic diagram or algebraic + simplifier. Default diagrams omit grouping nodes because edges already express + grouping. A separate semantic projection remains future work. +- `TreeDiagramLayout.Create` consumes a size per node, measured by the actual font + backend. Its rectangles and connectors contain no graphics types. Both traversal + and layout are iterative; layout takes linear time and keeps subtree extents apart. +- `LTRData.Graphics.SkiaSharp` is one edge package, targeting .NET 8/9/10. It references + the expression and plotting packages and SkiaSharp. The portable projects never + reference it or SkiaSharp. +- `SkiaDiagramRenderer.MeasureNodes` and `Render` use the same caller-owned `SKFont`. + `SkiaPlotRenderer.Render` draws supplied geometry; `RenderAxes` draws visible axes. + Neither renderer clears the canvas or retains paths, so overlays can be composed + explicitly. `SkiaPngEncoder.Encode` only encodes and leaves its inputs open. + +```csharp +var parse = MathParser.Default.Parse("sin(x) + x^2"); +// Check parse.Success and report parse.Diagnostics in a real caller. +var diagram = ExpressionDiagram.FromSyntax(parse.Root!); +using var typeface = SKTypeface.FromFamilyName("sans-serif"); +using var font = new SKFont(typeface, 20); +var sizes = SkiaDiagramRenderer.MeasureNodes(diagram, font); +var layout = TreeDiagramLayout.Create(diagram, sizes); +using var surface = SKSurface.Create(new SKImageInfo( + (int)Math.Ceiling(layout.Size.Width), (int)Math.Ceiling(layout.Size.Height))); +surface.Canvas.Clear(SKColors.White); +SkiaDiagramRenderer.Render(surface.Canvas, layout, font); +using var image = surface.Snapshot(); +SkiaPngEncoder.Encode(image, destinationStream); +``` + +For plots, parse/bind once, call `BindUnary("x")`, sample its `Evaluate` delegate, and +build `CurveGeometry` for a viewport before passing it to the Skia renderer. Arbitrary +numerical callers can supply a scalar delegate without using an expression parser. + +## Samples + +Both images below were generated by the actual website page/service through its +HTTP PNG routes on Linux, at 640 × 360, for `sin(x) + x^2` (X −10..10, Y −4..4). + +![Function plot](samples/plot.png) + +![Expression tree](samples/tree.png) + +## Native deployment and fonts + +The edge package pins **SkiaSharp 4.151.2**. The executable application chooses its +native assets; the website and rendering tests reference +`SkiaSharp.NativeAssets.Linux` at the same version. The managed SkiaSharp package +also brings the Windows/macOS assets. On Linux, install Fontconfig and usable fonts +(for example `libfontconfig1` and `fonts-dejavu-core` on Ubuntu). + +For deterministic diagram text, the website accepts `GraphViewer:FontPath`, or the +environment variable `GraphViewer__FontPath`, pointing to a deployed TTF/OTF file. +The application owns font selection and font-file distribution/licensing. Complex +text shaping and per-glyph font fallback are outside this slice. + +**FreeBSD is a separate native deployment target.** The Linux native package does +not supply FreeBSD assets. A FreeBSD process needs a compatible FreeBSD build of +`libSkiaSharp` for the selected managed version. Do not substitute a Linux shared +library or an older incompatible Skia ABI. The local validation in this slice runs +on Linux; FreeBSD execution remains a deployment check on the target host. + +Sources: [SkiaSharp release](https://github.com/mono/SkiaSharp/releases/tag/v4.151.2), +[managed package](https://www.nuget.org/packages/SkiaSharp/4.151.2), +[Linux native package](https://www.nuget.org/packages/SkiaSharp.NativeAssets.Linux/4.151.2). + +## Validation and fixes + +Local verification: 63 focused core tests and four native renderer tests pass on +.NET 10/Linux, and the review host's HTTP checks pass. The portable math project +also builds for .NET Framework 3.5 with zero warnings. The review host builds with +zero warnings. Other target/runtime combinations remain covered by repository CI +and target-host checks; a local FreeBSD run is not claimed. + +- The focused expression/plotting suite includes the existing legacy tests, modern + normative tests, the 18 compatible legacy expression examples, layout tests, and + large-finite-coordinate regressions. +- Large finite sample differences could overflow during clipping, and viewport + conversion could overflow an intermediate product. Regression tests reproduced + both failures. Clipping now sets the crossed boundary exactly and uses scaled + interpolation when differences overflow; transforms divide before scaling. +- `Rendering.Tests` exercises native PNG decoding/dimensions, visible labels, + non-finite sample gaps, axis visibility, and encoder ownership on .NET 8/9/10. +- The companion `GraphViewer.Review --verify` checks the actual Razor page and HTTP + routes, including malformed/oversized requests, multivariable trees, cache reuse, + HTML encoding, and the concurrent-render limit. + +The review host deliberately does not start the full site's database or visit logging. +Normal deployment configuration is still required to run the full site. +The site pins `ArsenalRecon.PasswordTable` to `[3.12.340]`, now available from +public NuGet, to retain EF Core 9 compatibility on both targets. The previous +.NET 10 package conflict is resolved. Entity XML serialization assembly generation +is addressed in the subsequent [release checkpoint](release-checkpoint.md). + +## Packaging and review + +The final review uses `LTRData.MathExpression` 1.1.0, +`LTRData.FunctionPlotting` 1.2.0 and `LTRData.Graphics.SkiaSharp` 1.1.0. +Consumers explicitly reference the stable versions containing the modern APIs. +No NuGet release is performed by this work. + +Both companion website hosts consume packages across repository boundaries. +Build `Library`, then `ltrwebdb`, with package output directed to the shared +`LocalNuGetPath` directory. Restore the website through a local `NuGet.Config` +containing that directory. Project references remain within each producer +repository; NuGet resolves framework assets, versions and transitive dependencies +between repositories. See the [local package workflow](../local-package-workflow.md) +for commands and package-origin checks, and the website's +`docs/math-rendering-review.md` for the UI and HTTP verification paths. + +The package workflow was validated on Linux with all 13 required producer +packages containing both .NET 9 and .NET 10 assets. Both full-site targets and the +.NET 10 review host built with zero warnings or errors. A fresh cache, source +mappings, package metadata and resolved framework assets confirmed local package +consumption. The review host's HTTP and native PNG checks passed against those +packages. The database stack resolved PasswordTable 3.12.340, EF Core 9.0.19 and +Pomelo 9.0.0 on both targets. + +Review and validate this package chain locally before publishing to NuGet servers. + +## Still deferred + +At this slice's original checkpoint, derivative/integral calculation, recurrence, +adaptive sampling and finite-asymptote detection, Windows migration, semantic diagrams, and optional compilation remained +separate work. Fixed-count sampling can miss narrow features or connect across an +asymptote between finite samples; PNG output does not change that contract. + +The subsequent [consumer migration](consumer-migration.md) implements netexpr, +Windows GraphViewer and sample calculus. FreeBSD native deployment work is now +postponed, with Linux or Windows Server selected for temporary website hosting. + +The user preference for `ArgumentNullException.ThrowIfNull` on supporting targets +is used in the new code. A broader optimization pass over the original prototype +guards remains deferred. diff --git a/docs/math-expression-redesign/samples/plot.png b/docs/math-expression-redesign/samples/plot.png new file mode 100644 index 0000000..502cdf4 Binary files /dev/null and b/docs/math-expression-redesign/samples/plot.png differ diff --git a/docs/math-expression-redesign/samples/tree.png b/docs/math-expression-redesign/samples/tree.png new file mode 100644 index 0000000..cc00da5 Binary files /dev/null and b/docs/math-expression-redesign/samples/tree.png differ diff --git a/docs/math-expression-redesign/usage-corpus.md b/docs/math-expression-redesign/usage-corpus.md new file mode 100644 index 0000000..aea4572 --- /dev/null +++ b/docs/math-expression-redesign/usage-corpus.md @@ -0,0 +1,124 @@ +# First-party expression and graph usage corpus + +## Search scope + +The default branches and available history were searched in: + +- `LTRData/Library`; +- `LTRData/LTRLib`; +- `LTRData/WindowsTools`, especially `GraphViewer`; +- `LTRData/MathTools`, especially `netexpr`; +- `LTRData/ltr-data.se` through the GitHub repository connection; +- the historical `richardneish/ltrdata` GraphViewer sources. + +Searches covered parser and graph type names, formula/expression settings and assignments, +common mathematical call syntax, documentation, tests, project files, resources, and +historical GraphViewer implementations. + +## Checked-in formula corpus + +### Application defaults + +| Expression | Source | What it demonstrates | +|---|---|---| +| `sin(x)` | Current `WindowsTools/GraphViewer/My Project/Settings.settings` | Unary variable, standard function call, case-insensitive historical input | +| `sin(x)` | Historical GraphViewer settings and application configuration | Long-lived default, unchanged across the scripting, generated-VB, and current parser eras | +| `sin(x)` | Disabled `ltr-data.se/coreweb/Pages/GraphViewer.cshtml.cs` | Same server-side plotting use case with invariant parsing | + +No other persisted end-user formula is checked into the first-party applications. The +GraphViewer combo box accepts arbitrary text and stores the most recent value in per-user +settings, but those user values are not repository evidence. + +### Current Library tests + +| Expression or family | Demonstrated intent | +|---|---| +| `sin(0.4) * 2` | Standard unary function and multiplication | +| `atan2(312,2)` | Two-argument function and comma separator | +| `e ** 2` | Named constant and `**` power spelling | +| `169 - 5 - 3 - 1` | Left-associated subtraction | +| `169 - (5 - 3 - 1)` | Parentheses | +| `169 (5 - 3 - 1)` | Implicit multiplication before parentheses | +| mixed `+`, `-`, `*`, `/` examples | Ordinary arithmetic precedence | +| `.1` and `.01` | Leading-decimal-point numeric literals | +| `1 << 10` | Shift operator was intentionally added to the legacy parser | +| unary sign chains such as `--35`, `+-35`, and `-+35` | Legacy prefix-sign handling | + +Tests prove that somebody deliberately added support, but they do not by themselves prove +that a feature belongs in the modern graph-oriented expression language. The shift case is +therefore classified as legacy-only pending a real consumer. + +## API and feature usage + +### Windows GraphViewer + +`GraphView.vb`: + +- owns one mutable `ScriptControl` and one retained `Surface`; +- assigns formula text and calls `Surface.Refresh` on recalculation; +- paints the retained function, derivative, integral, and axes; +- creates another surface for printing; +- exports BMP, GIF, JPEG, PNG, and TIFF through System.Drawing; +- lets the user retain old graphs by disabling clear-before-redraw. + +The persisted defaults are X `-10..10`, Y `-5..5`, derivative enabled, integral disabled, +and clear-before-redraw enabled. Overlaying prior plots is an intentional UI feature, but +retained `GraphicsPath` mutation is only its historical implementation. + +The project targets modern Windows TFMs and .NET Framework 3.5/4.0 from the same VB project. +That is evidence for keeping old-target compatibility in LTRLib until target support is +explicitly reconsidered; it is not a reason to constrain all new APIs indefinitely. + +### `netexpr` + +`MathTools/netexpr` accepts arbitrary formula text, discovers arbitrary parameters, asks +the user for invariant-culture values, dynamically compiles a LINQ lambda, and invokes it. +Its actual requirements are: + +- arbitrary variables; +- stable variable discovery; +- invariant input/output; +- direct evaluation. + +It does not require graphics, `x`/`y` special treatment, or a LINQ expression as the public +syntax model. Dynamic compilation is an implementation choice rather than a requirement. + +### `ltr-data.se` + +The disabled GraphViewer page caches compiled `ScriptControl` objects by formula, builds an +ImageSharp-era `Surface`, optionally draws derivative/integral curves, and encodes a raster +response. The current practical requirement is two server-generated PNGs: a function plot +and an expression diagram. + +The unbounded compiled-expression cache and unbounded image dimensions are application +risks to correct during integration, not behavior for a library API to preserve. + +## Symbol vocabulary evidence + +The current `MathFunctions` and historical `Functions.vb`/`Functions.vbs` provide aliases +such as `sqr`, `sgn`, `atn`, `sec`, `cosec`, `cotan`, arc variants, hyperbolic variants, +`ln`, `logn`, and `fac`. This proves historical availability, not checked-in formula use. +The modern standard catalog therefore starts with conventional names. A separately named +legacy catalog can restore aliases if migration data shows a need. + +## Previous-`y` recurrence search + +No checked-in formula intentionally reads `y`. The relevant uses of `y` are inside +`Surface.Refresh` and its VB ancestors, where the previous result is passed into the next +evaluation. The only checked-in application formula is unary `sin(x)`. + +Conclusion: the modern plotting input is a unary scalar function. Recurrence is category B +at most and should be a separately named compatibility concept if external evidence later +requires it. + +## Expression-tree renderer search + +No expression-tree renderer was found under names including `ExpressionTree`, +`ExpressionNode`, `DrawExpression`, or “expression tree” in the inspected first-party +default branches or the historical GraphViewer repository. The current parser's LINQ +expression output is a tree representation, but no observed first-party code lays it out or +renders it as an image. + +Expression-diagram design is therefore a new requirement, not a renderer port. If another +historical repository is identified, it should be reviewed as additional evidence and +classified rather than adopted automatically.