๐๏ธ Architecture
Helion is a browser-native framework for interactive math and physics. Its API is designed to express scientific intent directly, with a clear separation between mathematical/physical models and their visual representation.
Operator โโโบ State โโโบ View apply() synchronize() โฒ โ evolve() โ Solver (uses Equation)Doctrine
Section titled โDoctrineโIn mathematics and physics, change is often expressed as
Everything can be seen as states and operators. Software adds other constraints โ intent, performance, memory, maintainability โ so the most elegant mathematical abstraction is not always the best programming abstraction.
Helion follows one rule:
Unify concepts, not syntax.
Different kinds of state share the same grammar, but keep their own semantics and types.
Core grammar: State / apply / evolve / bind
Section titled โCore grammar: State / apply / evolve / bindโ| Concept | Question | Example |
|---|---|---|
State | What is the current state? | DiscreteScalarField, DiscreteComplexField, RealFunction, RadialSymmetricBody |
apply() | How is the state transformed instantly? | field.apply(new GaussianImpulse(...)) |
evolve() | How does the state evolve in time? | field.evolve(solver, dt) |
bind() | How is the state represented? | simulation.bind(field.alwaysWith(view)) |
// Instantaneous transformation: x โฆ O(x), no timefield.apply(new FFT2D());field.apply(new GaussianImpulse({ amplitude: 1 }));
// Time evolution: x(t) โฆ x(t + ฮt), explicit time stepfield.evolve(solver, dt);
// Visualization: declarative binding, then synchronized each framesimulation.bind(field.alwaysWith(view));apply and evolve are deliberately distinct. An FFT is not a time step; a Schrรถdinger step is. Forcing both under apply hides that difference. Keeping evolve makes the physics readable:
psi.apply(new GaussianImpulseComplex2D({ ... })) .evolve(new SchrodingerSolver(equation), dt);State owns itself. The solver does not own the field:
const solver = new SchrodingerSolver(equation);solver.step(psi, dt); // imperative formpsi.evolve(solver, dt); // preferred: state stays ownerThis also allows one solver to evolve multiple fields.
State, Operator, Equation, Solver
Section titled โState, Operator, Equation, SolverโState โ the thing that changes. All states extend MathPhysicsModelBehavior and share apply() / alwaysWith() / onceWith().
DiscreteScalarFieldDiscreteComplexFieldRadialSymmetricBody, Block, LatticeOperator โ an instantaneous algebraic or geometric transformation x โฆ O(x).
GaussianImpulse, PerlinNoiseOperator, DiamondSquareOperatorDoubleSlitOperator, ShapeMask, Potential, SoftnessFFT2D, FFTShift2D, LaplaceOperatorDeclarative composition is the norm:
potential.reset() .apply(new DoubleSlit({ size: 40, energy: 0.1 })) .apply(new Softness(4));Equation โ a physical law, not a numerical scheme.
BarrierWaveEquation // and other equations in src/model/math/equations.jsSolver โ a numerical procedure that realizes a discrete evolution operator x(t+ฮt) = U(ฮt) x(t).
WaveEquationSolverSchrodingerSolverConceptually a solver is an operator (the time-evolution operator), but Helion keeps the syntax separate because the time step dt belongs to evolve, not apply.
Not everything is a Field. A RadialSymmetricBody has mass, momentum, and collisions; a DiscreteComplexField does not. The unification is
Everything is StateState is transformed by Operatorsโ not Everything is a Field.
Fields, Surfaces and Views
Section titled โFields, Surfaces and ViewsโThe Field/Surface/View architecture is designed around a simple distinction:
A Field describes values. A Surface describes geometry. A View describes how those values and that geometry are rendered.
A Surface is useful, but it is not a mandatory intermediary between a Field and a View.
A Field is a mathematical object that answers the question:
What is the value of this field at a given parameter or grid location?
Continuous fields provide a parameter-space operation such as:
field.sample(u, v, target);Discrete fields additionally expose their native grid:
field.valueAt(i, j, target);field.nx;field.ny;The distinction between continuous and discrete fields should remain an implementation detail of the data-access layer, rather than something that every View has to understand.
The intended common abstraction is a field sampling/grid capability, for example:
field.sampleGrid(resolution, target);or an equivalent iterator over samples. The important contract is that the Field chooses the most appropriate representation:
- A continuous field samples its mathematical function at the requested resolution.
- A discrete field uses its native grid directly when that grid is suitable.
- A discrete field must not be needlessly resampled merely because a View asks for a raster.
This is particularly important for large numerical simulations: the existing grid is already the data representation, so interpolating it into another grid only to render it wastes CPU and memory.
Current examples include:
Fieldโ common field abstractionScalarFieldโ real-valued fieldComplexFieldโ complex-valued fieldVectorFieldโ vector-valued fieldRealFunctionโ continuous 1D real function over anInterval(new RealFunction({domain, func}))MultivariateFunctionโ continuous scalar field over aDomainComplexFunctionโ continuous complex field over aDomainDiscreteScalarFieldโ scalar values stored on a native gridDiscreteComplexFieldโ complex values stored on native real/imaginary grids
Surface
Section titled โSurfaceโA Surface is a geometrical embedding or sampling layer. It answers questions that a Field alone does not answer:
Where does this value live in 3D space, and what is the local geometry there?
A genuine Surface can provide information such as:
surface.frameAt(u, v, frame);where the frame may contain position, normal, curvature, and principal directions through DifferentialGeometry.
Examples include:
ParametricSurfaceโ(u, v) โ (x, y, z)over aDomainScalarFieldSurfaceโ embeds a scalar field as a height surfaceDiscreteFieldSurfaceโ derives positions and normals from a native scalar grid
The important architectural point is that a Surface is not simply a wrapper around a Field. A class whose only purpose is to delegate sample() to a Field does not add meaningful geometry and should not be required merely to make the Field visualizable.
Thus a ComplexFieldSurface-style adapter should not be the normal route for visualizing a complex field. Complex fields should be directly bindable to Views, while a genuine Surface can still be supplied when a particular geometric embedding is desired.
Two paths to visualization
Section titled โTwo paths to visualizationโThe public API should support both of these paths:
Field โ โโโโโโโโโโโโโโดโโโโโโโโโโโโโ โ โ direct visualization optional geometry โ โ โผ โผ FieldView2D/3D Surface โ frameAt(u,v) โ โผ FieldView3DFor ordinary field visualization, the user should be able to write:
field.alwaysWith(new FieldView2D());field.alwaysWith(new FieldView3D());without first constructing a Surface.
When geometric information is meaningful, a Surface remains available as an explicit layer:
const surface = new ParametricSurface({ ... });
surface.alwaysWith(new FieldView3D({ field }));The exact API may evolve, but the architectural rule remains: a Field does not need to become a Surface in order to be rendered.
A View is responsible for rendering, not for deciding whether its model is continuous or discrete.
The target public API should converge on two general-purpose field Views:
FieldView2Dโ 2D visualization of scalar, complex, and other supported field valuesFieldView3Dโ 3D visualization, optionally using Surface geometry
The View should ask the model for the representation it needs rather than branching on concrete field types:
view.canBindTo(model);view.initialize(model);view.synchronizeWith(model);Continuous versus discrete data should therefore be handled by the Fieldโs data-access strategy, not by a proliferation of View classes.
This replaces the need for separate public Views such as:
DiscreteFieldSurfaceViewDiscreteComplexFieldSurfaceView2DContinuousComplexFieldViewDiscreteComplexFieldSurfaceView...where those distinctions exist primarily because the source representation differs.
Native grids versus sampling
Section titled โNative grids versus samplingโThere are two fundamentally different rendering cases:
Continuous field โ โโโ sample at View resolution โ โผ rendering grid
Discrete field โ โโโ native grid โ โผ rendering gridA discrete field should normally render its existing grid directly. A continuous field has no native raster, so the View or Field sampling layer must create one at an appropriate resolution.
This principle applies equally to real-valued and complex-valued fields. It should not be special-cased for complex fields.
Architectural boundary
Section titled โArchitectural boundaryโThe resulting responsibilities are:
| Layer | Responsibility |
|---|---|
| Field | Values, domain, resolution, and efficient access to continuous or native-grid data |
| Surface | Optional geometric embedding, position, normals, and differential geometry |
| View | Rendering values and, when present, geometry |
| Renderer | Three.js/WebGL mechanics and scene rendering |
The key rule is:
A Field is directly visualizable. A Surface is optional geometry, not a mandatory adapter.
Simulation and Binding
Section titled โSimulation and BindingโSimulation is a builder that owns the render loop and the DOM plumbing (Viewport โ ThreeJsRenderer). Typical setup:
Simulation.with({ htmlDivId: "container", cameraPosition: new Vec3(3, 3, 3), scale: 1, headUpDisplay: true}) .runsEvery(0.016) // wall-clock scheduling .onStep((clock, dt) => { // called at fixed dt field.evolve(solver, dt); }) .bind(field.alwaysWith(surfaceView)) // continuous sync .bind(potential.onceWith(barrierView)) // one-shot sync (static) .append(slider) // controls โ details element .provideAxesAround(surfaceView) .frameSceneOn(surfaceView) .start();Key loop (Simulation.animate):
requestAnimationFrame โ SimulationClock accumulates wall time (realTimeStep vs simulationTimeStep) โ _updatePhysics(): while (accumulator >= realTimeStep) stepFunction(clock, dt) โ Binding.synchronize(): view.synchronizeWith(model) if ALWAYS or dirty โ ThreeJsRenderer.render()runsEvery(dt)โ wall-clock interval.advancesBy(dt)/atSpeed(scale)โ simulated time per step.onStep(fn)โ fixed-step mode (deterministic).maxOutCpu(fn)โ adaptive mode that tunesiterationsPerFrameto hit a target frame rate.alwaysWith(view)โ synchronized every frame.onceWith(view)โ synchronized once atinitialize(for static geometry).Viewportbuildscontainer โ canvasWrapper โ canvas + HUD + CSS2D labels + controls.SimulationClockseparatesrealTimeStep,simulationTimeStep,accumulator, andtimeScale.
Design principle:
A model is bound to a view exactly once. Afterwards the view may change internally without the binding or the simulation knowing.
Layers and directory map
Section titled โLayers and directory mapโsrc/ core/helion.js Simulation, Binding, Viewport, SimulationClock, Registry core/controls.js Slider, DropdownMenu, Checkbox, RadioGroup, Button, TextInput core/mathrenderer.js renderMath (MathJax/KaTeX for Simulation.setLatexTitle) model/math/math.js Vec2, Vec3, Complex, Interval, Range, Domain model/math/fields.js Field, ScalarField, RealFunction, ComplexField, DiscreteScalarField, DiscreteComplexField, VectorField model/math/surfaces.js Surface, ParametricSurface, ScalarFieldSurface, DiscreteFieldSurface model/math/numerics/ DifferentialGeometry, solvers (Wave/Shrodinger/Jacobi), integrators model/phys/bodies.js Body, RadialSymmetricBody, AxialSymmetricBody, Block, Lattice, BodyPair, BodyPairs model/phys/clouds.js PointCloud, Gas model/phys/forces.js Force, FieldForce, GravitationalForce, CoulombForce, SpringForce, DragForce, PairForce model/transformations/ Operators (FFT2D, GaussianImpulse, DoubleSlit, ShapeMask, Softness, โฆ + ShapeConfiguration/Shapes) view/colormappers.js Colour, hsvToRgb, ColorMappers, HueColorMapper, HexValueColorMapper, WavelengthColorMapper, ComplexColorMappers view/3d/surfaces/complex.js ComplexFieldViewable, ComplexSurfaceView3D, WaveFunctionSurface3D view/3d/surfaces/ SurfaceVisualization, ColorLayers, layers (Contours/PrincipalDirections/Glyph), normalizers (AdaptiveSymmetric/FixedInterval) view/3d/views.js DiscreteFieldBoxView (Instanced BoxGeometry for DiscreteScalarField) view/3d/composite/segmentviews.js CurveView, LineSegmentsView, Box/CylinderSegmentsView view/3d/renderer.js ThreeJsRenderer (perspective/orthographic, axes, shadows) view/3d/camera.js ThreeJsCamera (perspective/orthographic, OrbitControls) view/3d/primitives/ Sphere, Box, Cylinder, Arrow, Ring, Helix, Trail, VectorView, Label, โฆ view/3d/composite/ PointCloudView, LatticeView, SwitchableBondView, DiatomicMolecule, ArrowField, ElectromagneticWave, โฆ view/2d/views.js PixelRasterView, DiscreteFieldSurfaceView, FieldEdgeIntensityPixelRaster, ComplexFieldViewable2D, ComplexSurfaceView2D, TiledPlane, ParticleView2D (colorFunction/hasBorder) view/2d/primitives.js Arrow2D view/2d/composite/ ArrowField2D, OneDimensionalComplexPlaneWave2DConceptually:
Mathematical layer Field, Surface, DifferentialGeometry โData representation Continuous sampling or native discrete grids โGeometry (optional) Surface embedding, frames, normals, curvature โView layer FieldView2D, FieldView3D, layers, color mappers โRendering ThreeJsRenderer, materials, shaders โSimulation Simulation, Binding, clock, controlsThe physics/simulation layers know nothing about visual scale; the render layer maps physics units to world units via Simulation.with({ scale }).
For 2D plots an orthographic camera is available: Simulation.with({ camera:{ orthographic:true } }) or simulation.setOrthographic(true) โ orthographic disables rotate/pan (only zoom), useful for ComplexSurfaceView2D and CurveView top-down views. Axes around 3D surfaces can be toggled via simulation.removeAxes() / setAxesVisible().
1D functions use RealFunction + CurveView (resolution = segments) analogously to MultivariateFunction + SurfaceVisualization for 2D, e.g. new RealFunction({domain:new Interval(-3,3), func:x=>Math.sin(x)}) bound with new CurveView({resolution:200}).
Migration direction
Section titled โMigration directionโThe architecture is intended to evolve without breaking existing examples in one step.
- Introduce the common Field data-access capability for rendering continuous samples and native discrete grids.
- Make the new 2D and 3D field Views bind directly to Fields.
- Keep Surface for genuine geometric use cases such as parametric geometry, height surfaces, normals, and differential geometry.
- Update real-valued and complex-valued examples to use direct Field โ View binding where no geometric Surface is required.
- Preserve existing Surface/View classes temporarily as compatibility adapters or aliases while examples migrate.
- Remove redundant specialized Views and thin FieldโSurface adapters once the public API no longer depends on them.
The goal is not to eliminate abstractions, but to place each abstraction where it provides real value:
Field = what value exists?Surface = where is it, and what is its geometry?View = how should it be rendered?This keeps the mathematical model natural, preserves native-grid performance, and prevents the rendering API from being shaped by implementation details of the underlying data representation.
Further reading
Section titled โFurther readingโ- Getting Started โ first simulation with bodies (
guides/getting_started) src/index.jsโ public exports (the reference surface)examples/mathematics/scenes/real_surfaces.jsโ continuous surface +SurfaceVisualizationexamples/quantumphysics/scenes/quantum_wave_scattering.jsโ discrete complex field +SchrodingerSolverREADME.mdโ project overview and positioning