From fb859231eef6e0ff1048a86ebb2017b2395983d7 Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Thu, 3 Sep 2026 17:37:37 +0200 Subject: [PATCH 01/12] Document math expression redesign decisions --- docs/math-expression-redesign/README.md | 84 +++++++++ docs/math-expression-redesign/architecture.md | 173 ++++++++++++++++++ .../language-specification.md | 166 +++++++++++++++++ .../legacy-behavior-classification.md | 88 +++++++++ docs/math-expression-redesign/usage-corpus.md | 125 +++++++++++++ 5 files changed, 636 insertions(+) create mode 100644 docs/math-expression-redesign/README.md create mode 100644 docs/math-expression-redesign/architecture.md create mode 100644 docs/math-expression-redesign/language-specification.md create mode 100644 docs/math-expression-redesign/legacy-behavior-classification.md create mode 100644 docs/math-expression-redesign/usage-corpus.md diff --git a/docs/math-expression-redesign/README.md b/docs/math-expression-redesign/README.md new file mode 100644 index 0000000..dfadbba --- /dev/null +++ b/docs/math-expression-redesign/README.md @@ -0,0 +1,84 @@ +# 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) + +## Scope of this experimental 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. + +It deliberately does **not** implement SkiaSharp, PNG output, expression-diagram +rendering, System.Drawing migration, derivative/integral calculation, recurrence, +adaptive sampling, or LINQ-expression compilation. + +The existing `MathExpressionParser`, `IMathExpressionParser`, `MathFunctions`, and +`ScriptControl` remain temporarily so the experimental API can be reviewed without a +broad consumer migration. They 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. +- A future SkiaSharp edge package is justified because it isolates an external native + rendering dependency and different deployment requirements. +- System.Drawing compatibility remains in `LTRLib.Windows` initially. +- 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 + +- Whether the first SkiaSharp package should contain both plot and expression-diagram + renderers or use two edge packages. One combined package is currently preferred because + the dependency and deployment boundary is the same. +- 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 syntax diagrams need both source-oriented and semantic modes. Both remain the + likely design, but rendering is outside this slice. +- Whether new projects should eventually drop .NET Framework 3.5/4.0. This branch keeps + the repository target matrix while compatibility policy is decided. + +## Recommended next slice + +After this branch is reviewed and the language decisions are accepted: + +1. add expression-diagram content and measured-size tree layout inside + `LTRData.MathExpression`; +2. add one SkiaSharp edge package containing plot and diagram renderers plus PNG encoding; +3. integrate both PNG paths into `ltr-data.se` with input/resource limits and Linux tests; +4. only then evaluate derivative, integral, adaptive sampling, and Windows migration. + diff --git a/docs/math-expression-redesign/architecture.md b/docs/math-expression-redesign/architecture.md new file mode 100644 index 0000000..918f967 --- /dev/null +++ b/docs/math-expression-redesign/architecture.md @@ -0,0 +1,173 @@ +# Architecture, dependency boundaries, and proposed public APIs + +## 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 | Keep in `LTRLib.Windows` initially | Windows-only API and legacy TFMs | Windows GraphViewer | All portable/server consumers avoid System.Drawing | Yes: Windows-specific targets; compatibility release lifecycle | + +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.CreateBuilder() + .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/language-specification.md b/docs/math-expression-redesign/language-specification.md new file mode 100644 index 0000000..cec13b2 --- /dev/null +++ b/docs/math-expression-redesign/language-specification.md @@ -0,0 +1,166 @@ +# 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, allowing a future source-oriented diagram. Binding +removes syntactic parentheses, allowing a semantic operation diagram. Formatting and +diagram rendering are not implemented in this experimental slice. + +## 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..0afb5f5 --- /dev/null +++ b/docs/math-expression-redesign/legacy-behavior-classification.md @@ -0,0 +1,88 @@ +# 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/usage-corpus.md b/docs/math-expression-redesign/usage-corpus.md new file mode 100644 index 0000000..54fc4fa --- /dev/null +++ b/docs/math-expression-redesign/usage-corpus.md @@ -0,0 +1,125 @@ +# 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. + From 354622667e861be3aefa33e7f7d2a752ff84daf3 Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Thu, 3 Sep 2026 18:01:33 +0200 Subject: [PATCH 02/12] Prototype immutable math parsing and evaluation --- .../MathExpression/MathBinding.cs | 459 ++++++++++++++++ .../MathExpression/MathDiagnostics.cs | 44 ++ .../MathExpression/MathParser.cs | 496 ++++++++++++++++++ .../MathExpression/MathSymbolCatalog.cs | 233 ++++++++ .../MathExpression/MathSyntax.cs | 232 ++++++++ ...gacyMathExpressionCharacterizationTests.cs | 48 ++ Tests/ModernMathExpressionTests.cs | 187 +++++++ docs/math-expression-redesign/architecture.md | 3 +- 8 files changed, 1700 insertions(+), 2 deletions(-) create mode 100644 LTRData.MathExpression/MathExpression/MathBinding.cs create mode 100644 LTRData.MathExpression/MathExpression/MathDiagnostics.cs create mode 100644 LTRData.MathExpression/MathExpression/MathParser.cs create mode 100644 LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs create mode 100644 LTRData.MathExpression/MathExpression/MathSyntax.cs create mode 100644 Tests/LegacyMathExpressionCharacterizationTests.cs create mode 100644 Tests/ModernMathExpressionTests.cs diff --git a/LTRData.MathExpression/MathExpression/MathBinding.cs b/LTRData.MathExpression/MathExpression/MathBinding.cs new file mode 100644 index 0000000..4fe5373 --- /dev/null +++ b/LTRData.MathExpression/MathExpression/MathBinding.cs @@ -0,0 +1,459 @@ +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 (syntax is null) + { + throw new ArgumentNullException(nameof(syntax)); + } + + if (symbolCatalog is null) + { + throw new ArgumentNullException(nameof(symbolCatalog)); + } + + 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 (values is null) + { + throw new ArgumentNullException(nameof(values)); + } + + 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 (values is null) + { + throw new ArgumentNullException(nameof(values)); + } + + 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 (variableName is null) + { + throw new ArgumentNullException(nameof(variableName)); + } + + 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..15da225 --- /dev/null +++ b/LTRData.MathExpression/MathExpression/MathDiagnostics.cs @@ -0,0 +1,44 @@ +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) + { + Code = code ?? throw new ArgumentNullException(nameof(code)); + Message = message ?? throw new ArgumentNullException(nameof(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) + { + SourceText = sourceText ?? throw new ArgumentNullException(nameof(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..3e8f426 --- /dev/null +++ b/LTRData.MathExpression/MathExpression/MathParser.cs @@ -0,0 +1,496 @@ +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 (sourceText is null) + { + throw new ArgumentNullException(nameof(sourceText)); + } + + 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..ce8efb4 --- /dev/null +++ b/LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs @@ -0,0 +1,233 @@ +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 (function is null) + { + throw new ArgumentNullException(nameof(function)); + } + + AddFunction(name, new MathFunctionDefinition(function)); + return this; + } + + public MathSymbolCatalogBuilder AddFunction(string name, + Func function) + { + if (function is null) + { + throw new ArgumentNullException(nameof(function)); + } + + 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..1b70ada --- /dev/null +++ b/LTRData.MathExpression/MathExpression/MathSyntax.cs @@ -0,0 +1,232 @@ +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 (start < 0) + { + throw new ArgumentOutOfRangeException(nameof(start)); + } + + if (length < 0) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + 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; + Lexeme = lexeme ?? throw new ArgumentNullException(nameof(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) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + public override MathSyntaxKind Kind => MathSyntaxKind.Name; + + public string Name { get; } +} + +public sealed class ParenthesizedSyntax : MathSyntax +{ + public ParenthesizedSyntax(MathSyntax expression, SourceSpan span) + : base(span) + { + Expression = expression ?? throw new ArgumentNullException(nameof(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; + Operand = operand ?? throw new ArgumentNullException(nameof(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) + { + Operand = operand ?? throw new ArgumentNullException(nameof(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) + { + Left = left ?? throw new ArgumentNullException(nameof(left)); + Operator = @operator; + OperatorSpan = operatorSpan; + Right = right ?? throw new ArgumentNullException(nameof(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) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + NameSpan = nameSpan; + + if (arguments is null) + { + throw new ArgumentNullException(nameof(arguments)); + } + + 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/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/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/docs/math-expression-redesign/architecture.md b/docs/math-expression-redesign/architecture.md index 918f967..39ad2cd 100644 --- a/docs/math-expression-redesign/architecture.md +++ b/docs/math-expression-redesign/architecture.md @@ -76,7 +76,7 @@ double result = expression.Evaluate(valuesBySlot); Custom symbols are explicit: ```csharp -MathSymbolCatalog symbols = MathSymbolCatalog.CreateBuilder() +MathSymbolCatalog symbols = MathSymbolCatalog.Standard.ToBuilder() .AddConstant("g", 9.80665) .AddFunction("square", x => x * x) .Build(); @@ -170,4 +170,3 @@ The existing public classes remain untouched in the experimental slice. Later mi targets migrate first. No types named `ScriptControl`, `Surface`, or `Refresh` are added to the modern API. - From 2844e9489a35bee9cb2383902fdd3dffbb11978a Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Thu, 3 Sep 2026 18:02:48 +0200 Subject: [PATCH 03/12] Prototype parser-independent function plotting --- .../FunctionPlotting/Geometry.cs | 290 ++++++++++++++++++ .../FunctionPlotting/NumericRange.cs | 59 ++++ .../FunctionPlotting/Sampling.cs | 161 ++++++++++ .../LTRData.FunctionPlotting.csproj | 8 + Library.slnx | 1 + Tests/FunctionPlottingTests.cs | 141 +++++++++ Tests/Tests.csproj | 1 + docs/math-expression-redesign/README.md | 1 - .../language-specification.md | 1 - .../legacy-behavior-classification.md | 1 - docs/math-expression-redesign/usage-corpus.md | 1 - 11 files changed, 661 insertions(+), 4 deletions(-) create mode 100644 LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs create mode 100644 LTRData.FunctionPlotting/FunctionPlotting/NumericRange.cs create mode 100644 LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs create mode 100644 LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj create mode 100644 Tests/FunctionPlottingTests.cs diff --git a/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs b/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs new file mode 100644 index 0000000..f3dbdaa --- /dev/null +++ b/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs @@ -0,0 +1,290 @@ +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 (viewport is null) + { + throw new ArgumentNullException(nameof(viewport)); + } + + return new CanvasPoint( + (x - viewport.XRange.Minimum) * viewport.Canvas.Width / + viewport.XRange.Length, + viewport.Canvas.Height - + (y - viewport.YRange.Minimum) * viewport.Canvas.Height / + viewport.YRange.Length); + } + + public static CanvasPoint ToData(double canvasX, double canvasY, + PlotViewport viewport) + { + if (viewport is null) + { + throw new ArgumentNullException(nameof(viewport)); + } + + return new CanvasPoint( + canvasX * viewport.XRange.Length / viewport.Canvas.Width + + viewport.XRange.Minimum, + (viewport.Canvas.Height - canvasY) * viewport.YRange.Length / + viewport.Canvas.Height + viewport.YRange.Minimum); + } +} + +public static class CurveGeometryBuilder +{ + public static CurveGeometry Build(SampleSeries series, PlotViewport viewport) + { + if (series is null) + { + throw new ArgumentNullException(nameof(series)); + } + + if (viewport is null) + { + throw new ArgumentNullException(nameof(viewport)); + } + + 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) + { + var deltaX = x1 - x0; + var deltaY = y1 - y0; + var start = 0d; + var end = 1d; + + if (!ClipTest(-deltaX, x0 - xRange.Minimum, ref start, ref end) || + !ClipTest(deltaX, xRange.Maximum - x0, ref start, ref end) || + !ClipTest(-deltaY, y0 - yRange.Minimum, ref start, ref end) || + !ClipTest(deltaY, yRange.Maximum - y0, ref start, ref end)) + { + return false; + } + + var originalX = x0; + var originalY = y0; + + x1 = originalX + end * deltaX; + y1 = originalY + end * deltaY; + x0 = originalX + start * deltaX; + y0 = originalY + start * deltaY; + + return true; + } + + private static bool ClipTest(double direction, double distance, + ref double start, ref double end) + { + if (direction == 0d) + { + return distance >= 0d; + } + + var ratio = distance / direction; + + if (direction < 0d) + { + if (ratio > end) + { + return false; + } + + if (ratio > start) + { + start = ratio; + } + } + else + { + if (ratio < start) + { + return false; + } + + if (ratio < end) + { + end = ratio; + } + } + + return true; + } + + 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/Sampling.cs b/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs new file mode 100644 index 0000000..9523653 --- /dev/null +++ b/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs @@ -0,0 +1,161 @@ +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 (samples is null) + { + throw new ArgumentNullException(nameof(samples)); + } + + 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 (function is null) + { + throw new ArgumentNullException(nameof(function)); + } + + 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); + } + + private 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..4f619fc --- /dev/null +++ b/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj @@ -0,0 +1,8 @@ + + + + 0.1.0 + $(Version) + + + diff --git a/Library.slnx b/Library.slnx index 12cb74a..ca238f3 100644 --- a/Library.slnx +++ b/Library.slnx @@ -7,6 +7,7 @@ + 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/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/math-expression-redesign/README.md b/docs/math-expression-redesign/README.md index dfadbba..a30786b 100644 --- a/docs/math-expression-redesign/README.md +++ b/docs/math-expression-redesign/README.md @@ -81,4 +81,3 @@ After this branch is reviewed and the language decisions are accepted: 2. add one SkiaSharp edge package containing plot and diagram renderers plus PNG encoding; 3. integrate both PNG paths into `ltr-data.se` with input/resource limits and Linux tests; 4. only then evaluate derivative, integral, adaptive sampling, and Windows migration. - diff --git a/docs/math-expression-redesign/language-specification.md b/docs/math-expression-redesign/language-specification.md index cec13b2..8ae34d3 100644 --- a/docs/math-expression-redesign/language-specification.md +++ b/docs/math-expression-redesign/language-specification.md @@ -163,4 +163,3 @@ The initial modern language omits: - 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 index 0afb5f5..f19e565 100644 --- a/docs/math-expression-redesign/legacy-behavior-classification.md +++ b/docs/math-expression-redesign/legacy-behavior-classification.md @@ -85,4 +85,3 @@ New tests use explicit naming/categories: 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/usage-corpus.md b/docs/math-expression-redesign/usage-corpus.md index 54fc4fa..aea4572 100644 --- a/docs/math-expression-redesign/usage-corpus.md +++ b/docs/math-expression-redesign/usage-corpus.md @@ -122,4 +122,3 @@ 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. - From d178c3782607f4d05774ec6c0f24e0e470857b1e Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Tue, 8 Sep 2026 12:47:29 +0200 Subject: [PATCH 04/12] Add portable expression diagram content and measured tree layout --- .../Diagrams/ExpressionDiagram.cs | 166 +++++++++++++++++ .../Diagrams/TreeDiagramLayout.cs | 175 ++++++++++++++++++ .../LTRData.MathExpression.csproj | 5 +- Tests/ExpressionDiagramTests.cs | 96 ++++++++++ Tests/ModernExpressionCorpusTests.cs | 43 +++++ 5 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 LTRData.MathExpression/Diagrams/ExpressionDiagram.cs create mode 100644 LTRData.MathExpression/Diagrams/TreeDiagramLayout.cs create mode 100644 Tests/ExpressionDiagramTests.cs create mode 100644 Tests/ModernExpressionCorpusTests.cs 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..8f9d2fd 100644 --- a/LTRData.MathExpression/LTRData.MathExpression.csproj +++ b/LTRData.MathExpression/LTRData.MathExpression.csproj @@ -1,8 +1,9 @@ - 1.0.23 - $(Version) + 1.1.0-preview.1 + 1.1.0 + $(Version) 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/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); + } +} From 4862fea8c8fd1d7f7702fd5aad467f5172795e80 Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Tue, 8 Sep 2026 12:47:52 +0200 Subject: [PATCH 05/12] Keep plot clipping and transforms finite at extreme values --- .../FunctionPlotting/Geometry.cs | 109 +++++++++--------- Tests/CurveGeometryPrecisionTests.cs | 39 +++++++ 2 files changed, 91 insertions(+), 57 deletions(-) create mode 100644 Tests/CurveGeometryPrecisionTests.cs diff --git a/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs b/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs index f3dbdaa..43dea46 100644 --- a/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs +++ b/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs @@ -123,11 +123,11 @@ public static CanvasPoint ToCanvas(double x, double y, PlotViewport viewport) } return new CanvasPoint( - (x - viewport.XRange.Minimum) * viewport.Canvas.Width / - viewport.XRange.Length, + (x - viewport.XRange.Minimum) / viewport.XRange.Length * + viewport.Canvas.Width, viewport.Canvas.Height - - (y - viewport.YRange.Minimum) * viewport.Canvas.Height / - viewport.YRange.Length); + (y - viewport.YRange.Minimum) / viewport.YRange.Length * + viewport.Canvas.Height); } public static CanvasPoint ToData(double canvasX, double canvasY, @@ -139,10 +139,10 @@ public static CanvasPoint ToData(double canvasX, double canvasY, } return new CanvasPoint( - canvasX * viewport.XRange.Length / viewport.Canvas.Width + + canvasX / viewport.Canvas.Width * viewport.XRange.Length + viewport.XRange.Minimum, - (viewport.Canvas.Height - canvasY) * viewport.YRange.Length / - viewport.Canvas.Height + viewport.YRange.Minimum); + (viewport.Canvas.Height - canvasY) / viewport.Canvas.Height * + viewport.YRange.Length + viewport.YRange.Minimum); } } @@ -218,66 +218,61 @@ public static CurveGeometry Build(SampleSeries series, PlotViewport viewport) private static bool TryClip(NumericRange xRange, NumericRange yRange, ref double x0, ref double y0, ref double x1, ref double y1) { - var deltaX = x1 - x0; - var deltaY = y1 - y0; - var start = 0d; - var end = 1d; - - if (!ClipTest(-deltaX, x0 - xRange.Minimum, ref start, ref end) || - !ClipTest(deltaX, xRange.Maximum - x0, ref start, ref end) || - !ClipTest(-deltaY, y0 - yRange.Minimum, ref start, ref end) || - !ClipTest(deltaY, yRange.Maximum - y0, ref start, ref end)) + 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++) { - return false; - } - - var originalX = x0; - var originalY = y0; - - x1 = originalX + end * deltaX; - y1 = originalY + end * deltaY; - x0 = originalX + start * deltaX; - y0 = originalY + start * deltaY; - - return true; - } - - private static bool ClipTest(double direction, double distance, - ref double start, ref double end) - { - if (direction == 0d) - { - return distance >= 0d; - } - - var ratio = distance / direction; - - if (direction < 0d) - { - if (ratio > end) + 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) { - return false; + y = (outside & 8) != 0 ? yRange.Maximum : yRange.Minimum; + x = Interpolate(x0, x1, Fraction(y, y0, y1)); } - - if (ratio > start) + else { - start = ratio; + 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 { - if (ratio < start) - { - return false; - } - - if (ratio < end) - { - end = ratio; - } + 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)); + } - return true; + 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) 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); + } +} From c6324caa7eccc9f41c0739fe049e3d078714974f Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Tue, 8 Sep 2026 12:49:32 +0200 Subject: [PATCH 06/12] Add SkiaSharp plot and diagram PNG renderers --- .../LTRData.Graphics.SkiaSharp.csproj | 14 ++ .../SkiaDiagramRenderer.cs | 98 ++++++++++++++ .../SkiaPlotRenderer.cs | 75 +++++++++++ LTRData.Graphics.SkiaSharp/SkiaPngEncoder.cs | 20 +++ Library.slnx | 2 + Rendering.Tests/Rendering.Tests.csproj | 15 +++ Rendering.Tests/SkiaRenderingTests.cs | 85 ++++++++++++ docs/math-expression-redesign/README.md | 36 ++--- docs/math-expression-redesign/architecture.md | 3 + .../language-specification.md | 8 +- .../rendering-slice.md | 126 ++++++++++++++++++ .../math-expression-redesign/samples/plot.png | Bin 0 -> 6741 bytes .../math-expression-redesign/samples/tree.png | Bin 0 -> 6306 bytes 13 files changed, 461 insertions(+), 21 deletions(-) create mode 100644 LTRData.Graphics.SkiaSharp/LTRData.Graphics.SkiaSharp.csproj create mode 100644 LTRData.Graphics.SkiaSharp/SkiaDiagramRenderer.cs create mode 100644 LTRData.Graphics.SkiaSharp/SkiaPlotRenderer.cs create mode 100644 LTRData.Graphics.SkiaSharp/SkiaPngEncoder.cs create mode 100644 Rendering.Tests/Rendering.Tests.csproj create mode 100644 Rendering.Tests/SkiaRenderingTests.cs create mode 100644 docs/math-expression-redesign/rendering-slice.md create mode 100644 docs/math-expression-redesign/samples/plot.png create mode 100644 docs/math-expression-redesign/samples/tree.png diff --git a/LTRData.Graphics.SkiaSharp/LTRData.Graphics.SkiaSharp.csproj b/LTRData.Graphics.SkiaSharp/LTRData.Graphics.SkiaSharp.csproj new file mode 100644 index 0000000..8a0f5fc --- /dev/null +++ b/LTRData.Graphics.SkiaSharp/LTRData.Graphics.SkiaSharp.csproj @@ -0,0 +1,14 @@ + + + net8.0;net9.0;net10.0 + 0.1.0-preview.1 + 0.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/Library.slnx b/Library.slnx index ca238f3..03c69d9 100644 --- a/Library.slnx +++ b/Library.slnx @@ -10,6 +10,7 @@ + @@ -19,4 +20,5 @@ + diff --git a/Rendering.Tests/Rendering.Tests.csproj b/Rendering.Tests/Rendering.Tests.csproj new file mode 100644 index 0000000..ca53ad3 --- /dev/null +++ b/Rendering.Tests/Rendering.Tests.csproj @@ -0,0 +1,15 @@ + + + net8.0;net9.0;net10.0 + 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/docs/math-expression-redesign/README.md b/docs/math-expression-redesign/README.md index a30786b..3f05907 100644 --- a/docs/math-expression-redesign/README.md +++ b/docs/math-expression-redesign/README.md @@ -16,8 +16,9 @@ classified before it becomes a test or a public contract: - [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) -## Scope of this experimental slice +## Original core slice The branch validates only these boundaries: @@ -31,9 +32,10 @@ The branch validates only these boundaries: - explicit sample status for non-finite values and evaluation failures; - backend-neutral Cartesian polyline geometry with viewport clipping. -It deliberately does **not** implement SkiaSharp, PNG output, expression-diagram -rendering, System.Drawing migration, derivative/integral calculation, recurrence, -adaptive sampling, or LINQ-expression compilation. +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. System.Drawing migration, derivative/integral calculation, +recurrence, adaptive sampling and LINQ-expression compilation remain deferred. The existing `MathExpressionParser`, `IMathExpressionParser`, `MathFunctions`, and `ScriptControl` remain temporarily so the experimental API can be reviewed without a @@ -48,7 +50,7 @@ broad consumer migration. They do not define the new language. 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. -- A future SkiaSharp edge package is justified because it isolates an external native +- The SkiaSharp edge package is justified because it isolates an external native rendering dependency and different deployment requirements. - System.Drawing compatibility remains in `LTRLib.Windows` initially. - The modern expression language is invariant-culture, source-preserving, and @@ -60,24 +62,22 @@ broad consumer migration. They do not define the new language. ## Remaining design questions -- Whether the first SkiaSharp package should contain both plot and expression-diagram - renderers or use two edge packages. One combined package is currently preferred because - the dependency and deployment boundary is the same. +- 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 syntax diagrams need both source-oriented and semantic modes. Both remain the - likely design, but rendering is outside this slice. +- 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. -## Recommended next slice +## Current review checkpoint -After this branch is reviewed and the language decisions are accepted: - -1. add expression-diagram content and measured-size tree layout inside - `LTRData.MathExpression`; -2. add one SkiaSharp edge package containing plot and diagram renderers plus PNG encoding; -3. integrate both PNG paths into `ltr-data.se` with input/resource limits and Linux tests; -4. only then evaluate derivative, integral, adaptive sampling, and Windows migration. +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. +Review the [API, sample images and deployment notes](rendering-slice.md) and the +website review host before evaluating derivative, integral, adaptive sampling and +Windows migration. diff --git a/docs/math-expression-redesign/architecture.md b/docs/math-expression-redesign/architecture.md index 39ad2cd..063f188 100644 --- a/docs/math-expression-redesign/architecture.md +++ b/docs/math-expression-redesign/architecture.md @@ -1,5 +1,8 @@ # 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 diff --git a/docs/math-expression-redesign/language-specification.md b/docs/math-expression-redesign/language-specification.md index 8ae34d3..aa6ce15 100644 --- a/docs/math-expression-redesign/language-specification.md +++ b/docs/math-expression-redesign/language-specification.md @@ -147,9 +147,11 @@ variable matching the requested name. It fails explicitly when other variables r 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, allowing a future source-oriented diagram. Binding -removes syntactic parentheses, allowing a semantic operation diagram. Formatting and -diagram rendering are not implemented in this experimental slice. +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 diff --git a/docs/math-expression-redesign/rendering-slice.md b/docs/math-expression-redesign/rendering-slice.md new file mode 100644 index 0000000..b5ff609 --- /dev/null +++ b/docs/math-expression-redesign/rendering-slice.md @@ -0,0 +1,126 @@ +# 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 or full-site build 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. +The full site still requires its existing private `webdbcore` package/feed and normal +deployment configuration. + +## Packaging and review + +The expression package now uses `1.1.0-preview.1`; the new renderer uses +`0.1.0-preview.1`. This gives the consumer an explicit prerelease dependency instead +of resolving the already published expression package without these APIs. No NuGet +release is performed by this work. Function plotting retains its prototype `0.1.0`. + +The companion website supports `LibrarySourcePath` for reviewing these projects +directly. Its review host defaults to a sibling `Library` checkout. See the website's +`docs/math-rendering-review.md` for the one-command UI and HTTP verification paths. + +After review, package the math, plotting, and renderer projects into your normal +feed, or continue using an explicit source reference during development. + +## Still deferred + +Derivative/integral calculation, recurrence, adaptive sampling and finite-asymptote +detection, Windows migration, semantic diagrams, and optional compilation remain +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 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 0000000000000000000000000000000000000000..502cdf46cafc82730108d78bf286aeacf46a55e6 GIT binary patch literal 6741 zcmeHMc|6qH`~S=s5ee0hgo%o>mt7blQBm2FWrU(ELzBohBe~f}NJPd>bz5Yq>@sta zX|d*_?8_Zo%ZzPovwhEWzkmGx{{8*+dJV5R=X{>?JkNPQ@8^9!lFr*%i;3(LfgniC z=CtKS2!azJ2<9)m4UABa*M^R6e#*2JK{o`&~8mA8}IJ*1+!-7KmUlSg3hZ` z=P!AzALeUmx!+(UhpDpTZt1sv@BZ#SVBHYw-Zu$dwS_Dm=4;oQDk|<9_3w<_v+d80 z)*LW>40ragz8zm8_ZOYrj(&Ob{U3ijL&QQU!Vwk3n1^VqpNYu%Rs_niHqYUvDgmw~ z<|giun3#yH!pG%H5YE%C(W(jJq`GYWp6#0gs595nzC`XRO8sRE+ZhB?xN{P6u3rfn z?!NUxY4$hqB&*{HREQ5;;`LCfO)ZkLd)laYZn?6A`ujc4Z)IF{k`GI|#tuMUB4)o0kt9-*tZqI6PA zlkRK=#6poLAq$HsyGlX#b9(cpvg0hXA_cJe+4Rx9ZWO2S%9x!5h@%7@IPiY?kvS=7 zA-yVH)s@%`8Nzm~K7#JGs1<4(HD!e$%fLDR2z#chsOa3b0)o^BjyV{pdCAtiLpt+v z+V<_P)IIr5Ug)J3n0Ox&87#P16pum%&6%(BU>s%WfHn?nAWHdKbrl~h*u^5F@gS75 z;|D_~k97X&^zN+?4i>@NC5;w{;V(|eqOcwhfO3g73YqZMsrE>Bb(r<}{$rqo3Okws z{NiWppR7}smMs-M&M?*P*5PSIcOHBP0YIv~9WNB|*+~m|9mi#nAC}@_TQ-oz-6^|v z55Y;Bny-#E@F@);G$D2uA)~0tJVSsg==IzX)OdPfFj2DkeoLmS4?mRiAQT<^1;nIo`e56nr~eZy>vv4ET(x3iY}$wU8aNf+@zYW>?jKhD*<7V#m> zJxRYK{F=$mm@XXDvfaxI0<$+fxu`@2q-q`2-}@u%gKiTTC-BGZ{vD^jiON@6UUG3e zeh@H-$YJQf1(1uA}z5ZQ&WOB}XHStPl)pO$kqf$^q)#WtG=FEJf)EPfwnq=k4_aNR7 z5U(C1oo$}+qN>DtTuP7L5yPv#+1Nv-Pym20jT@tt{-LdI(5Uw;HUu5Pw^+k~yLl(CeBDeO ztx7;&7e*IR+3YYaA$tSi%!ePTmnQ=Q!q3WJWZ%C?Z3CSKJYQrzesGaqTow2JT`VgP zcvkIwv#%I(It#%~M3mzMy>9ExpOBT56xVnHdJ7u4n}8--9WOw147`YGm$ZWf_yNf$ zASotxa6j*FaVy>3G3vl0q*(ENKS1xF5U4JnK(0+q-Wo-OxvAKSD;wtR2f6!B5|N`BnxzjD`ar z^v*1+5_aC*kq$f)*IZ}IYJnPE)Js_ohc%9vYq|Z-+9WiA-MhrHN1rHA6XQu{*kcCX)gIbV1{kL zk>Hmh628;}%5faSU3TVFi9{D;Uoua+zL`7Rind6G~M$+|TN zU-a#q76@t2OmZc{51s~_IP#NW*J&a;ZIv-$7(IKjA+%BTRIgYX4M)9dvNHn(6CQHB zA{*i7bEdD@F)ghgci-#;Z=0_bc(yW--dP=YB%M6+V2~TeQ^)}2JM`DCS~79RdAj;^K5~4Q?to7w9{{Y?j;6 zV>vl(Uq^-fks*{mpr3f9%q^je1Hkgdaf=ysp$&V>+>S3flj zWnFb!6Qyt?uKQ_lo!jK3vs&vwqI3AOuAMexq5uXD3P;yKV?7bc_^ZSkCP6?m)bg|3 zU_-H&pf&1Nke{OBgybL@EdyrCQe~ZXA3vOy*g+4dm^&TNH|dzoITsW<8|`;C=w*sv zk2R=g0x*ts;u__?6^ar1A>C=n5zUqXo(>F)?gKsQeLN~=`7c#v#Re`cTarH>6F%ix z7J@v>cv9Pi92BQ%bj60e5-o~bMnQ3&`FQF}|Ggf&tw;Qc$JISRu}J>PHTS!R!_%s3 zr-5F!1RPf8-0>|&s3vs1w#53Cu>CX%AH}%QPljj!o-r3Aq$7G7r%J4kL@ju@OxC^y zTDJ0A5D)&}S;-dj47aEy=6lJ_ezU`o903(2xWr7>!%b)iNT7lG(rYGmu2HwYy-G~n zc*qI_&jC!uT-1;DS0m*|Npt#^GgL6^&#cw&b zZMiytgjcqN&U*s3^IO;G7#6Bw7KO)Q|5kPb7mGwpEh8PIKSCB{(+(n@5d7zHMWflrahzl{Lr?K^*Q#a;d}B2Q>AuAYfke%~ZX_ z)~UkPHBG*D2H&OiEQKj^k&0vvi(dZ%Xh=#J;B^eW{&=`Vi<1_Xxiy~SZ`32{GkgEj z0L>YSTL!HuHV^dhDLXkCx#9k3p$$>cR2XhCQGoh~GC-6Iji7B*#o_5mg+1gRpyJ1V z1AAe`)!IFTIZbnZ@8qAW%?Gvx@zUiC)#NYh9hnP-fR=>zEtF~!& zG|DB4C9Q%5S+C~bxo(cs2zNGFv72a}#5>?P!1Z8nM$`4Nw5adgogvc?atv}txiMY; zhNp!}fT9hT;!*aE?gpFyU`~IM_4NtXep|UW7)i+G_~VaOUV&tg>PRj&?x*%#-2}g z$l9Q>zno#@b#I4zy=FyC0J|z8Wp_`I)!#zVTfIZ;c!fLur$CNZA47=br;BR~zj;h& z*6y78lBWEW_WfZ^)c8iOVXY}hX5={tZgl};tV8d_h6%W)${X9s->BI3U1Kb zA`3D%)-VbwiHTG?S!wP9v;_*xYW+MnXiOPn_<*TmwZ5vVJn0?~uJ%~lNUDADzjkgh zE*lHQK&H<| z`QQZBh$HH#1#&R_x$eqRsZ(#Y$VS?9I%t)X0WUt~MuziX4}o+zw8UlI>@PmSxxqw? zRrgK97bh8oPnar@0MK`t5$hh{Jp0$4lqL62@Jm^(27?1~DSoNx$dy*mlPCmmya!Ja z_ewE3umUd4*T>3Cvy9nGN$YLk=1qVBrSv6wEbcm>`(_vWt0rFwfrcTbYr2)6o>f#X z$=dPYPH5o5$`4MD_x$H871uGi{l-0oOasoE8Odmmq$HV}_{fYf3qY6|IXXLZB?J4` z47^j|+++t@pQzBTPT=R8?!{vOk-J(ra@5XZV=Sp@|Y~+9m~w=bYBx& zQuO+#7yQe`n<-)n4d=muJBq`Evxlq|I)Vgvz2B6dR&s3nCRYdWtUrOW-S{Z32BazB z=`Ij{JGkbHt3BE4@u=+6y64N(W)y~H<5LhwPc=UL7xP4b(0O%GnJT8-(xH{>qY+&H zzT(xdkGPm%Cc{DDGSHil6_AAHOEVuGd?s?oE2b~G1sX>jmr)DmOo7A2hEC%wxhkeqSbhrt-EFITV73d zlu&C+d8{6v3K}IL*EH0UJ#ZP|J`9M&qCD+art%+^*N=U6S(a9Nl;0T}g$0x`d(xRl zm^}m>Vj2@^8jjqr#GTaV9m{pQrrhR;t$c3+Y-2ia>!Nk}RxTT2qt!^pdPa31c+AnV z>2XuuFuNMB@L@~HQ6DC}qt0w&W&&iahwY?F8xtKJl;+d@pC2wPjk8&wpk7-LQ1HU? zmx>$Q8iUwUrN&xQRurt$eJ0r~N-_M{=5ajM3UtWq0tj(z$$t3_0kc7GoT-ae?{Vca zNmwGd{V&a!I82rbisf~{we})V8A(DpcFFZpsl*rW<(B;HeXEfTcknoNNDsgJ>60S- zuHm2^hhf6LQzY(FMtzdmK4V$8Z42SVCkyG=h7b)vSG=fj z@c{-rJ3c&cq;a~v>EH3t&rrC5_+s3{T*HyNwbVz#^pb~He)KlGi!K7*D{0GhSHNto&h9mxui99Z)~D{) zB<<}Q9gLl^U=NUEv~TNGwZ@-OZYA-?Iac||Uk}Zfc>X1U*_;k|8oZIQG2DqwHrtBi zRmIENdnDr*f4cY#{w`YKJC(aJ#HeG4qX1Ww(#bfB=mkt^(u1&*lV{8fB*?ty?V(#1 zeq}rsgS~pGgcDC{0S;~~#`jFV>MTjw?(^HDTh|D->uir<1y=56RKuL`z0lrl_!1vnXjmbrvG>;YpPSHfk_Dx(rhVJQV*?I0Vi7Gdh9?f zPbp=h&}w%nOJ5L`aSSHxnA*ae*~=dtCbNj0#H+a0;Z-cSKVOAQ zygbc6(6R+Hg-2y&pJ)WYlTe*i<(YO??U literal 0 HcmV?d00001 diff --git a/docs/math-expression-redesign/samples/tree.png b/docs/math-expression-redesign/samples/tree.png new file mode 100644 index 0000000000000000000000000000000000000000..cc00da531f26c7d0164b42d3e2f0ef55e7a5c0c7 GIT binary patch literal 6306 zcmeHLYdD+fy8awm+9K_;W>j0Wo6-}e6h(to)2czWL#h#nb~~iRsp1qwSe>b^wit(M z5m9YXRFqVl53MPUQ&1sJ36+C}QY1)-tS`N0?Q73m`?{t-*8a1v{rz~8eBYbrd*A1M zp8LL^=O33Y+9~hcw-W#WW%~=?{|ErGi2xvTLvcHJr_<<%%ixDR`mDXHB6!6s`rZM6 zy>R>Q|K=Kl|1=pFGZ~r0U2={seE2FL)3m7Qocm6)hx=oFv!W@vyLZ~2)R7wc42Z<; zyHDna4h+5<&g_})@7IU++b;O6S)xVMdn~lLX_l5o+Z483lf6OHZ3>J^fYu#Ue zdGzW|%AyW)WX?Jxysw1GTb4;~$Y zK>)h?Cyo8pPa>V%$Hz$6@zpYJQ`_U$w~Pu)4`u3}D!xASu8a$8!Xv>WnuMhRF(>yo znzGC07P_=?R|z@+n$`xl6h?;csg09^sbxqvJ$wIosCmhO=o{yim=!!YVE6+dYl32z z)d*k1%!(3Ru_^@Ywbe(j;%J#(gUi|9z$2(~A9-Vsq6@+!bWPJLV*k>nvcu-FKlal` zYu`eqF8gJ@f*X|W2M^;;1_WbuwKls|%LuYBZEFM;JKI_tSJ&435z$6#Q*f9wW|Y&L zldq4cY_hBk&o~h#Rc@y*u{k1v=_Mxa%!HzVQXAUMqh5a9uN;K=>o4?Vw>DFA$O8bMq&5k6O8 zxycA95uZz|%*SvP!bKk?MUZS6*LDr*tLx+xm14ZN8Vy(1$DGV2@#sEtGi&k3G-JQ6 ztRJFeT;G0~^@jzn?^e9@>}eXIVB4;!&8OFa?H19146sxCYlfs;<9K?%j?_D4pfk4W z#A&T+iSGXae0dO}4h{4j8-7s%`VlcnHdTIV=fD1P599<0z(1Y7xy*(`&vK$ zYo75lo{Y`ncta#2yTZd$a6eZXsX7$ZZg(&iU5XtKJUnE(-3=0!0kXhf&;OeT{|hv^MAj&I zH6m!1SJNq(@i9cyTcE}*<33@|ryL+GIVm@Hdw#iBqugpC3hy5JiAX_XAEpo-x7a#W4o-&u+eJo6pQL-F5 z!hCRZCbxQ#Uz&6J=2@Oy>WkRnK(Mxaz>1g;p69<4xFAbfl+y?Vs>tJ zd`hDAp2Yr6cS1mSEiT?O)tq<7G0!4QGKHOoICD6NZ&|I9xBE$*3r;ZVN0?ZUn;nso zsJ47)_pB|5bluLx7AOfaQR)5j8{#9Y;L0A0YyJu0f={F5U<3^v>x645F7nWRtS6(0 zogL6~D@NP8aSog*=N-lV=fWWpsRiw$i4*0NmGE=75}X|B&f7T4?iTfT!BFpqPLX-) z*g%gN`U{U5@N2XGL67%I2`PyL`GfpIlTZ2TCS@<2JyS#WJ|IVGz^Jn(a?`mge3U$2 z2PlNl4wpK3(u_r}p1cg1&C8^uFee3(iEQj&K>GiA&z}t6wN)T$4U)CC8a!^keE?$P zU{^6}dc(2KbFD0WWAUL?UWn&=|PEzT) z*{P8?8O?+W*VX_{+$Lg~vDTu8#4lMBB+&@>E3Dv9OMPU%;59zHiz0l8t3PJOh(Qm( zfM=ScRVbE>rPUt1Iq3~k186=6oVBhAn|_1GaNvgPWAB}<^XDpC4@EPv&IZl=U6z8= zbHf$gLN3mtmf12Ttkm~zIWyvo!?vvCha$&bl~#(PqAof(I2hDTk?PkN8{x|oJ@!1w zIzuE9fypkH2>g`5YnUTCXLv+RBg5&_$t5ibzGLI#E9*09$bmPF(W09zYt6VWd%b28 z$tG2jMak4dw%=A33aOP{-?(|?FgH17hIOb5Oblomy(KKTXlI9HNVeA42&U*_Lqo&z zQX1}rva&LaDkn0RQ54fzl+2Y5v*weLk&*Tp#l^*^OicV3O&Ck@USnim&nOb7^Ynbm z4;f1PO;0Q)tTir|dy6gB7;)Z}!Q8jd2L1p(daJ}JVRPLhe(BsOntC(`8W&~IFq`2K zGyPV`(z8zQ=;$az%8DOBl5DIaS?PAZ!ew^-a#tw3Fj?~=63I1y_Dv^iiQ+fK123<) z8~JpgNI|2^ZUOq1b4d!CJ)M2R82{ef2Mz`pgRKnK%XXDs_CxRN)z#H_o{QSEnn)PU zB`LuXJQ)+*)@7k+q&1gJCdbd(BH45wW|owK+&l&MUU;`AlxT6^dR;tV5pkozG$j5T zwH59OkGdmV{K^tOmWO*+&n3K{8FS2UaI=yK9gIRI>hRGFVL(7YY0&ZhR?LpQqEgU- zJ^7YqXl`Gxopo%KI~YCbcA6AO!sqsh^~SvLrNe6ezuwscqq6)SSw>$>5YKtHuqp$B zf_k>8#g|b{W#lq}pJcAlUtZ5K4Gt`J%FBIyOMx4@#o0Uouhvn}iVvf`2cG}UOZIjG zZ_uGNYk$bPC-@TI_r*@5O&ByGmRC9vzp|LAGhPdF%AUl1Y?Xt2mwRf46z7EH_ZkU{ zvroC)M7h<)6(@iX0rR+&id}ee;%b=PLdyo>%<{WIrG2gsn^(t@y!e34eYkD(%((ElaMOwngGHq>a=%3_ne=?7k zv5+M&i-;((jEsa;&O-k?E|{dMKS)jOD?{>21#^~zr=uch#<9INb;WYxeKOD;HnX;> zdHYEX!A>A{ID*_cHa6^^#_9MYKr!MjzI!wv0QpiI=KN6=VzqgpbGH;AedCOXs|$5=gcFFCCp`0J-bc26J!+uY{8Mi-vj1_?MnUdYypuyc2!2v^ zDc+du?uSgavM8I&w|^c*ieAkdW8KfEX+9{l6-m5%oBg`8Uo6h{fQU}@NwZGROtUFrX^qCu$Z9k;#WHn&)`hT@9y5e_eJfwIF+3%_Hgqvb;@wDPVfuse2Ib?WCPh24RU4k$!OW1KxBYIBc? zI?ld&4W-M;no;Slxc4nmXdDcqQeY#K>Hp|W!MwkmJmfone{Tzd5zoiZS8dd7erT|a z6$=B!XMTL00~mtZp<{x4!(L@9)IGN|r8Kt7A@@|_QIfx7@89DzOCvgYCUYxv7b;7? zv!^1Wv+s1m)*%d^@&Mi(g{_3z;Vjbl133T)B+pNF7X)-?{bQz#P$ku-ZbFSVzvFJm z3Hm_kqJrU}R4|;Je9jjYsZn28=i7;99i_r%=oe`LMs7Bq5mE9!=JPOu+La4n#`b6azRdbV)%E44qeOK5GQiK#N02$MleH)nyS`;l zA9h&j&#-f`4*#BWAmSQhv%@F+*FuF7X(f@#V7$c6 zn%XHDQ8%Aymegkiw;i83d~NUJmd!OLg+JQK673KDwA5T6nKQ|;+IY0tzJ~Llklk5L zvHL81FZ78DO1O5Cjq-Z-SOKfBk=UDW=JmkAP&B7QVR}WKTfl|gq!}l9PN^JJx`b?6 z^TbTOK3qSA_NKJFFY%)u&CJlbr-eD8<`E9RXfaK1+OW=d7xd&#H^o|Tp0}fWE+@B6 zq1vOy^+T=MKfQpD6C7IBYbG|@K1gM(-~J@e8L44`wjHMlZJk9VI@Plryt_f>tWLn% z8En3BGfs$iv055{_|iifVNjC)>B@)5j+Qt*dveIAyWp?a)WAAJj?QrL&fH}sOvekV zb}d7gl2&*l5n9W{L~kwKHS`<&bq9=EZr+FDLIwO~9SUkK|BCdliZ*+v7|X?GEb$@+ z*%BkH&CtO`4sW1`(pSm|FcN;Gp!P$~aQs}Ob=Q98XVC!*?qje+(b(qD_FWr|Mq|Hg z=bVvH^)w~7e^?u`;0gwZD;M8wQ}fOa9U)Kzin_ZpR#h`M zhGrmJv&I*$W*s+d)j>n7Uh?eQjP*bj90A=YcoC5ZOQ2YDHMdY7-+_ z^NC#@mLY-Ww*RS*@rSmCqRsO|0`1#H zL<1?I^Nv~;frS?+j6~_}$Hu=aONg;Bi-N6g8oF^ii1&$F3{)DE*{6*S)0Ng=f$#bY zZH+?aXI~M$5Q41yCRqxd5*v#60!OBnj zjH~KTMq97PkVBbXf~<$Hg2z`U#Qj=Er5~*8^uX3ZaWA5Jy~^*w0$m-Vk3A;sFFoTh zl-jn?njfDU%YY@?B_EDf0}fmwyVz0_>_a&;+^wu!~+r2fYgoZ8lu-cXFP2bh&x;jF^39Fc~S!cm1uZp86SV8suw<=N64IAL{U7 zJ$R+@;2X3|!bnX3n%Hvl@|9O)cD^2!%9_k8BV_5+^TsTz*O%Cs?vrQ|b;uq@#U$Bn z^=F_$+_U6XCyn(qEGwM}q-?78H~Bk0>w#Vy>L5A^--a&sWOO2o{Q2)dzovtdy=ygS zaiLF?ni8-`|3zHv+-Kj}*L`Q2hF$lu(@cnfCoGLyX6mFU3)C%MHLU6CQaJhu6376z zc)alCn=3|WQI-{U%iS_>>OCON+;Prt{78d2=Q}fS7)1Xjz1U=T`;C>v`0T!x9^1r}8BnkNUH$MK|jg Date: Tue, 8 Sep 2026 15:43:22 +0200 Subject: [PATCH 07/12] Record full-site source build validation --- docs/math-expression-redesign/rendering-slice.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/math-expression-redesign/rendering-slice.md b/docs/math-expression-redesign/rendering-slice.md index b5ff609..bf36480 100644 --- a/docs/math-expression-redesign/rendering-slice.md +++ b/docs/math-expression-redesign/rendering-slice.md @@ -81,7 +81,9 @@ 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 or full-site build is not claimed. +and target-host checks; a local FreeBSD run is not claimed. The companion full +website also builds on .NET 9/Linux using `webdbcore` and `LTR.WebDb.Entity` from +the `LTRData/ltrwebdb` source checkout, including its database XML serializers. - The focused expression/plotting suite includes the existing legacy tests, modern normative tests, the 18 compatible legacy expression examples, layout tests, and @@ -97,8 +99,14 @@ and target-host checks; a local FreeBSD run or full-site build is not claimed. HTML encoding, and the concurrent-render limit. The review host deliberately does not start the full site's database or visit logging. -The full site still requires its existing private `webdbcore` package/feed and normal -deployment configuration. +The full site supports `WebDbSourcePath` as an alternative to its private `webdbcore` +package/feed; normal deployment configuration is still required to run the site. +Its .NET 9 build reports a password-package version fallback warning and a database +XML serializer assembly-reference warning. Full-site .NET 10 restore is blocked by +an existing dependency conflict: the available `ArsenalRecon.PasswordTable` package +requires EF Core 10 on that target, while the site and MySQL provider use EF Core 9. +The standalone .NET 10 rendering review host is unaffected. See the companion +website's `docs/math-rendering-review.md` for source-build commands and details. ## Packaging and review From 1c96716357ad9153c92fa46fea26f5f6033ab8f4 Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Tue, 8 Sep 2026 16:07:30 +0200 Subject: [PATCH 08/12] Record successful full-site builds for .NET 9 and 10 --- .../rendering-slice.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/math-expression-redesign/rendering-slice.md b/docs/math-expression-redesign/rendering-slice.md index bf36480..70d997d 100644 --- a/docs/math-expression-redesign/rendering-slice.md +++ b/docs/math-expression-redesign/rendering-slice.md @@ -82,8 +82,9 @@ Local verification: 63 focused core tests and four native renderer tests pass on 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 companion full -website also builds on .NET 9/Linux using `webdbcore` and `LTR.WebDb.Entity` from -the `LTRData/ltrwebdb` source checkout, including its database XML serializers. +website also builds for .NET 9 and .NET 10 on Linux, using `webdbcore` and +`LTR.WebDb.Entity` from the `LTRData/ltrwebdb` source checkout, including its +database XML serializers. - The focused expression/plotting suite includes the existing legacy tests, modern normative tests, the 18 compatible legacy expression examples, layout tests, and @@ -101,12 +102,13 @@ the `LTRData/ltrwebdb` source checkout, including its database XML serializers. The review host deliberately does not start the full site's database or visit logging. The full site supports `WebDbSourcePath` as an alternative to its private `webdbcore` package/feed; normal deployment configuration is still required to run the site. -Its .NET 9 build reports a password-package version fallback warning and a database -XML serializer assembly-reference warning. Full-site .NET 10 restore is blocked by -an existing dependency conflict: the available `ArsenalRecon.PasswordTable` package -requires EF Core 10 on that target, while the site and MySQL provider use EF Core 9. -The standalone .NET 10 rendering review host is unaffected. See the companion -website's `docs/math-rendering-review.md` for source-build commands and details. +The site pins `ArsenalRecon.PasswordTable` to `[3.12.340]`, now available from +public NuGet, to retain EF Core 9 compatibility on both targets. Both full-site +restores succeed without warnings; builds report zero errors and one existing +database XML serializer assembly-reference warning each. The resolved graphs were +verified to use PasswordTable 3.12.340, EF Core 9.0.19 throughout, and Pomelo 9.0.0. +The previous .NET 10 package conflict is resolved. See the companion website's +`docs/math-rendering-review.md` for source-build commands and details. ## Packaging and review From 421c62df1d634c703a34f1fc935b2ddf1259d855 Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Tue, 8 Sep 2026 16:45:46 +0200 Subject: [PATCH 09/12] Document shared local NuGet build and validation workflow --- docs/local-package-workflow.md | 144 ++++++++++++++++++ docs/math-expression-redesign/README.md | 1 + .../rendering-slice.md | 41 ++--- 3 files changed, 168 insertions(+), 18 deletions(-) create mode 100644 docs/local-package-workflow.md diff --git a/docs/local-package-workflow.md b/docs/local-package-workflow.md new file mode 100644 index 0000000..5ab2ece --- /dev/null +++ b/docs/local-package-workflow.md @@ -0,0 +1,144 @@ +# 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 graph packages are `LTRData.MathExpression` 1.1.0-preview.1, +`LTRData.FunctionPlotting` 0.1.0, and `LTRData.Graphics.SkiaSharp` +0.1.0-preview.1. Their other Library dependencies must also be in the feed. +`ltrwebdb` produces `LTR.WebDb.Entity` and `webdbcore`, currently at 1.0.3. +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. + +## 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. + +XML serialization assembly work remains deferred. 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. Database package generation +retained its existing XML serializer warnings; that work remains deferred. + +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 index 3f05907..0cc38e3 100644 --- a/docs/math-expression-redesign/README.md +++ b/docs/math-expression-redesign/README.md @@ -17,6 +17,7 @@ classified before it becomes a test or a public contract: - [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) +- [Build and review the repositories through a shared local NuGet feed](../local-package-workflow.md) ## Original core slice diff --git a/docs/math-expression-redesign/rendering-slice.md b/docs/math-expression-redesign/rendering-slice.md index 70d997d..4f10c95 100644 --- a/docs/math-expression-redesign/rendering-slice.md +++ b/docs/math-expression-redesign/rendering-slice.md @@ -81,10 +81,7 @@ 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 companion full -website also builds for .NET 9 and .NET 10 on Linux, using `webdbcore` and -`LTR.WebDb.Entity` from the `LTRData/ltrwebdb` source checkout, including its -database XML serializers. +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 @@ -100,15 +97,11 @@ database XML serializers. HTML encoding, and the concurrent-render limit. The review host deliberately does not start the full site's database or visit logging. -The full site supports `WebDbSourcePath` as an alternative to its private `webdbcore` -package/feed; normal deployment configuration is still required to run the site. +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. Both full-site -restores succeed without warnings; builds report zero errors and one existing -database XML serializer assembly-reference warning each. The resolved graphs were -verified to use PasswordTable 3.12.340, EF Core 9.0.19 throughout, and Pomelo 9.0.0. -The previous .NET 10 package conflict is resolved. See the companion website's -`docs/math-rendering-review.md` for source-build commands and details. +public NuGet, to retain EF Core 9 compatibility on both targets. The previous +.NET 10 package conflict is resolved. XML serialization assembly work remains +deferred. ## Packaging and review @@ -117,12 +110,24 @@ The expression package now uses `1.1.0-preview.1`; the new renderer uses of resolving the already published expression package without these APIs. No NuGet release is performed by this work. Function plotting retains its prototype `0.1.0`. -The companion website supports `LibrarySourcePath` for reviewing these projects -directly. Its review host defaults to a sibling `Library` checkout. See the website's -`docs/math-rendering-review.md` for the one-command UI and HTTP verification paths. - -After review, package the math, plotting, and renderer projects into your normal -feed, or continue using an explicit source reference during development. +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 From 162d02a80c456e2c782eaad86a6f132db7e2f704 Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Tue, 8 Sep 2026 20:45:51 +0200 Subject: [PATCH 10/12] Add sample calculus and document consumer migration --- .../FunctionPlotting/SampleCalculus.cs | 146 ++++++++++++++++++ .../FunctionPlotting/Sampling.cs | 2 +- .../LTRData.FunctionPlotting.csproj | 5 +- Tests/SampleCalculusTests.cs | 106 +++++++++++++ docs/local-package-workflow.md | 14 +- docs/math-expression-redesign/README.md | 18 ++- docs/math-expression-redesign/architecture.md | 2 +- .../consumer-migration.md | 94 +++++++++++ .../rendering-slice.md | 8 +- 9 files changed, 381 insertions(+), 14 deletions(-) create mode 100644 LTRData.FunctionPlotting/FunctionPlotting/SampleCalculus.cs create mode 100644 Tests/SampleCalculusTests.cs create mode 100644 docs/math-expression-redesign/consumer-migration.md 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 index 9523653..5bf2959 100644 --- a/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs +++ b/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs @@ -130,7 +130,7 @@ public static SampleSeries Sample(ScalarFunction function, NumericRange domain, return new SampleSeries(samples); } - private static FunctionSampleStatus Classify(double value) + internal static FunctionSampleStatus Classify(double value) { if (double.IsNaN(value)) { diff --git a/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj b/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj index 4f619fc..1a027c6 100644 --- a/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj +++ b/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj @@ -1,8 +1,9 @@ - 0.1.0 - $(Version) + 0.2.0-preview.1 + 0.2.0 + $(Version) 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/docs/local-package-workflow.md b/docs/local-package-workflow.md index 5ab2ece..2e329a3 100644 --- a/docs/local-package-workflow.md +++ b/docs/local-package-workflow.md @@ -72,9 +72,12 @@ If a build explicitly disables it, follow that build with `dotnet pack --no-buil for each producer project, using the same configuration and framework properties. The output still uses `LocalNuGetPath`. -The graph packages are `LTRData.MathExpression` 1.1.0-preview.1, +The initial website slice uses `LTRData.MathExpression` 1.1.0-preview.1, `LTRData.FunctionPlotting` 0.1.0, and `LTRData.Graphics.SkiaSharp` -0.1.0-preview.1. Their other Library dependencies must also be in the feed. +0.1.0-preview.1. The subsequent Windows consumer slice requires +`LTRData.FunctionPlotting` 0.2.0-preview.1 for sample calculus. 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.3. The website's renderer and database references remain ordinary package references. @@ -83,6 +86,13 @@ 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. diff --git a/docs/math-expression-redesign/README.md b/docs/math-expression-redesign/README.md index 0cc38e3..d5818db 100644 --- a/docs/math-expression-redesign/README.md +++ b/docs/math-expression-redesign/README.md @@ -17,6 +17,7 @@ classified before it becomes a test or a public contract: - [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) ## Original core slice @@ -35,8 +36,9 @@ The branch validates only these boundaries: 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. System.Drawing migration, derivative/integral calculation, -recurrence, adaptive sampling and LINQ-expression compilation remain deferred. +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 so the experimental API can be reviewed without a @@ -53,7 +55,9 @@ broad consumer migration. They do not define the new language. 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. -- System.Drawing compatibility remains in `LTRLib.Windows` initially. +- 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 @@ -79,6 +83,8 @@ broad consumer migration. They do not define the new language. 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. -Review the [API, sample images and deployment notes](rendering-slice.md) and the -website review host before evaluating derivative, integral, adaptive sampling and -Windows migration. +The subsequent [consumer migration](consumer-migration.md) covers netexpr, +GraphViewer, sample differentiation and integration, and package-based validation. +Review Windows overlays, resize, printing/export and the documented numerical +semantics. 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 index 063f188..9692eea 100644 --- a/docs/math-expression-redesign/architecture.md +++ b/docs/math-expression-redesign/architecture.md @@ -36,7 +36,7 @@ source -> lexer/parser -> immutable syntax -> binder/catalog -> interpreted eval | `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 | Keep in `LTRLib.Windows` initially | Windows-only API and legacy TFMs | Windows GraphViewer | All portable/server consumers avoid System.Drawing | Yes: Windows-specific targets; compatibility release lifecycle | +| 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. diff --git a/docs/math-expression-redesign/consumer-migration.md b/docs/math-expression-redesign/consumer-migration.md new file mode 100644 index 0000000..7e28d09 --- /dev/null +++ b/docs/math-expression-redesign/consumer-migration.md @@ -0,0 +1,94 @@ +# 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` 0.2.0-preview.1 adds `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 remains at 1.1.0-preview.1. 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, XML serialization assemblies, adaptive sampling and +the broad final optimization pass remain deferred. diff --git a/docs/math-expression-redesign/rendering-slice.md b/docs/math-expression-redesign/rendering-slice.md index 4f10c95..b232f50 100644 --- a/docs/math-expression-redesign/rendering-slice.md +++ b/docs/math-expression-redesign/rendering-slice.md @@ -131,11 +131,15 @@ Review and validate this package chain locally before publishing to NuGet server ## Still deferred -Derivative/integral calculation, recurrence, adaptive sampling and finite-asymptote -detection, Windows migration, semantic diagrams, and optional compilation remain +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. From 0e853ea1abdc3d4484695db1e026ebf88535ae42 Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Tue, 8 Sep 2026 22:42:37 +0200 Subject: [PATCH 11/12] Prepare stable math packages and use framework argument throw helpers --- .../FunctionPlotting/Geometry.cs | 16 +++++ .../FunctionPlotting/Sampling.cs | 8 +++ .../LTRData.FunctionPlotting.csproj | 4 +- .../LTRData.Graphics.SkiaSharp.csproj | 4 +- .../LTRData.MathExpression.csproj | 2 +- .../MathExpression/MathBinding.cs | 20 ++++++ .../MathExpression/MathDiagnostics.cs | 21 +++++- .../MathExpression/MathParser.cs | 4 ++ .../MathExpression/MathSymbolCatalog.cs | 8 +++ .../MathExpression/MathSyntax.cs | 65 ++++++++++++++++--- docs/local-package-workflow.md | 24 +++---- docs/math-expression-redesign/README.md | 14 ++-- .../consumer-migration.md | 10 +-- .../release-checkpoint.md | 39 +++++++++++ .../rendering-slice.md | 12 ++-- 15 files changed, 209 insertions(+), 42 deletions(-) create mode 100644 docs/math-expression-redesign/release-checkpoint.md diff --git a/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs b/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs index 43dea46..4b277f9 100644 --- a/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs +++ b/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs @@ -117,10 +117,14 @@ 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 * @@ -133,10 +137,14 @@ public static CanvasPoint ToCanvas(double x, double y, PlotViewport viewport) 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 + @@ -150,15 +158,23 @@ 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; diff --git a/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs b/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs index 5bf2959..6ea6082 100644 --- a/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs +++ b/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs @@ -54,10 +54,14 @@ public sealed class SampleSeries 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(); } @@ -79,10 +83,14 @@ 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) { diff --git a/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj b/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj index 1a027c6..1fcbee6 100644 --- a/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj +++ b/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj @@ -1,8 +1,8 @@ - 0.2.0-preview.1 - 0.2.0 + 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 index 8a0f5fc..42011b0 100644 --- a/LTRData.Graphics.SkiaSharp/LTRData.Graphics.SkiaSharp.csproj +++ b/LTRData.Graphics.SkiaSharp/LTRData.Graphics.SkiaSharp.csproj @@ -1,8 +1,8 @@ net8.0;net9.0;net10.0 - 0.1.0-preview.1 - 0.1.0 + 1.1.0 + 1.1.0 $(Version) SkiaSharp renderers for function plots and mathematical expression diagrams. diff --git a/LTRData.MathExpression/LTRData.MathExpression.csproj b/LTRData.MathExpression/LTRData.MathExpression.csproj index 8f9d2fd..8eecec2 100644 --- a/LTRData.MathExpression/LTRData.MathExpression.csproj +++ b/LTRData.MathExpression/LTRData.MathExpression.csproj @@ -1,7 +1,7 @@ - 1.1.0-preview.1 + 1.1.0 1.1.0 $(Version) diff --git a/LTRData.MathExpression/MathExpression/MathBinding.cs b/LTRData.MathExpression/MathExpression/MathBinding.cs index 4fe5373..3d408ad 100644 --- a/LTRData.MathExpression/MathExpression/MathBinding.cs +++ b/LTRData.MathExpression/MathExpression/MathBinding.cs @@ -40,15 +40,23 @@ 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); @@ -165,10 +173,14 @@ internal BoundMathExpression(BoundNode root, IEnumerable variables 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) { @@ -182,10 +194,14 @@ public double Evaluate(params double[] 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]; @@ -203,10 +219,14 @@ public double Evaluate(IDictionary values) 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) { diff --git a/LTRData.MathExpression/MathExpression/MathDiagnostics.cs b/LTRData.MathExpression/MathExpression/MathDiagnostics.cs index 15da225..0f7118b 100644 --- a/LTRData.MathExpression/MathExpression/MathDiagnostics.cs +++ b/LTRData.MathExpression/MathExpression/MathDiagnostics.cs @@ -10,8 +10,18 @@ public sealed class MathDiagnostic { public MathDiagnostic(string code, string message, SourceSpan span) { - Code = code ?? throw new ArgumentNullException(nameof(code)); - Message = message ?? throw new ArgumentNullException(nameof(message)); +#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; } @@ -29,7 +39,12 @@ public sealed class MathParseResult internal MathParseResult(string sourceText, MathSyntax? root, IEnumerable diagnostics) { - SourceText = sourceText ?? throw new ArgumentNullException(nameof(sourceText)); +#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(); } diff --git a/LTRData.MathExpression/MathExpression/MathParser.cs b/LTRData.MathExpression/MathExpression/MathParser.cs index 3e8f426..4616370 100644 --- a/LTRData.MathExpression/MathExpression/MathParser.cs +++ b/LTRData.MathExpression/MathExpression/MathParser.cs @@ -12,10 +12,14 @@ public sealed class MathParser 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); diff --git a/LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs b/LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs index ce8efb4..c160fe4 100644 --- a/LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs +++ b/LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs @@ -127,10 +127,14 @@ public MathSymbolCatalogBuilder AddConstant(string name, double value) 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; @@ -139,10 +143,14 @@ public MathSymbolCatalogBuilder AddFunction(string name, Func fu 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; diff --git a/LTRData.MathExpression/MathExpression/MathSyntax.cs b/LTRData.MathExpression/MathExpression/MathSyntax.cs index 1b70ada..e6235a6 100644 --- a/LTRData.MathExpression/MathExpression/MathSyntax.cs +++ b/LTRData.MathExpression/MathExpression/MathSyntax.cs @@ -10,6 +10,10 @@ 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)); @@ -19,6 +23,7 @@ public SourceSpan(int start, int length) { throw new ArgumentOutOfRangeException(nameof(length)); } +#endif Start = start; Length = length; @@ -103,7 +108,12 @@ public NumberSyntax(double value, string lexeme, SourceSpan span) : base(span) { Value = value; - Lexeme = lexeme ?? throw new ArgumentNullException(nameof(lexeme)); +#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; @@ -118,7 +128,12 @@ public sealed class NameSyntax : MathSyntax public NameSyntax(string name, SourceSpan span) : base(span) { - Name = name ?? throw new ArgumentNullException(nameof(name)); +#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; @@ -131,7 +146,12 @@ public sealed class ParenthesizedSyntax : MathSyntax public ParenthesizedSyntax(MathSyntax expression, SourceSpan span) : base(span) { - Expression = expression ?? throw new ArgumentNullException(nameof(expression)); +#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; @@ -147,7 +167,12 @@ public PrefixSyntax(MathPrefixOperator @operator, SourceSpan operatorSpan, { Operator = @operator; OperatorSpan = operatorSpan; - Operand = operand ?? throw new ArgumentNullException(nameof(operand)); +#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; @@ -165,7 +190,12 @@ public PostfixSyntax(MathSyntax operand, MathPostfixOperator @operator, SourceSpan operatorSpan, SourceSpan span) : base(span) { - Operand = operand ?? throw new ArgumentNullException(nameof(operand)); +#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; } @@ -185,10 +215,20 @@ public BinarySyntax(MathSyntax left, MathBinaryOperator @operator, SourceSpan operatorSpan, MathSyntax right, bool isImplicit, SourceSpan span) : base(span) { - Left = left ?? throw new ArgumentNullException(nameof(left)); +#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; - Right = right ?? throw new ArgumentNullException(nameof(right)); +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(right); +#else + if (right is null) throw new ArgumentNullException(nameof(right)); +#endif + Right = right; IsImplicit = isImplicit; } @@ -211,13 +251,22 @@ public CallSyntax(string name, SourceSpan nameSpan, IEnumerable arguments, SourceSpan span) : base(span) { - Name = name ?? throw new ArgumentNullException(nameof(name)); +#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(); } diff --git a/docs/local-package-workflow.md b/docs/local-package-workflow.md index 2e329a3..bf5d0a2 100644 --- a/docs/local-package-workflow.md +++ b/docs/local-package-workflow.md @@ -72,13 +72,11 @@ If a build explicitly disables it, follow that build with `dotnet pack --no-buil for each producer project, using the same configuration and framework properties. The output still uses `LocalNuGetPath`. -The initial website slice uses `LTRData.MathExpression` 1.1.0-preview.1, -`LTRData.FunctionPlotting` 0.1.0, and `LTRData.Graphics.SkiaSharp` -0.1.0-preview.1. The subsequent Windows consumer slice requires -`LTRData.FunctionPlotting` 0.2.0-preview.1 for sample calculus. 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.3. +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 @@ -127,8 +125,10 @@ 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. -XML serialization assembly work remains deferred. Local packing and validation -do not publish to NuGet servers; publication remains a later release step. +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 @@ -146,8 +146,10 @@ 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. Database package generation -retained its existing XML serializer warnings; that work remains deferred. +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), diff --git a/docs/math-expression-redesign/README.md b/docs/math-expression-redesign/README.md index d5818db..5ca86af 100644 --- a/docs/math-expression-redesign/README.md +++ b/docs/math-expression-redesign/README.md @@ -19,6 +19,7 @@ classified before it becomes a test or a public contract: - [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 @@ -41,8 +42,8 @@ and moves netexpr and Windows GraphViewer to the modern APIs. Recurrence, adapti sampling and LINQ-expression compilation remain deferred. The existing `MathExpressionParser`, `IMathExpressionParser`, `MathFunctions`, and -`ScriptControl` remain temporarily so the experimental API can be reviewed without a -broad consumer migration. They do not define the new language. +`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 @@ -85,6 +86,9 @@ 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. -Review Windows overlays, resize, printing/export and the documented numerical -semantics. FreeBSD native SkiaSharp work is postponed; the website will temporarily -run on Linux or Windows Server. +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/consumer-migration.md b/docs/math-expression-redesign/consumer-migration.md index 7e28d09..ee8ead1 100644 --- a/docs/math-expression-redesign/consumer-migration.md +++ b/docs/math-expression-redesign/consumer-migration.md @@ -48,7 +48,7 @@ is required by this application. Other legacy consumers can still use them. ## Numerical calculus -`LTRData.FunctionPlotting` 0.2.0-preview.1 adds `SampleCalculus`, independent of +`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. @@ -75,7 +75,7 @@ are intentionally not reproduced. 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 remains at 1.1.0-preview.1. Use a fresh cache +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). @@ -90,5 +90,7 @@ 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, XML serialization assemblies, adaptive sampling and -the broad final optimization pass remain deferred. +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/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 index b232f50..3bd3332 100644 --- a/docs/math-expression-redesign/rendering-slice.md +++ b/docs/math-expression-redesign/rendering-slice.md @@ -100,15 +100,15 @@ The review host deliberately does not start the full site's database or visit lo 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. XML serialization assembly work remains -deferred. +.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 expression package now uses `1.1.0-preview.1`; the new renderer uses -`0.1.0-preview.1`. This gives the consumer an explicit prerelease dependency instead -of resolving the already published expression package without these APIs. No NuGet -release is performed by this work. Function plotting retains its prototype `0.1.0`. +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 From f240f36481dab57231aa5806865e4f87c419a8a1 Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Tue, 8 Sep 2026 22:52:40 +0200 Subject: [PATCH 12/12] Isolate renderer test host files and reuse CI build output --- .github/workflows/build-test.yml | 4 ++-- Rendering.Tests/Rendering.Tests.csproj | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) 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/Rendering.Tests/Rendering.Tests.csproj b/Rendering.Tests/Rendering.Tests.csproj index ca53ad3..3b1a949 100644 --- a/Rendering.Tests/Rendering.Tests.csproj +++ b/Rendering.Tests/Rendering.Tests.csproj @@ -1,6 +1,8 @@ net8.0;net9.0;net10.0 + + bin\$(Configuration)\ false true CS1591