Sep 28, 2026 · Virtastic
Solving Real Physics in a Tab: Compiling a Fortran FEM Solver to WebAssembly
CalculiX, a 977-file Fortran finite-element solver, running client-side in the browser. What it took, how we know the answers are right, and what is still open.
Before any explanation, run it. The page linked below solves a real finite-element problem in your browser and checks the answer against the exact solution:
Open the live solver: freecad.virtastic.app/calculix-demo.html
Press the button. About a fifth of a second later, CalculiX will have solved a steel block under 1 MPa of tension and printed five numbers next to the closed-form values they should equal. The top face moves 4.761905e-6 m. The lateral face contracts by 1.428571e-6 m. The stress is 1.000000e6 Pa, and the off-axis stresses are zero to within round-off. If any of those were wrong, the page would say so.
That is the real CalculiX 2.22 solver, the same one FreeCAD’s FEM workbench drives, compiled to WebAssembly and executing on your machine. Nothing was sent to a server. It is the same ccx.wasm that ships inside FreeCAD at freecad.virtastic.app, where you can mesh your own part and solve it, free, with nothing to install.
The rest of this paper is about how that came to work, why it was harder than it looks, and how we decide whether to believe the numbers. It is written for two kinds of reader. If you do simulation for a living, the parts that matter are the verification method and the limits we are upfront about. If you build toolchains, the parts that matter are a class of failure that C and Fortran code hides for decades and WebAssembly exposes on the first run.
What we actually built
CalculiX is a three-dimensional structural finite-element program written mostly in Fortran 77 with some Fortran 90 spelling mixed in, plus a C driver. Version 2.22 has 977 Fortran source files. It does not solve linear systems or eigenproblems itself. It calls out to libraries, and those libraries are part of the story:
- SPOOLES 2.2, a sparse direct solver from 1999. This is CalculiX’s default linear solver. It is C, and it ships with 46 per-directory makefiles that shell out to
shandawk. - ARPACK-NG 3.9.1, the eigensolver behind modal analysis. It is Fortran.
- Reference LAPACK and BLAS, which ARPACK needs and does not bundle. Also Fortran.
- f2c and libf2c, the Bell Labs Fortran-to-C translator and its runtime library. This is how any of the Fortran gets to WebAssembly at all, for reasons the next section explains.
The build scripts for all five are in the public freecad-web repository. CalculiX itself ships as a separate module, ccx.js plus ccx.wasm, at 86,366 and 4,886,227 bytes. It is fetched the first time you solve something.
The module is separate for two reasons. It keeps the main FreeCAD download smaller. And CalculiX is GPL-2.0-or-later, so keeping it as its own module is also a licensing boundary. FreeCAD’s Python code hands it an input deck, gets result files back, and never links against it.
We looked for a public WebAssembly build of CalculiX before writing this and did not find one. There is at least one browser tool that reads and writes CalculiX file formats, but it reimplements the analysis in another language rather than running the solver. A search is not a proof, and if you know of prior work, please tell us and we will credit it here.
Why Fortran is the hard part
Emscripten, the standard C and C++ compiler for WebAssembly, has no Fortran frontend. There is no flag for it and no plugin. If a project has .f files, they do not compile.
The route that works is translation. f2c is a real and complete Fortran 77 to C translator that Bell Labs wrote and netlib still hosts. Feed it Fortran 77, get C, compile the C with Emscripten. The catch is that CalculiX is not clean Fortran 77. It uses ! comments, do ... end do, cycle and exit, :: declarations, array sections, allocatable arrays and whole-array intrinsics. f2c parses Fortran 77 and rejects all of it.
So the actual pipeline is:
.f (F90-flavoured)
| f77ify.py rewrite to something f2c will accept
v
.f (F77)
| f2c translate to C
v
.c
| five ABI passes make the C agree with itself and with the C callers
v
.c
| emcc compile to WebAssembly
v
.o
Two of those stages contain most of the difficulty. We will take the ABI passes first, because they hold the finding that we think is most useful outside this project.
The thing WebAssembly will not forgive
In WebAssembly, a function’s parameter types and return type are part of its type. Two functions with the same name and different signatures are different things, and a call through the wrong one is not a mild error.
Native code is much looser. If a C file declares void foo_(int *a) and the Fortran that defines it was translated as int foo_(int *a, int len), the native linker resolves the name and stops caring. A wrong return type means the caller reads a register the callee never wrote, which is usually harmless because nobody looks at the value. An extra trailing argument means the callee ignores a register it was never told about. This has worked, in real production code, for decades.
In WebAssembly the same mismatch gives wasm-ld nothing to link against, and it does something worse than failing. It emits a stub for the call whose body is unreachable, prints a warning that is easy to miss, and finishes linking successfully. The failure shows up at runtime, in a browser, as:
RuntimeError: unreachable
at wasm-function[4127]
No symbol name. No source line. Nothing that suggests the cause is a function prototype.
For CalculiX this is not hypothetical. f2c and gfortran disagree about three things a C caller depends on, and CalculiX’s C code was written against gfortran. Each disagreement needed its own fix, and each fix is a small script in tools/.
Name mangling. f2c appends two underscores to any Fortran name that already contains one. gfortran appends one. CalculiX’s C calls op_corio_, and f2c defines op_corio__. The fix renames every occurrence, not just call sites:
def renames(fortran_names):
out = {}
for n in fortran_names:
if '_' in n and not n.endswith('_'):
out[n + '__'] = n + '_'
return out
Return type. f2c translates every Fortran SUBROUTINE as returning int. CalculiX’s C headers declare them void. On x86 this is invisible. In WebAssembly it is a type mismatch. The fix rewrites /* Subroutine */ int foo_(...) to void, and it has to be careful about which ones to leave alone, because a few runtime routines in libf2c really do return int. exit_ and s_stop are two. Those are spelled the same way in f2c output as the ones that return nothing, so the script reads libf2c’s own sources to learn which is which instead of guessing. That took a second script, because 38 of libf2c’s files write the return type on its own line and a line-anchored scan misses every one of them.
Hidden length arguments. For every CHARACTER argument, f2c appends a hidden ftnlen parameter carrying the string length. CalculiX’s C callers do not pass them. Before and after:
/* before */ void foo_(integer *n, char *s, ftnlen s_len)
/* after */ void foo_(integer *n, char *s)
/* call site, before */ foo_(&i, s, (ftnlen)80)
/* call site, after */ foo_(&i, s)
The script only drops a length argument when the callee body never reads it.
Two more passes deal with problems that are the same kind of thing. CalculiX calls some of its own routines with more arguments than they are defined with. cload_ is called with 17 and defined with 13. One routine, umat_compression_only_, is declared with 28 arguments in one file and defined with 30, and that mismatch alone was enough to stop the whole module from instantiating. A padding pass normalizes arity across call sites and definitions. And f2c emits each Fortran COMMON block as a tentative definition in every file that mentions it, which under clang’s default -fno-common becomes a pile of duplicate symbols. We tried -fcommon first. It crashes clang’s WebAssembly backend on ARPACK’s large debug_ and timing_ blocks, so that door is closed and the fix is to keep the first definition and turn every other copy into an extern.
The tool that finds them
The single most useful line in the whole pipeline is one linker flag:
-Wl,--fatal-warnings
wasm-ld’s signature-mismatch warning does name the symbol and both signatures. With warnings fatal, a nameless runtime trap turns into a link error that says exactly which function disagrees with which. Every verification link we run uses it.
We learned why it matters the hard way. The first verification of the CalculiX archive was a smoke test: link libccx.a against a small program that calls a translated routine with a checkable answer, and confirm it works. It passed. But a static archive only pulls in the members a program references, so the test never touched most of the code. The real check links the whole program, including CalculiX’s own main(), which the archive deliberately leaves out. That link failed immediately on cload_ and on system_, whose void declaration disagreed with libf2c’s int. Both had sailed through the smoke test. A weaker verification would have shipped a solver with holes in it and no indication anywhere.
Making Fortran 90 look like Fortran 77
The other large piece is f77ify.py, which at 2,069 lines is bigger than any of the ABI passes combined. It exists because CalculiX’s source is Fortran 77 in fixed form that has picked up Fortran 90 constructs over the years, and f2c accepts none of them.
It rewrites comments, turns cycle and exit into goto, rewrites F90 attribute declarations into F77 form, expands array constructors into explicit assignments or DATA statements, and turns array sections into first-element addresses, implied DO loops, or nested loops depending on where they appear. allocatable arrays become fixed-size static arrays with a runtime bounds guard. Whole-array reductions like maxval and sum(abs(...)) become calls into two small hand-written helpers.
The hardest case was automatic arrays sized by other arguments, for example real*8 x(neq(2)). f2c will not translate these (“adjustable dimension on non-argument”), and 63 of the 977 routines hit it. One of them is e_c3d.f, the three-dimensional element stiffness routine, without which solid finite-element analysis does not work at all. The fix substitutes fixed maximum dimensions, and those bounds are derived, not guessed. For example, the per-element index arrays are sized at 255 because CalculiX’s own userelements.f rejects anything larger with an error message, so the limit is already enforced by the program itself. We measured what happens when a bound is wrong: removing one override took the translated count from 959 of 977 routines to 954 and broke both the elastic and plastic test decks.
For routines that f2c still cannot translate, a last script generates a same-signature stub whose body calls a function that aborts with the routine’s name. That is a deliberate choice. An untranslatable routine that is silently skipped produces a solver that links, runs, and returns wrong stresses. One that aborts by name produces an error message a person can act on.
Two library traps worth knowing
Two problems came from the libraries under CalculiX, not CalculiX. Both are silent.
LAPACK moved to Fortran 90. Starting with release 3.10, several LAPACK routines, including dnrm2 and dlartg, were rewritten as .f90 files that use a Fortran MODULE. A build that globs *.f skips them without a word. We measured 25 .f90 files in 3.12.0. The build asserts the count, because a missing routine only surfaces later as an undefined symbol or, worse, does not surface at all.
dlamch gets stubbed and then aborts on the first solve. Modern dlamch.f, which supplies machine-precision constants that every convergence test in LAPACK and ARPACK reads, uses Fortran 90 intrinsics like EPSILON and TINY. f2c cannot parse them, so the routine gets stubbed with the abort described above, and the failure lands on the first real solve rather than at build time. The fix is to substitute LAPACK’s own portable FORTRAN 77 version, which lives in its INSTALL directory.
How we decide whether to believe the numbers
A solver that runs is not a solver that is right. Compiling a program to a new target changes how its floating-point code executes in ways that are easy to get subtly wrong, and finite-element codes do not fail loudly. A wrong stiffness matrix produces a plausible-looking displacement field.
So verification is layered, and each layer answers a different question.
Does the translated Fortran compute correctly? A smoke test calls straighteq3d_, a small translated routine that builds the four bounding planes of a triangle, on a right triangle with vertices at (0,0,0), (1,0,0) and (0,1,0). The planes have analytic answers, so the check is exact arithmetic on paper. Two reduction helpers are checked the same way.
Does the eigensolver converge to the right eigenvalues? ARPACK’s smoke test runs the real reverse-communication loop (dsaupd and dseupd) on the one-dimensional Laplacian of order 100. That matrix has closed-form eigenvalues, 2 - 2cos(j*pi/(n+1)). The four largest come back matching to 1e-9, with the largest at 3.999032565 against an analytic 3.999032565, in 409 iterations.
Does the whole program get the physics right? This is the deck check, and the one the live demo reproduces. The deck is a single eight-node brick, 1 m on a side, steel with E = 210 GPa and ν = 0.3, restrained on its bottom face and loaded with a 1 MPa tension on top. We chose it because the answer needs no reference solution. For a uniform uniaxial stress state, a single linear hexahedron is exact, not approximate. So there is nothing to calibrate and no mesh to blame:
sigma_zz = 1.0e6 Pa
eps_zz = sigma / E = 1.0e6 / 210.0e9 = 4.761904761904762e-06
u_z(top) = eps_zz * 1 m = 4.761904761904762e-06 m
u_x(x=1) = -nu * eps_zz = -1.428571428571429e-06 m
Any disagreement means the solver is wrong. The check is written to compare, not to converge: absolute tolerance 1e-12 on displacement and 1.0 Pa on stress, with every off-axis stress component required to land within 1 Pa of zero.
We want to be exact about what that tolerance means. CalculiX prints results in its .dat file to seven significant figures. Against a displacement near 4.8e-6, a tolerance of 1e-12 is a relative tolerance of about 2e-7, which is roughly the precision the file format carries. So the claim is that the result is correct to every digit CalculiX reports, which is the strongest statement the output format supports. To test further, you would have to read the binary .frd file instead. We say this here because a tolerance that sounds tighter than the data behind it is a common way for a verification to look better than it is.
Does a new build behave like the last one? Before a rebuilt solver replaces the one in production, our CI runs four decks through both the new module and the live one: an elastic static case, a modal analysis, a plastic case, and a thermal case. They have to match digit for digit, including the round-off noise in the last places, and that gate blocks the release. This does not prove the numbers are physically right. It proves that a change to the build did not change what the solver does.
One bug the live demo caught in its own author
Building the demo for this paper turned up two things worth telling, because they are the same kind of lesson as the rest of the paper.
The first: the page would not run at all, because the shipped module sets up shared memory and a worker pool at startup, and browsers only grant SharedArrayBuffer to pages served with cross-origin isolation headers. A plain static host does not send them. It could not be embedded as an iframe on another site for the same reason, so the demo lives on the FreeCAD site, whose server already sends the right headers for the main application. That is why the link above goes there.
The second: the first version of the demo reported a failing verdict while showing rows that all matched. The results parser used a regular expression with the multiline flag, which quietly changes what $ means from end-of-string to end-of-line, and it read one row of the displacement table instead of eight. The physics was correct and the checker was wrong. A verification tool is also code that has to be verified, and we only caught this because we ran it and looked at what came back.
Getting it into a browser
The finished module is ccx.js and ccx.wasm, built as a modularized Emscripten module. FreeCAD’s FEM workbench writes an input deck and runs ccx -i <job>, then reads back <job>.frd and <job>.dat. In the browser that call has to cross from FreeCAD’s world into the solver’s.
FreeCAD’s Python call suspends the entire WebAssembly stack, CPython frame included, using JavaScript Promise Integration, so the browser stays responsive while the solver runs. A small bridge lazily loads ccx.js, creates a fresh module instance, writes the deck into the solver’s own filesystem, calls the exported fcweb_ccx_run function, and copies whichever result files came back into FreeCAD’s filesystem.
It creates a fresh instance for every solve on purpose. CalculiX keeps its state in global variables and was written as a one-shot process, so reusing an instance would carry one run’s state into the next. The cost is re-instantiating about 4 MB of WebAssembly, which is small next to the solve.
CalculiX’s element assembly can run in parallel, so we built a -pthread version and measured it against the serial one in the browser, through the real bridge. It was correct: on a one-element cube and on an 8,640-element beam it produced byte-identical output, the same 2,008,434-byte .frd file and the same maximum displacement of 0.0199569 mm. It was not faster. The beam took 2.80 seconds serial and 3.09 seconds threaded, and 4.71 seconds with four threads actually configured, because thread creation and atomics cost more in WebAssembly than CalculiX’s parallel sections save at these sizes. Serial is the deliberate configuration, and there are numbers behind that choice.
CalculiX also wants a lot of memory before it does anything. Its static data alone is around 66 MB, so the module link asks for a 256 MB initial heap. The default fails at link time with an unhelpful “initial memory too small.”
What is still open
We would rather tell you than have you find it.
Larger meshes are compared with each other, not with an independent answer. The analytic check above runs on one element. The 8,640-element beam shows that the serial and threaded builds agree with each other to the byte, which tells you the build is consistent and says nothing about whether either matches physics. Our regression gate has the same shape: four decks (elastic, modal, plastic, thermal) must match the previous production module digit for digit, which proves a build change did not alter the solver and does not prove the numbers are right. What is missing is a multi-element deck with a known analytic answer, run repeatedly, so a mistake that only appears at scale has somewhere to show up. That is the next piece of verification work.
A linker mismatch fails in the worst way. The module link flags have to agree with how the archive was compiled. Link a serial archive as if it were threaded, or the reverse, and wasm-ld does not produce an error. It crashes. We drive both steps from a single variable so they cannot drift apart, and we have the crash on record so nobody rediscovers it.
Coverage of CalculiX’s element library is not exhaustively verified. The demo shows one element type solving one load case exactly. FreeCAD’s FEM workbench exercises more of it every day, and the regression decks cover elastic, modal, plastic and thermal analysis. We have not run a systematic sweep of every element type CalculiX supports against reference results.
Why this matters beyond one solver
CalculiX is one example of a large category. Structural mechanics, thermal analysis, fluid dynamics, geophysics and a good share of engineering software were written in Fortran, often in the 1980s and 1990s, and run on the desktops and clusters of the people who need them. Very little of it is reachable from a browser, a Chromebook or a classroom.
The pipeline in tools/ is not specific to CalculiX. The problems it solves, F90 spellings that f2c rejects, three kinds of ABI disagreement, COMMON block duplication and silent stubs, are properties of translating Fortran that was written against gfortran, not properties of this solver. We built ARPACK and LAPACK through it as well. A different solver will need its own fixes at the edges, but the shape of the work is now known and most of the scripts are reusable.
The reason to bother is access. A structural simulation should not require a workstation license and an afternoon of installation. With the solver in the tab, a student can mesh a bracket and see where it bends on the laptop they already have, and a small shop can check a design without buying anything.
What funding moves next
Virtastic is small, and what exists today is paid for by donations and a few custom engagements. If you fund open-source infrastructure and want the fuller picture, the backers page lays out what we build and what support would change.
Concretely, the next dollars go to three things, in this order:
- Multi-element verification against analytic answers, run repeatedly, so the first open item above becomes a closed one.
- Turning the Fortran pipeline in
tools/into a documented standalone tool with its own tests, so someone porting a different Fortran code can start from it instead of from this paper. - The next solver in the chain.
If this is useful to you, there are two ways to help:
You can also just use it. Everything above runs free at freecad.virtastic.app, the build scripts are in freecad-web, and questions and bug reports are welcome in our Discord.
A single steel block, 1 MPa, 977 files of 1990s Fortran, and a displacement of 4.761905e-6 metres that matches the exact answer to every digit the solver prints.
Have a desktop app that belongs in the browser?
We recompile legacy desktop software to WebAssembly, then maintain and host it. See it proven on your own app.