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.
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.
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.
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 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.
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.
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.
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.
linear_extrude(3) text("hello"); does what it should.| Build | |
|---|---|
| OpenSCAD | master (2026.07 snapshot), branch wasm-port |
| Qt | 6.11.1, static, built for wasm with JSPI + native wasm exceptions |
| Emscripten | 4.0.12 |
| Exceptions | wasm-EH, uniform exnref encoding (post-link --emit-exnref) |
| Geometry | Manifold (default) + CGAL 6.0.1 / GMP 6.3 / MPFR 4.2 |
| Threads | Single-threaded |
| Memory model | wasm32, 4 GB ceiling |
| Transfer sizes (Brotli) | |
|---|---|
| openscad.wasm | 27.6 MB raw → 7.6 MB |
| openscad.data (fonts, examples, MCAD, locales) | 7.3 MB raw → 1.8 MB |
| openscad.js + loader | 292 KB raw → 58 KB |
| Total first load | ~9.5 MB |
| What works | |
|---|---|
| Editor (QScintilla) | Yes — highlighting, folding, tabs, find/replace |
| F5 preview | Yes (ThrownTogether renderer; see caveats) |
| F6 render | Yes (Manifold; CGAL available) |
| Viewport | Orbit / pan / zoom-to-cursor, axes + scale markers, per-object color(), transparency |
| text() / fonts | Yes (fontconfig + FreeType + HarfBuzz, bundled Liberation) |
| Open / Save / Save As | Yes (picker in, IndexedDB persistence + download out) |
| Export STL/OFF/3MF/AMF/CSG/SVG/DXF… | Yes (downloads) |
| Examples + MCAD library | Bundled |
| Customizer, console, error log, animate | Yes |
| Translations | Bundled (~15 languages) |
| Backups | Yes, persisted (IndexedDB) |
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.
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