npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

linework

v0.4.0

Published

Tiny true-3D renderer for annotated technical drawings in SVG — rotate, project, depth-sort, emit strings. Zero dependencies.

Downloads

23

Readme

linework

npm ci license: MIT zero dependencies live demo

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.

▶ Try the live demo — drop a 3D model, watch it become a drawing

A street lantern imported from a glTF mesh and rendered by linework as feature-edge linework

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.

Why

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.

The sketch layer — DX is a feature

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 SVG

For 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 free

Prefer bare metal? linework exports the raw Shape types + render()/xform(), and linework/helpers sits in between.

Import a 3D model → a technical drawing

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.

Coordinates (read this once)

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

Server-side rendering

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.

Install & test

npm i linework        # ESM, types included
npm test              # projection, parallax, paint order, culling,
                      # sketch scoping, and feature-edge extraction

Compatibility

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

Contributing

Small, dependency-free, and test-driven on purpose — see CONTRIBUTING.md. Changes are tracked in CHANGELOG.md. Issues and PRs welcome.

Provenance

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.

License

MIT © Isaac Rowntree