OpenSCAD in your browser

Posted July 4, 2026 by magik · Leave a comment

A few days ago I put LibreCAD in the browser — the whole Qt desktop application, compiled to WebAssembly, running in a tab. The obvious question was whether that was a one-off stunt or a repeatable recipe. So here is the second data point: OpenSCAD, the programmers' solid 3D CAD modeller. You write code, you get geometry. It's the tool behind half the printable brackets on the internet.

To be clear about what this is: not a viewer, not a code editor with a server behind it, and not the (excellent) openscad-playground, which pairs a headless WASM build of the geometry engine with a web-native UI. This is the desktop application — the Qt one, with the QScintilla editor, the docks, the customizer, the console, the same menus — compiled to WebAssembly and drawing its 3D viewport through WebGL. Nobody had shipped that before, as far as I can tell.

Launch OpenSCAD →

First load is ~9.5 MB compressed (Brotli); your browser caches it afterwards. Needs a recent Chromium-based browser (Chrome or Edge 137+) — the port relies on WebAssembly JSPI, which Firefox and Safari don't ship yet.

What's inside

OpenSCAD is a much heavier beast than LibreCAD. Under the GUI sits a real computational-geometry stack: CGAL (exact arithmetic on top of GMP/MPFR), the new Manifold kernel, Clipper2, fontconfig/FreeType/HarfBuzz for text(), glib (its UTF-8 machinery backs the language's string type), lib3mf, libzip, libxml2, Boost, and the QScintilla editor widget. All of it now compiles statically into one WebAssembly module next to Qt 6.11. The headless-CLI precedent from the openscad-wasm project proved the geometry stack could build under Emscripten; the GUI, the viewport and the browser plumbing were the new parts.

The interesting problems

One module, one exception encoding

CGAL leans on C++ exceptions, so everything is built with native WebAssembly exceptions. It turns out WebAssembly currently has two encodings of those — the legacy try/catch instructions and the newer try_table/exnref — and V8 refuses a module that mixes them. My Qt build used the new encoding; the freshly-built dependencies defaulted to the old one; and a few setjmp-based C objects (FreeType, Qt's bundled libpng/libjpeg) turn out to emit legacy instructions regardless of the requested mode, because the sjlj lowering has its own opinion. The fix was layered: build the C++ world uniformly with the new encoding, and let Binaryen's --emit-exnref pass translate whatever legacy instructions survive into the new form after linking. One module, one encoding, V8 happy.

A 2003-era viewport on a 2023-era API

OpenSCAD's renderer is honest fixed-function OpenGL: matrix stacks, glBegin/glEnd (including GL_QUADS and vertices at w=0 for the infinite axes), two glLightfv lights, color material, client vertex arrays as offsets into VBOs, and a sprinkling of GLU — gluPerspective, gluProject, gluUnProject. WebGL2 has none of that. The port adds a compatibility layer (~900 lines) that implements exactly the subset OpenSCAD uses over GLES 3.0: CPU-side matrix stacks in double precision, one GLSL ES program with per-pixel two-light shading, immediate-mode batching with quad-to-triangle conversion, and interception of draw calls only while fixed-function state is active — Qt's own modern-GL rendering passes through untouched. A detail that cost an evening: every QOpenGL-style widget on the web gets its own WebGL context, and WebGL contexts share nothing, so the layer keeps its shader program and buffers per-context.

The compositing problem

The nastiest wall was not drawing the scene — it was getting the drawn scene onto the page. Qt composites an OpenGL widget by wrapping its framebuffer texture in the top-level window's context; that requires resource sharing between contexts, which WebGL simply does not have. The result out of the box is a lost context, a "Failed to create wrapper texture" warning, and a gray rectangle. The port sidesteps the mechanism entirely: on wasm the viewport is a plain widget that renders the GL scene into an offscreen framebuffer, reads it back, and hands the image to Qt's ordinary raster compositing. A read-back per frame sounds barbaric, but for a CAD viewport — where frames happen when you orbit, not sixty times a second — it's perfectly fluid.

Single-threaded honesty

The build is single-threaded (matching the Qt-for-WASM build), which flushed out every place the desktop app quietly leans on threads. The F6 render worker now runs synchronously. The font-cache progress dialog used QtConcurrent; gone. My favourite: on a no-thread Qt build, QMutex::try_lock() is a no-op that always succeeds — which silently defeated a mutex-as-reentrancy-guard in the viewport-control dock and let a half-initialized spinbox write distance = 0 into the camera. Fifteen lines of plain-bool guard restored the laws of physics.

What JSPI gives you for free

The LibreCAD port needed hand-patched Qt and post-processed JavaScript to make nested dialogs work. This time: nothing. Qt 6.10+ upstreamed a suspend–resume architecture — browser events queue in JavaScript and resume one JSPI-suspended processEvents() stack — so app.exec(), the welcome dialog, preferences, combo-boxes, everything just works. It also enables my favourite trick in this port: the file-open picker is a suspending C++ function. The code calls WasmFileIo::pickFileToMemfs(), the wasm stack parks itself while the browser's file dialog is open, and the function returns the path of the file the user chose. Blocking code, no blocked browser.

Files that survive

Your home directory (/home/web_user) lives in IndexedDB: saved .scad files, backups and settings survive reloads. Save and Save As also hand you a downloaded copy, and every export (STL, OFF, 3MF, AMF, CSG, SVG, DXF…) downloads its result. Open picks files from your disk into the in-browser filesystem. The bundled examples and the MCAD library are baked into the image.

Try it

Launch OpenSCAD →

Technical specs

Build
OpenSCADmaster (2026.07 snapshot), branch wasm-port
Qt6.11.1, static, built for wasm with JSPI + native wasm exceptions
Emscripten4.0.12
Exceptionswasm-EH, uniform exnref encoding (post-link --emit-exnref)
GeometryManifold (default) + CGAL 6.0.1 / GMP 6.3 / MPFR 4.2
ThreadsSingle-threaded
Memory modelwasm32, 4 GB ceiling
Transfer sizes (Brotli)
openscad.wasm27.6 MB raw → 7.6 MB
openscad.data (fonts, examples, MCAD, locales)7.3 MB raw → 1.8 MB
openscad.js + loader292 KB raw → 58 KB
Total first load~9.5 MB
What works
Editor (QScintilla)Yes — highlighting, folding, tabs, find/replace
F5 previewYes (ThrownTogether renderer; see caveats)
F6 renderYes (Manifold; CGAL available)
ViewportOrbit / pan / zoom-to-cursor, axes + scale markers, per-object color(), transparency
text() / fontsYes (fontconfig + FreeType + HarfBuzz, bundled Liberation)
Open / Save / Save AsYes (picker in, IndexedDB persistence + download out)
Export STL/OFF/3MF/AMF/CSG/SVG/DXF…Yes (downloads)
Examples + MCAD libraryBundled
Customizer, console, error log, animateYes
TranslationsBundled (~15 languages)
BackupsYes, persisted (IndexedDB)

Caveats

Source code

The port lives on the wasm-port branch of my OpenSCAD fork (upstream: github.com/openscad/openscad). The application-level changes are small and guarded behind __EMSCRIPTEN__; the interesting new files are src/glview/wasm/WasmGLCompat.cc (the fixed-function-GL-on-WebGL2 layer), src/gui/WasmGLWidget.cc (the offscreen-render viewport base), and src/gui/WasmFileIo.cc (the suspending file picker and blob downloads). The desktop build compiles unchanged from the same tree.

License

OpenSCAD is GPL-2.0-or-later; this WebAssembly binary is a compiled form of that source (with CGAL's linking exception honoured), so the same license applies. The source for this exact build is on the branch linked above.


Two for two. The recipe holds: Qt for WebAssembly with JSPI, a static dependency stack, an honest look at whatever the app assumed about filesystems, threads and OpenGL — and a real desktop CAD program runs in a tab. If you make something with it, I'd love to hear about it.

— magik