Pangram verdict · v3.3
We believe that this document is a mix of AI-generated, and human-written content
AI likelihood · overall
MixedArticle text · 1,824 words · 6 segments analyzed
Posted July 7, 2026 It started with a Hacker News thread: LibreCAD, the whole Qt desktop application, compiled to WebAssembly and running in a tab. magik shipped that, then OpenSCAD, and the pattern looked like a recipe — Qt for WebAssembly, JSPI, a static dependency stack, and an honest audit of whatever the app assumed about threads, filesystems and OpenGL. The obvious next question was how far it scales. So here is FreeCAD: parametric 3D CAD, sketch-constrained solid modeling, an embedded Python, a dozen-plus workbenches. It is roughly an order of magnitude larger than the other two — about 1.5 million lines of C++ and 700 thousand of Python — and it now runs in a browser tab. The port took ~4 days from the HackerNews thread to the current state. The implementation was done almost entirely by Fable, and was a way for me to stress-test Fable capabilities in really big complex projects. If that sounds like a large claim, it is checkable. Every prompt over those four days is reproduced verbatim near the end of this post — all forty-eight of them — and the complete session transcripts, every sub-agent and every workflow, are published as a browseable archive. Launch FreeCAD → First load is ~96 MB compressed (Brotli) — this is a big program and I won’t pretend otherwise; your browser caches it afterward. Needs a recent Chromium-based browser (Chrome/Edge 137+): the port relies on WebAssembly JSPI, which Firefox and Safari don’t ship yet. When it boots, a demo document (a box with a cylindrical cut) opens in a live 3D viewport. What’s inside the rest of this post is a technical note written entirely by Fable to answer the most burning questions, but it is an AI post from here on FreeCAD is not one program; it’s a platform.
Under the Qt GUI sits OpenCASCADE (OCCT), the industrial B-rep geometry kernel; Coin3D with Quarter, an Open Inventor scene graph driving the 3D view; a full embedded CPython 3.14; the PySide6/shiboken6 Qt bindings (so Python code can build Qt UI); and this time, cross-compiled specifically for the FEM workbench, a subset of VTK 9.3 and the Salome SMESH mesher. On top of that: 17+ workbenches — Part, Sketcher, PartDesign, Draft, Spreadsheet, Measure, Surface, Import, Mesh/MeshPart/Points/Inspection/Robot, TechDraw, Assembly, CAM, BIM, FEM, Material and Start. All of it compiles statically into one 196 MB WebAssembly module next to Qt 6.11. Two pieces of that had never been done before in a browser, as far as I can tell: PySide6 and pivy (the Coin bindings) running under wasm at all, and OCCT+Coin+CPython+PySide+VTK+SMESH linked together into a single module. The rest of this post is how it got there, in the order it happened. The build, in sequence This section is long on purpose — the request was every fix and every direction, in order, and the specificity is the point. If you just want to poke at the app, the launch button is above. 0 · Toolchain: a Qt you have to build yourself LibreCAD could use a prebuilt Qt-for-wasm. FreeCAD can’t, for one reason: JSPI. FreeCAD is saturated with modal dialogs — 185 C++ exec() sites and 156 more in Python — and blocking Python code cannot be mechanically rewritten into callbacks. The only way to keep QDialog::exec() semantics is to suspend the wasm stack while the dialog is open, which is exactly what WebAssembly JSPI (JavaScript Promise Integration) does. That requires -feature-wasm-jspi, which requires a Qt built from source.
So the toolchain under /opt/toolchains is Qt 6.11.1 compiled for wasm with JSPI and native wasm exceptions, on Emscripten 4.0.12, sitting next to hand-built static prefixes for OCCT, CPython, ICU, Boost, Xerces, {fmt} and yaml-cpp. 1 · The kernel, headless first The discipline that made a project this size tractable was kernel-first: prove OCCT + CPython + the FreeCAD App layer run in wasm before touching a single pixel of GUI. FreeCAD’s C++ modules are Python extension modules (PyMOD_INIT_FUNC), normally dlopened at runtime. There is no dlopen in a wasm module, so every module is statically linked and registered on the interpreter’s inittab with PyImport_AppendInittab before Py_Initialize. With the home path, the Python environment and a NODERAWFS build for node-side testing sorted, FreeCADCmd ran headless — open an .FCStd, recompute, round-trip STEP — first in node, then in the browser. That was the first thing that shipped, and it’s a product on its own: a scriptable CAD kernel in a tab. 2 · Linking the GUI, and lying to OpenGL Getting the full GUI target to link was mostly bookkeeping — a shared inittab, a QProcess stand-in (no child processes in a tab), guarding the Start module’s timezone/process assumptions. The interesting part was OpenGL. FreeCAD renders through Coin3D, which is honest 1990s fixed-function OpenGL: matrix stacks, glBegin/glVertex, glLightfv, client vertex arrays. WebGL2 has none of that. So the link pulls in a hand-written fixed-function-GL-on-GLES2 shim — the same wall OpenSCAD hit, solved the same way, but for Coin’s much larger GL surface. 3 · Booting the main window A linked module is not a running one. The GUI boot fought two things. First, C++ static-initialization order: with everything in one module, global constructors from OCCT, Coin, Python and Qt run in an order nobody designed for, and a couple of them read state that a later one sets.
Second, the Qt-wasm platform plugin needs specific exported functions and an event loop that can actually suspend. An early Qt message handler was what finally made the blocker legible — it revealed that the event loop needed asyncify to survive app.exec(). With that, the main window came up: document tree, property editor, toolbars, menus, modal dialogs working. Stage 3 gate passed. 4 · A WebGL2 viewport, and the bugs that hid in it This was the hard frontier. Coin’s renderer is ~70 glBegin sites across 20 files plus matrix-stack math; I built a ~900-line fixed-function emulator (WasmGLFixedFunc) that implements exactly the subset Coin uses over GLES 3.0 — CPU-side matrix stacks, one GLSL ES program with two-light per-pixel shading, immediate-mode batching. But the nastiest wall, as OpenSCAD also found, was compositing: Qt composites a GL widget by wrapping its framebuffer texture in the top-level window’s context, and WebGL contexts share nothing. The fix is an offscreen framebuffer the scene renders into (WasmGLWidget), read back and handed to Qt’s ordinary raster compositing. Then a run of bugs that were each a small story. Solids rendered invisible: the GL emulator stubbed glGetDoublev(GL_DEPTH_CLEAR_VALUE) to 0, so the depth buffer cleared to the near plane and everything failed the depth test — a one-line default fixed the universe. A flood of INVALID_ENUM errors: Coin queries legacy fixed-function enums through glGetFloatv/glGetBooleanv that WebGL rejects; intercept and answer them locally. Parts turned white during selection drags because the rubber-band overlay grabbed a blank framebuffer — keep the live scene under the overlay instead. And the long one: the vertex-array fast path. Coin’s isSupported gate refuses vertex arrays unless all ten legacy client-array entry points resolve, so I added no-op glIndexPointer/glArrayElement; then SoVertexArrayIndexer wanted glDrawRangeElements and glMultiDrawElements, absent from WebGL2, so I routed them through the emulator.
The killer was subtler: the emulator’s own scratch VBOs stayed bound after a draw, so the next gather captured them as Coin’s buffers and read vertices from the wrong memory — the scene rendered as a field of dots instead of triangles. Tracking the emulator’s buffers in an _ownBufs set and ignoring them when snapshotting the bindings fixed it, and the vertex-array path went from ~1.3 fps immediate-mode to comfortably interactive. 5 · Two ways to encode an exception For a while the build used Emscripten’s ASYNCIFY plus JavaScript-based exceptions. The migration to JSPI plus native wasm exceptions ran into a WebAssembly wrinkle that also bit the OpenSCAD port: there are two encodings of wasm exceptions — the legacy try/catch instructions and the newer try_table/exnref — and V8 refuses a module that mixes them. Building the whole C++ world uniformly with the new encoding gets you most of the way, but a few setjmp-based objects emit legacy instructions regardless. The fix is a post-link Binaryen pass, wasm-opt --translate-to-exnref, that normalizes whatever survives into one encoding — and a small JS post-process (jspi_postprocess.py) that wraps the timer and event-dispatch callbacks in WebAssembly.promising so JSPI can suspend across them. One module, one encoding, V8 content. The result was 46% smaller and markedly faster than the asyncify build. 6 · PySide6 on WebAssembly (the part nobody had done) A FreeCAD without Python workbenches is half a FreeCAD, and Python workbenches build their UI through PySide6 — the Qt bindings — which had never run under wasm. Getting shiboken6 and PySide6 to build static, no-dlopen, against the wasm CPython, and then getting a Python Gui.getMainWindow() to return a live PySide6 QMainWindow that round-trips to the real C++ window, was its own multi-week subproject. It works: QtCore, QtGui, QtWidgets, signals and slots, all driven from Python in the browser.
That unlocked the Python workbenches. 7 · Seventeen workbenches, one thread The parity push turned on workbench after workbench — PartDesign, Sketcher, Measure, Spreadsheet, Surface, Import, then Mesh/Points/MeshPart/Inspection/Robot with numpy, then TechDraw and Assembly, then Draft, CAM and BIM. Every one flushed out something the desktop app assumes it can do that a single-threaded tab can’t: Sketcher’s constraint solver ran its QR decompositions on a thread (deferred to run inline), and a scatter of modules used QtConcurrent or thread-affinity guards that had to be serialized. The inittab grew a RegName=CInitSuffix form to decouple a Python import name from its C init symbol, a JS→Python command pump let the page drive FreeCAD, and a resource-path bridge reconciled the two layouts FreeCAD looks resources up under (share/Mod/<M> vs the packaged Mod/<M>, and Gui/Resources vs Resources). Persistence landed too: the user config lives in IndexedDB, and the one genuinely tricky bug was hydration timing — the async IndexedDB restore could land after FreeCAD had already read an empty config, so the restore is gated on a run-dependency before init proceeds. 8 · This session: CAM/BIM wouldn’t activate Picking the work back up, activating the CAM or BIM workbench crashed the tab with a memory-access-out-of-bounds deep in Qt’s focus handling. The tempting diagnosis was a PySide/shiboken use-after-free, and I chased that for a while. It was wrong. A chronological trace of the crash showed the main window alive right up until a QMessageBox appeared — a modal “workbench failed to load” dialog, run with exec() during the async activation pump, which spun a nested event loop that tore the app down. The fix was to suppress that modal path on wasm and log instead. The same modal-during-async pattern was behind a STEP-import crash (guarded) and a couple of others. A missing generated resource module (Tux) needed regenerating with rcc and its PySide2 import rewritten to PySide6. 9 · FEM, part one: loading without the mesher The FEM example wouldn’t open — No module named 'Fem'.