This document explains the application-specific math API used by XpressFormula:
- what the main math-related types and modules are
- which mathematical abstractions they represent
- how they fit together from input text to plotted pixels
- what standard C++ libraries they depend on and why
- how to extend them safely
This guide is intentionally detailed and beginner-friendly.
It complements:
math-api-cheatsheet.md(quick lookup and "where used" references)expression-language.md(syntax for end users)algorithms-guide.md(algorithm concepts)imgui-implementation-guide.md(UI architecture)
XpressFormula does not have a single MathApi class. Instead, the "math API" is a set of cooperating modules:
Corenamespace- tokenization
- parsing
- AST representation
- evaluation
- coordinate transforms
- constants
UI::FormulaEntry- equation normalization and render-kind classification
Plotting::PlotRenderer- mathematical sampling and mesh/contour extraction used for rendering
Think of it as a layered internal library:
- Expression Math Layer (
Core) - Formula Semantics Layer (
FormulaEntry) - Numerical Sampling + Visualization Math Layer (
PlotRenderer)
This table is the most important mental model for understanding the code.
| Mathematical concept | What it means | Code representation |
|---|---|---|
| Token | Small lexical unit in input text | Core::Token / Core::TokenType |
| Expression | A symbolic formula | Core::ASTNode tree |
| Scalar literal | A fixed number | Core::NumberNode |
| Variable | Unknown/free symbol (x, y, z) |
Core::VariableNode |
| Unary operation | -a, +a |
Core::UnaryOpNode |
| Binary operation | a+b, a*b, a^b |
Core::BinaryOpNode |
| Function application | sin(x), pow(a,b) |
Core::FunctionCallNode |
| Evaluation context | Fixed x, y, z sample values |
Core::EvaluationContext |
| Equation | left = right |
Internally normalized to (left - right) = 0 |
| 2D viewport transform | World coordinates ↔ screen pixels | Core::ViewTransform |
| Implicit curve/surface | Zero set of a function (F(...)=0) |
AST + contour/mesh extraction in PlotRenderer |
Files:
src/XpressFormula/Core/Token.hsrc/XpressFormula/Core/Tokenizer.hsrc/XpressFormula/Core/Tokenizer.cpp
TokenType defines the language atoms recognized by the tokenizer:
- numbers
- identifiers
- operators (
+ - * / ^) - grouping/punctuation (
(),) EndError
enum class TokenType {
Number,
Identifier,
Plus, Minus, Star, Slash, Caret,
LeftParen, RightParen, Comma,
End,
Error
};Tokens are not math values yet. They are syntax building blocks.
Example:
sin(x)+2
is first just:
Identifier("sin")LeftParenIdentifier("x")RightParenPlusNumber("2")
In this area you will see:
<string>- stores token text, error messages
<vector>- token lists
<cctype>- character classification (
isdigit,isalpha,isspace)
- character classification (
Why this design is good:
- easy to debug
- good error messages (stores positions)
- parser gets a clean token stream
Files:
The AST is the canonical internal representation of parsed math expressions.
ASTNode is the base type and each derived node represents a mathematical construct:
NumberNode: scalar constantVariableNode: symbolic variableUnaryOpNode: unary operator applicationBinaryOpNode: binary operator applicationFunctionCallNode: function application to arguments
class BinaryOpNode : public ASTNode {
public:
BinaryOperator op;
ASTNodePtr left;
ASTNodePtr right;
BinaryOpNode(BinaryOperator o, ASTNodePtr l, ASTNodePtr r)
: op(o), left(std::move(l)), right(std::move(r)) {}
NodeType type() const override { return NodeType::BinaryOp; }
};This node corresponds to the abstract form:
BinaryOp(op, left, right)
For example:
x^2 + y^2
becomes conceptually:
Add(Power(x,2), Power(y,2))
<memory>- AST nodes use
std::shared_ptr(ASTNodePtr) for tree ownership
- AST nodes use
<vector>- function argument lists
<string>- variable and function names
Why shared_ptr here?
- AST nodes are easy to pass around and store
- formula entries can hold ASTs without manual lifetime management
- developer ergonomics matters more than micro-optimizing ownership in this project
Files:
Parser::parse(const std::string&) returns a Parser::Result:
asterrorvariables
This is a strong API design because it returns both:
- the parsed structure
- semantic metadata (which variables were used)
struct Result {
ASTNodePtr ast;
std::string error;
std::set<std::string> variables;
bool success() const { return ast != nullptr && error.empty(); }
};The parser implements precedence through function layering:
// expression := term (('+' | '-') term)*
// term := power (('*' | '/') power)*
// power := unary ('^' power)? // right-associative
// unary := ('-' | '+')? primary- easy to read and maintain
- easy to attach good error messages
- supports function calls and nested expressions naturally
- enough power for the app’s expression language without parser generators
<vector>- token storage
<string>- error text and names
<set>- variable collection, built-in names, constants lookup
Files:
struct EvaluationContext {
double x = 0.0;
double y = 0.0;
double z = 0.0;
};
using Variables = std::unordered_map<std::string, double>;
static double evaluate(const ASTNodePtr& node, const EvaluationContext& context);
static double evaluate(const ASTNodePtr& node, const Variables& vars);The evaluator is a numerical interpreter for ASTs. Hot plotting paths use
EvaluationContext, which avoids string hashing while sampling. Variables
remains as a compatibility adapter for older callers and tests.
You can think of Evaluator::evaluate(ast, context) as:
- a function from
(expression tree, x/y/z sample point)to a real number
with the important caveat:
- invalid/undefined cases return
NaN
case NodeType::BinaryOp: {
auto* bin = static_cast<BinaryOpNode*>(node.get());
double l = evaluate(bin->left, context);
double r = evaluate(bin->right, context);
switch (bin->op) {
case BinaryOperator::Add: return l + r;
case BinaryOperator::Subtract: return l - r;
case BinaryOperator::Multiply: return l * r;
case BinaryOperator::Divide: return (r == 0.0) ? NaN : l / r;
case BinaryOperator::Power: return std::pow(l, r);
}
}Invalid math occurs often in plotting:
- division by zero
- log of non-positive numbers
- sqrt of negative numbers (in real mode)
Returning NaN allows rendering code to simply skip invalid samples using std::isfinite(...).
This keeps the plotting pipeline robust and simple.
Built-in function metadata lives in:
The registry stores each function's stable ID, parser name, UI signature, help
text, category, detailed explanation, equivalent formula or explanatory note,
loadable example, min/max arity, and evaluator callback. Parser uses the
registry to reject unknown function names and stores the matched definition on
FunctionCallNode. Evaluator then applies strict arity checks and invokes the
same registry callback. The Formula Editor reads the same metadata for its
Functions tab and detailed function help dialog.
The function set includes:
- basic math:
sqrt,cbrt,abs,log,pow,min,max,mod,sign - trigonometry:
sin,cos,tan, inverse trig, hyperbolic trig,atan2 - distance helpers:
hypot,length2,length3,distance2,distance3 - range/interpolation helpers:
clamp,saturate,mix,lerp,smoothstep - pattern helpers:
fract,tri,pulse,repeat - implicit composition and SDF helpers:
smin,smax,sdSphere,sdBox,sdTorus, cylinders - deterministic procedural noise:
noise2,noise3,fbm2,fbm3
Wrong argument counts return NaN; log intentionally supports both one and
two arguments.
The optional benchmark test compares the compatibility map adapter that mirrors
the previous hot sampling loops with the fixed-slot EvaluationContext path:
$env:XF_RUN_EXPRESSION_BENCHMARK = '1'
.\src\XpressFormula.Tests\x64\Release\XpressFormula.Tests.exeLocal Release x64 results recorded on 2026-07-20 with MSBuild 18.8.2+ce25c0108:
| Sampling shape | Map adapter baseline | Fixed-slot context | Ratio |
|---|---|---|---|
Curve, 200 x 4097 samples |
97.8928 ms |
71.1519 ms |
1.37583x |
Explicit surface, 100 x 97^2 samples |
136.382 ms |
99.5032 ms |
1.37063x |
Implicit field, 20 x 33^3 samples |
69.1287 ms |
42.6645 ms |
1.62029x |
<unordered_map>- compatibility adapter for callers that still pass named variables
<cmath>- core real-valued math functions
<algorithm>std::min/std::max
<limits>- canonical quiet
NaN
- canonical quiet
<vector>- function arguments
Files:
src/XpressFormula/Core/MathConstants.hsrc/XpressFormula/Core/ConstantRegistry.hsrc/XpressFormula/Core/ConstantRegistry.cpp
Defines:
PIETAU
MathConstants.h owns the numeric values. ConstantRegistry is the single
source for expression-language names such as pi, e, and tau, including
future constant metadata. The parser resolves constants through that registry.
for (const Core::ConstantInfo& constant : Core::constantRegistry()) {
// constant.name and constant.value are what user expressions see.
}Files:
ViewTransform is one of the most important mathematical components in the app.
It converts between:
- world coordinates (mathematical x/y space)
- screen coordinates (pixels)
This is an affine mapping with scaling and translation, plus an inverted Y axis for screen space.
World to screen:
sx = originX + width/2 + (wx - centerX) * scaleXsy = originY + height/2 - (wy - centerY) * scaleY
The minus sign on sy is because screen Y increases downward.
Vec2 ViewTransform::worldToScreen(double wx, double wy) const {
float sx = screenOriginX + screenWidth * 0.5f
+ static_cast<float>((wx - centerX) * scaleX);
float sy = screenOriginY + screenHeight * 0.5f
- static_cast<float>((wy - centerY) * scaleY);
return { sx, sy };
}Nearly every renderer depends on it:
- grid and axis drawing
- curve plotting
- heatmaps
- implicit contours
- mouse hover coordinates
niceGridSpacing() chooses grid intervals like:
12510- and powers of ten
This is a standard visualization technique for readable axes.
<cmath>pow,log10,floor
<algorithm>std::clampfor zoom limits
File:
Even though it lives in UI, FormulaEntry performs important math-semantic work.
It stores:
- original input text
- parsed AST(s)
- variable set
- error state
- classification (
Curve2D,Surface3D,Implicit2D,ScalarField3D)
This is one of the most important abstractions in the app.
leftAst = leftResult.ast;
rightAst = rightResult.ast;
ast = std::make_shared<Core::BinaryOpNode>(
Core::BinaryOperator::Subtract, leftAst, rightAst);Mathematically this means:
left = rightis represented as the zero set ofleft - right
This unified representation enables:
- implicit 2D contour rendering (
F(x,y)=0) - implicit 3D surface rendering (
F(x,y,z)=0)
FormulaEntry infers "what the expression means" from variables and equation shape.
Examples:
sin(x)-> one-variable function -> 2D curvesin(x)*cos(y)-> two variables -> explicit surface candidatex^2 + y^2 = 100-> implicit 2D equationx^2 + y^2 + z^2 = 16-> implicit 3D surface (F(x,y,z)=0)
Files:
PlotRenderer is where the expression math is turned into images.
It depends on:
Core::ASTNodePtr(the parsed expression)Core::Evaluator(numerical samples)Core::ViewTransform(world↔screen mapping)
drawCurve2D:- 1D sampling over x
drawHeatmap:- 2D grid sampling over x/y
drawImplicitContour2D:- marching squares on a 2D sampled scalar field
drawSurface3D:- explicit surface mesh from
z=f(x,y)
- explicit surface mesh from
drawImplicitSurface3D:- surface-nets style extraction from sampled
F(x,y,z)=0 - cached world mesh + re-projection
- optional split render passes around
z=0(All/BelowGridPlane/AboveGridPlane) - plane clipping stage used only for split passes (no second mesh extraction)
- surface-nets style extraction from sampled
Recent 3D grid work separates heavy mesh calculation from lightweight display ordering:
- Mesh calculation:
- explicit
z=f(x,y): sample x/y and build triangles - implicit
F(x,y,z)=0: sample scalar field + surface-nets extraction
- explicit
- Projection/shading:
- apply azimuth/elevation/z-scale and compute depth
- Optional grid-plane split (only when 3D grid is shown):
- clip triangles/faces to
z<=0pass - draw projected grid plane
- clip triangles/faces to
z>=0pass
- clip triangles/faces to
Important implementation detail:
- split passes are controlled through
Surface3DOptions(planePass,gridPlaneZ) - implicit world-mesh cache is reused exactly as before; split rendering does not create a second cached mesh variant
Core is math infrastructure and expression semantics.
PlotRenderer adds:
- visualization choices
- color schemes
- ImGui draw primitives
- camera/projection style
So it is a math consumer, not the foundational math library.
This section is a practical "why these headers exist" map for contributors.
<string>- tokens, identifiers, errors, function names
<vector>- token streams, AST function arguments
<memory>- AST ownership (
shared_ptr)
- AST ownership (
<set>- variable collection and built-in name lookup
<cctype>- character classification
<unordered_map>- legacy variable environment adapter
<cmath>- numerical math operations
<limits>NaN
<algorithm>min,max
<cmath>- logarithmic grid spacing and powers of 10
<algorithm>- clamping zoom
<vector>- sampled grids, vertices, faces
<algorithm>- sorting, clamping, min/max
<cmath>- trig, square roots, projections, normals
<limits>- numeric sentinels for bounds
<cstdio>- lightweight formatting for labels
Why these are standard-library based:
- keeps the core math stack portable and easy to build
- avoids bringing in heavy symbolic math or geometry dependencies
- easier for newcomers to inspect and debug
Input:
sin(x) + 2
Conceptual use of the API:
auto parsed = XpressFormula::Core::Parser::parse("sin(x) + 2");
if (parsed.success()) {
XpressFormula::Core::EvaluationContext context;
context.x = 1.0;
double y = XpressFormula::Core::Evaluator::evaluate(parsed.ast, context);
}What this represents mathematically:
- the function
f(x) = sin(x) + 2 - evaluated at
x = 1
Input:
x^2 + y^2 = 100
Internal representation:
F(x,y) = x^2 + y^2 - 100
Why this is useful:
- contour extraction only needs to know where
F(x,y)changes sign and crosses zero
ViewTransform converts plot coordinates to screen pixels and back.
This allows:
- drawing axes in the correct place
- displaying hover coordinates
- zooming toward the mouse cursor
Sphere:
x^2 + y^2 + z^2 = 16
Torus:
(x^2+y^2+z^2+21)^2 - 100*(x^2+y^2) = 0
In both cases, the math API path is:
- parse to AST
- normalize equation to zero-form (already zero-form in these examples)
- classify as
F(x,y,z)=0 - sample scalar field
- extract surface mesh
- project and render
- if 3D grid is visible: split render around
z=0and interleave plane draw
This is not a symbolic math system (CAS).
What it does well:
- parse and numerically evaluate expressions
- sample and visualize them interactively
What it does not do:
- algebraic simplification
- symbolic differentiation/integration
- exact arithmetic
Everything is evaluated in double.
So expect:
- rounding error
- numerical instability near singularities
- domain problems returning
NaN
This is normal and expected in interactive plotting tools.
- Add a
FunctionIdinFunctionRegistry.h - Add the metadata row and evaluator callback in
FunctionRegistry.cpp - Add parser, evaluator, docs, and example tests
- Update
expression-language.md
The Formula Editor supported-functions reference is generated from the registry. For user-facing functions, include a useful detailed description, equivalent formula or note, and a parseable example so the help dialog stays complete.
- Add the numeric value in
MathConstants.hif it is not already available - Add the public name and value row in
ConstantRegistry.cpp - Add parser/evaluator/docs tests
- Document it in
expression-language.md
- Extend
FormulaRenderKind - Update
FormulaEntry::parse()classification - Add render branch in
PlotPanel - Add renderer implementation in
PlotRenderer
If you want to understand the math stack end-to-end:
src/XpressFormula/Core/Token.hsrc/XpressFormula/Core/Tokenizer.cppsrc/XpressFormula/Core/ASTNode.hsrc/XpressFormula/Core/Parser.cppsrc/XpressFormula/Core/Evaluator.cppsrc/XpressFormula/Core/ViewTransform.cppsrc/XpressFormula/UI/FormulaEntry.hsrc/XpressFormula/Plotting/PlotRenderer.cpp
- AST: Abstract Syntax Tree, a structured representation of an expression
- Evaluation context: fixed
x,y, andzsample values used during evaluation - Implicit function: function used via its zero set
F(...)=0 - Scalar field: a function assigning one scalar value to each point in space
- Affine transform: linear transform plus translation (used in coordinate mapping)
- NaN: "Not a Number", used here to mark invalid numeric results
algorithms-guide.mdexpression-language.mdarchitecture.mdimgui-implementation-guide.mdfuture-directions-and-extension-ideas.md
This document is licensed under the MIT License. See ../LICENSE.