Skip to content

About

Free, open-source DWG/DXF viewer that runs entirely in your browser: layers, measuring, vector PDF export. No upload, no account. GNU LibreDWG + WebAssembly.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

dwg.moonforge.tech

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

Run it

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+.

How it fits together

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.

Upstream fixes we carry

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 patch

It 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.

What scripts/prepare-cad.mjs does, and why

  1. 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.
  2. Copies the workers and WASM into public/cad/workers/.
  3. 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.py so text wraps where the drawing's author saw it wrap.
  4. Checks that public/cad/templates/acadiso.dxf exists: our blank template from scripts/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.

Plotting to PDF

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 pattern uniform 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.

Measuring

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.

Content Security Policy

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).

Testing

There is no test suite yet. What was verified by hand before the first release:

  • npx wrangler pages dev dist (run from a copy of dist/ outside the project, see Deploy): every route returns 200, unknown URLs 404, .html URLs redirect to clean ones, headers as in public/_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 pdftoppm and compared with the canvas: text, hatch patterns, dashed lines and sheet size.

Guides and the content engine

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.

Deploy

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.dev

A production deploy ends with an IndexNow ping (scripts/indexnow.mjs, key file in public/).

Licence

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.

About

Free, open-source DWG/DXF viewer that runs entirely in your browser: layers, measuring, vector PDF export. No upload, no account. GNU LibreDWG + WebAssembly.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages