A tiny true-3D renderer for annotated technical drawings, output as plain SVG strings. Rotate → project → depth-sort → paint. Zero dependencies, ~180-line core, fully tested.
The lantern above was imported from a glTF mesh and rendered by the library itself, in Node, with zero client JavaScript. On the live demo it orbits under your pointer — and you can drop your own model.
The category is empty. WebGL libraries (three.js) make shaded surfaces easy and annotated linework painful — thin strokes, dash patterns, line-weight hierarchy, text callouts are all fights. Pseudo-3D toys (Zdog — last release 2019) can't do text or fine-grained depth sorting. Nothing ships "parametric technical illustration": exploded diagrams, dimension lines, balloons, title blocks. This does exactly that, and nothing else.
- The classic pipeline, honestly implemented — 3D points → yaw/pitch rotation → perspective projection → painter's-algorithm depth sorting per frame → SVG string. Paint order is computed, never authored.
- Shapes built for drawings, not games — multi-stroke paths (hollow-tube outlines as one shape), discs that project to correct ellipses, backface-culled boxes, per-object sorting for animated parts.
- SVG strings are the point — themeable with CSS variables, crawlable, printable, accessible, and renderable server-side at build time.
- Styling is yours — the library emits class names you define; it never dictates a look.
Authoring should read like drafting, not like assembling tuples. Context blocks scope parts, tags and layering over everything drawn inside them:
import { sketch, scene } from "linework/sketch";
const s = sketch({ yaw: 0.5, pitch: 0.16, f: 1500, cx: 460, cy: 320 });
s.box(300, 380, 320, 42, 40, 80); // base plate
s.part("housing", 'class="prt"', () => { // one <g>, one sort unit
s.cyl([460, 218], 70, 34, -34, "ink"); // bearing body
s.bias(0.6, () => s.cap([460, 218], 34, 32, "ink")); // bore, layered above
});
s.tube(9).M([180, 400]).Q([300, 420], 20, [420, 340], 10); // hollow frame tube
s.note("320 mm", [460, 452]); // paper-space annotation
el.innerHTML = s.render(); // depth-sorted SVGFor animation, scene() gives you the frame-loop idiom — define once, replay with a new view per frame:
const draw = scene((s, { explode }) => { /* build with s.* */ });
el.innerHTML = draw({ yaw, pitch, f: 1500, cx: 460, cy: 320 }, { explode });
// ~1 ms for a few hundred shapes — drag-to-orbit rebuilds are freePrefer bare metal? linework exports the raw Shape types + render()/xform(), and linework/helpers sits in between.
linework/import turns a 3D mesh — glTF/GLB, OBJ, STL, or a three.js BufferGeometry — into linework strokes. A shaded model carries no lines — its form lives in where the surface bends — so it recovers exactly the lines a draftsperson would draw: the outline and the hard creases, nothing from the smooth interior of a face. The result drops straight into render() and rotates like any other scene.
(That lantern in the header is exactly this: a CC0 glTF — 5,394 triangles of shaded mesh → ~2,500 feature edges → rotatable line drawing, in one meshToShapes() call. Drop your own .glb on the demo.)
import { parseGLB, meshToShapes } from "linework/import";
import { render } from "linework";
const meshes = parseGLB(await file.arrayBuffer()); // parseOBJ · parseSTL · fromBufferGeometry
const shapes = meshToShapes(meshes, {
angle: 25, // crease threshold°
fit: { cx: 400, cy: 300, size: 440 }, // fit into a screen box
});
el.innerHTML = render(shapes, { yaw: 0.6, pitch: 0.35, f: 1400, cx: 400, cy: 300 });featureEdges(mesh) is exposed on its own if you want the raw edge list. Vertices are welded by position first, so meshes that split a shared edge across primitives still sort as one surface. No Draco, and geometry-only — textures and materials are ignored.
Formats:
| Input | Function | Notes |
|---|---|---|
| glTF / GLB | parseGLB(buffer) |
embedded buffers, node transforms; no Draco |
| OBJ | parseOBJ(text) |
fan-triangulated |
| STL | parseSTL(buffer | text) |
binary or ASCII; the 3D-printing format |
| three.js | fromBufferGeometry(geo) |
reads the typed arrays; no three.js dependency |
| STEP / IGES | fromOcct(result) |
via occt-import-js (OpenCASCADE WASM) — you bring the kernel; linework stays tiny |
STEP is a trimmed-NURBS B-rep, not a mesh — tessellating it is a job for a real CAD kernel, so linework doesn't embed one. occt-import-js returns meshes that fromOcct() maps straight in, keeping the ~6 MB kernel an optional peer rather than a dependency.
Imported geometry is just shapes — so you layer paper-space annotations (overall dimensions, callout balloons, a title block) over an import exactly as you would a hand-authored scene. The live demo dimensions the lantern automatically from its mesh bounds and balloons its extreme features; toggle Annotate to see the annotation layer come and go over the same rotating model.
| Field | Meaning |
|---|---|
x |
right, in your SVG's user units |
y |
down — screen convention, not math convention |
z |
toward the viewer; negative recedes and depth-dims |
view.yaw |
rotation about the vertical axis through cx (radians) |
view.pitch |
rotation about the horizontal axis through cy; positive looks down |
view.f |
perspective focal distance — k = f/(f−z); larger = flatter |
bias |
per-shape depth nudge for deliberate coplanar layering |
Paper space: annotations (dimensions, balloons, title blocks) are strings appended after the sorted scene — they never rotate, exactly like a real drawing's notes. sketch.pt(p, z) projects model points so leaders can pin paper to model.
Known limitation (shared by every painter's-algorithm renderer): cyclic overlaps can't sort correctly — split long members into segments if you construct one.
render() is a pure string function. Static site generators can emit finished 3D-looking diagrams with zero client JS:
import { writeFileSync } from "node:fs";
writeFileSync("diagram.svg", wrapInSvgTag(render(shapes, view)));The lantern hero above is generated exactly this way — scripts/gen-import-demo.mjs imports the committed model and renders the SVG in Node (npm run images); the demo page runs the same code live. For a hand-authored server-side example, see docs/scene.js.
npm i linework # ESM, types included
npm test # projection, parallax, paint order, culling,
# sketch scoping, and feature-edge extractionESM-only (no CJS build), Node ≥ 18, zero runtime dependencies. Which entry runs where:
| Entry | Runs in | Notes |
|---|---|---|
linework · /helpers · /sketch · /import |
Node and browser | pure functions; import uses TextDecoder/DataView, both universal |
linework/orbit |
browser only | uses requestAnimationFrame, matchMedia, performance |
render() and the importers run server-side, so you can generate diagrams at build time (see Server-side rendering); only the drag-to-orbit helper needs a browser. Imported files are treated as untrusted input — see SECURITY.md for the parsing threat model.
Versioning: while 0.x, minor releases may contain breaking changes and patch releases won't. From 1.0, standard semver — a breaking change to the public API bumps the major. Releases publish from CI with provenance.
Small, dependency-free, and test-driven on purpose — see CONTRIBUTING.md. Changes are tracked in CHANGELOG.md. Issues and PRs welcome.
Extracted from Fitment — a "will that part fit your bike?" planner whose exploded service-manual drawings are rendered entirely by this engine, live-orbitable, with dimension callouts in paper space over the rotating model.
MIT © Isaac Rowntree