A free, open-source DWG and DXF viewer that runs entirely in the browser. The drawing is decoded by GNU LibreDWG compiled to WebAssembly and drawn with WebGL. No upload, no account, no server that ever sees a file.
Why it exists: /why · po polsku: /pl/dlaczego
npm install
npm run dev # http://localhost:4321 (Astro picks the next free port if taken)
npm run build # static site in dist/predev and prebuild run scripts/prepare-cad.mjs, which prepares everything the CAD engine loads at
runtime (see below). Node 20+.
| Part | Where |
|---|---|
Pages (EN at /, PL at /pl), one per search intent, static HTML |
src/pages/, strings and routes in src/lib/i18n.ts |
| Layout: title, canonical, hreflang, Open Graph, JSON-LD | src/layouts/Base.astro |
| Viewer UI (open, drop, layers, status) | src/components/Viewer.astro, src/viewer/app.ts |
| CAD engine, loaded only when a file is opened | src/viewer/engine.ts |
| Plot to PDF: geometry read back from the scene, PDF writer, preview | src/viewer/plot.ts, panel in src/viewer/plot-ui.ts |
| Measuring: object snapping, distances and area, overlay | src/viewer/measure.ts, panel in src/viewer/measure-ui.ts |
| Engine runtime assets: workers, WASM, fonts, template | public/cad/, produced by scripts/prepare-cad.mjs |
| Sitemap with hreflang pairs | src/pages/sitemap.xml.ts |
| Security headers, caching, per-worker CSP | public/_headers (Cloudflare Pages) |
The engine is mlightcad (@mlightcad/cad-simple-viewer, MIT) with
@mlightcad/libredwg-converter (GPL-3.0) for DWG. Versions are pinned exactly: the packages release every
few days.
The patches live as data in scripts/cad-patches.mjs, each with an upstream note saying whether it
was reported, where, and what the next step is. Before touching any of them, run:
npm run check-upstream # installs the latest @mlightcad packages in a temp dir, re-tests every patchIt answers the only question that matters per patch: is the bug still in the published package? A
"still broken" line with NOT reported in its note is a contribution waiting to be made; a "check by
hand" line means the code moved upstream, so confirm on the new version before dropping our patch,
and never report a bug that is already fixed.
State as of 2026-09-27: tab-stop hang reported and fixed in
mtext-parser#10 (open); %%d and the converter's
MTEXT newlines are confirmed broken in the latest releases and ready to send; the word-wrap pair is
gone from mtext-renderer 0.13.0, so the next move there is a version bump, not a bug report.
- Patches four rendering bugs in the pinned mlightcad packages (text wrapping, oblique text width, the degree sign, MTEXT line breaks from LibreDWG). Each patch is idempotent and the script fails if a pattern is gone, so upgrading a package cannot silently drop a fix. Details are in the comments next to each patch.
- Copies the workers and WASM into
public/cad/workers/. - Publishes our own font catalogue. The engine's default font repository mirrors AutoCAD SHX and Microsoft
fonts that nobody may redistribute. Instead every common font name maps to
osifont (GPL-3.0 with font exception), widened 15% to AutoCAD's
text metrics by
scripts/make_font.pyso text wraps where the drawing's author saw it wrap. - Checks that
public/cad/templates/acadiso.dxfexists: our blank template fromscripts/make_template.py, instead of the engine fetching Autodesk's from a CDN.
The result: opening a drawing makes no request to any host but this one. The end-to-end check below verifies that.
PDF in the viewer plots the whole drawing, the current view or a window you drag, on a sheet from A4 to A0,
fitted or at a fixed scale, in colour or black and white. The output is vector, written by hand in
src/viewer/plot.ts: no library, no raster snapshot, and text is filled outlines, so no font is embedded and
no font licence is involved.
The geometry is read back out of the renderer's batched three.js objects, which is the fiddly part:
- line batches give segments (chained into polylines), and a linetype shader's
patternuniform becomes a PDF dash array with the phase taken from each polyline's own distance along the line, - filled meshes (MTEXT glyphs, solid fills, wide polylines) are triangle soup; boundary edges are chained back into contours, so a paragraph is one path with holes rather than a few hundred slivers,
- hatches are drawn by a fragment shader over a plain polygon, so the pattern lines are re-created from the uniforms (angle, base, offset, dashes) and clipped to the polygon's triangles.
Only what is on screen is plotted: a slot hidden with its layer is skipped, as the renderer skips it. Because
this reads private fields (_geometryInfo, the layout view's camera), the code is defensive: when a package
update moves them, export fails loudly in the panel instead of writing a wrong PDF.
Measure reads distances off the drawing the way DIST does in AutoCAD: click points, get the length, the
offsets, the bearing, a running total and, from three points on, the area of the closed outline.
Picks snap to the geometry — line ends, crossings, midpoints and the nearest point on a line, in that order of
preference — because a reading taken "roughly there" on a wall is worth nothing. The snapping index is built
from the same PlotGeometry the PDF export produces, so both features share one pass over the scene: segments
go into a uniform grid (about one cell per segment), and a segment too long to bucket lands in a short list
scanned on every query. Hatch strokes are left out, or a wall crossing a hatched wall would offer a hundred
meaningless crossings. On a 102 000-segment drawing the index takes about 10 ms to build and a query is well
inside a frame.
Clicks are read on the drawing container, not on an overlay that swallows them, so panning and zooming keep working while measuring: a press that travels more than a few pixels is a pan, anything shorter is a pick.
What one unit means comes from the file's INSUNITS header variable, and the panel says so. That variable is
not needed to draw, so plenty of drawings carry the wrong one (two of the three test files do: a plan drawn in
centimetres that calls itself millimetres, and a metric detail that calls itself inches). Hence the unit
selector next to the reading: the file's answer is the default, not the verdict. INSUNITS also fixes the
scale label of the PDF export, which used to assume millimetres.
Pages run under a strict CSP. The DWG parser is Emscripten output and needs eval, so /cad/workers/* gets
its own policy with 'unsafe-eval' (a worker runs under the CSP of its own script response).
There is no test suite yet. What was verified by hand before the first release:
npx wrangler pages dev dist(run from a copy ofdist/outside the project, see Deploy): every route returns 200, unknown URLs 404,.htmlURLs redirect to clean ones, headers as inpublic/_headers.- Headless Chrome against that server: the sample DXF and a real 2.6 MB DWG 2004 detail drawing open on desktop and a 390 px phone viewport, with zero console errors and zero requests to other origins.
- Measuring: a LINE taken straight from the database, put on screen and measured with the tool — the readout matched its true length exactly on all three drawings (522, 200 and 180 units), in Chrome and in WebKit, with the pick landing on the endpoint snap. Checked too: panning still works while measuring, a tap measures on a 390 px phone viewport, and the unit selector changes every reading.
- Plotting: both real drawings and the sample exported to PDF (whole drawing, a dragged window, fitted and at a
fixed scale, colour and black and white) in Chrome and in WebKit, then rasterised with
pdftoppmand compared with the canvas: text, hatch patterns, dashed lines and sheet size.
Articles are markdown in content/<en|pl>/<slug>.md (Astro content collection, schema in
src/content.config.ts), paired across languages by key and rendered by src/components/GuideArticle.astro
under /guides/<slug> and /pl/poradniki/<slug>. Hubs: /guides, /pl/poradniki. Hand-written pages keep
living in src/pages and are listed for the hubs in seo/pages.json.
What an article may say is data, not prose:
| File | Holds |
|---|---|
seo/clusters.json |
the topic map: one intent per entry, owner (engine / page / manual), priority, the angle for the writer, which facts it may use |
seo/facts.json |
claims about other products, each with its source, verbatim quotes and a check date; the Sources section is built from it |
seo/features.json |
what the viewer can and cannot do; update it in the same commit as a feature |
seo/measured.json |
our own measurements, the only figures with a unit an article may quote |
seo/policy.json |
product names that need a fact, and claims the site never makes |
automation/ (ported from pdf.techsource.pro, runs from launchd, reports to Telegram):
| Script | When | Does |
|---|---|---|
content-gen.mjs |
Tue + Fri 07:53 (from 2026-10-13) | next topic from the map, Polish then English; claude -p --tools ""; checks from content-lib.mjs; build, commit, push, deploy.sh. Stops when the tree is dirty or the home page is not indexed |
keyword-harvest.mjs |
Mon 06:47 | autocomplete + Super User phrases, filed under topics, committed |
deploy-watch.mjs |
daily 08:51 | status, unique titles, canonicals, robots, 404; IndexNow when the URL set changes |
rank-report.mjs |
Mon 09:23 | Search Console (sc-domain:moonforge.tech, filtered to this host): cannibalisation, positions 11-20, silent and stale pages, unfiled phrases, fresh questions |
facts-check.mjs |
1st of month 10:07 | re-reads every source, flags facts whose quote disappeared, moves check dates |
unpublish.mjs |
by hand | retire an article: 301 in public/_redirects, topic language marked retired, deploy |
test-guards.mjs |
by hand | runs the draft checks on hand-made drafts; run after touching content-lib.mjs or seo/ |
Plists are in automation/launchd/; installed copies live in ~/Library/LaunchAgents. Pause the writer with
launchctl bootout gui/$(id -u)/com.lifeos.dwg-content-gen.
Cloudflare Pages, project dwg-moonforge-tech, custom domain dwg.moonforge.tech (DNS zone on Cloudflare).
Run wrangler from a temporary directory: started inside a Vite project it rewrites the project's config.
./deploy.sh # production
DEPLOY_BRANCH=test ./deploy.sh # preview at test.dwg-moonforge-tech.pages.devA production deploy ends with an IndexNow ping (scripts/indexnow.mjs, key file in public/).
GPL-3.0-or-later, see LICENSE. Component licences are listed on /licenses.
Not affiliated with or endorsed by Autodesk. AutoCAD and DWG are trademarks of Autodesk, Inc.
Made by Moonforge Labs.