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

fast-mesh-wasm

v0.5.3

Published

Fast triangle mesh utilities for JavaScript, written in Rust and compiled to WebAssembly

Readme

fast-mesh-wasm

npm CI license

Fast triangle mesh utilities for JavaScript and TypeScript, written in Rust and compiled to WebAssembly.

Live demo: the showcase app in your browser.

API reference: the TypeScript API of the fast-mesh-wasm package and the Rust API of the workspace crates, rebuilt from main on every push. Task-oriented recipes live in the cookbook.

  • Works in browsers (Vite, webpack, Next.js), Node.js and Bun
  • Installs the same way with npm, pnpm and bun. No native build step, no install scripts, zero dependencies
  • Full TypeScript types and inline docs, generated from the Rust source
  • A Scene for interactive work: placements, queries, distance-field colouring, sections, repair and undo/redo
  • Uses the flat buffer layout of WebGL and three.js BufferGeometry, so data goes in and out without conversion

Status: early development (0.x). The API may change between minor versions.

Install

npm install fast-mesh-wasm
pnpm add fast-mesh-wasm
bun add fast-mesh-wasm

Usage

import { init, computeVertexNormals, computeBounds } from "fast-mesh-wasm";

await init(); // loads the .wasm file; required in browsers, a no-op in Node.js and Bun

// A unit quad: 4 vertices, 2 triangles
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0]);
const indices = new Uint32Array([0, 1, 2, 0, 2, 3]);

const normals = computeVertexNormals(positions, indices);
// Float32Array [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]

const bounds = computeBounds(positions);
// { min: [0, 0, 0], max: [1, 1, 0], center: [0.5, 0.5, 0], size: [1, 1, 0] }

Inputs can be typed arrays or plain arrays: Float32Array positions and Uint32Array indices are used without copying, while Uint16Array, Float64Array and number[] are converted once.

With three.js

const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setIndex(new THREE.BufferAttribute(indices, 1));

const normals = computeVertexNormals(geometry.attributes.position.array, geometry.index!.array);
geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));

three.js adapter

fast-mesh-wasm/three binds Scene meshes to THREE.Mesh objects that follow the scene on every scene.commitFrame(). It needs three (an optional peer dependency, >=0.160) and, for TypeScript, @types/three:

npm install three
npm install -D @types/three
import * as THREE from "three";
import { TransformControls } from "three/addons/controls/TransformControls.js";
import { Scene } from "fast-mesh-wasm";
import { attachTransformControls, bindMesh, bindSection } from "fast-mesh-wasm/three";

const scene = new Scene();
const a = scene.addMesh(positionsA, indicesA);
const b = scene.addMesh(positionsB, indicesB);

const threeScene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer();
renderer.localClippingEnabled = true; // needed for bindSection's clipping plane

const boundA = bindMesh(scene, a); // a THREE.Mesh in boundA.object
const boundB = bindMesh(scene, b, { color: 0x88aaff, opacity: 0.6 });
threeScene.add(boundA.object, boundB.object);

// Drag B with a TransformControls gizmo: one gesture is one scene drag and one undo step.
const controls = new TransformControls(camera, renderer.domElement);
controls.attach(boundB.object);
threeScene.add(controls.getHelper()); // three >= 0.169; on older three use threeScene.add(controls)
attachTransformControls(controls, scene, boundB);

// A section: contours and fill, and the bound meshes are clipped at its plane
// (the side the section normal points to stays).
const section = scene.addSection({ meshes: [a, b], plane: { origin: [0, 0, 0], normal: [0, 0, 1] } });
const boundSection = bindSection(scene, section, [boundA, boundB]);
threeScene.add(boundSection.group);

function frame() {
  scene.commitFrame(); // once per animation frame, before rendering: the adapters apply the changes here
  renderer.render(threeScene, camera);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
  • Colouring (scene.setColoring) shows up as a vertex-colour attribute with no code in the app. Undo and redo move the objects through the same change stream.
  • The engine's colour maps are sRGB-encoded bytes. The adapter converts them to three's linear working space as it copies them (a normalized Uint16 colour attribute), so they look the same as in a renderer that passes the bytes through to a non-sRGB canvas.
  • A material you pass to bindMesh is used as given and must not be shared between bound meshes (the adapter controls its color, vertexColors, opacity, transparent and depthWrite). With shading: "fragment" it also patches the material's shaders and disposes its compiled program on bind and on restore (see Fragment shading).
  • The adapter only reads scene.onChanges; it never calls takeChanges.
  • Bound meshes take their bounding box and sphere from the scene's live-vertex bounds (see Scene.bounds), recomputed only when positions or indices change; on a host without them the adapter walks the mirrors instead.
  • Bound objects (and section groups) must sit under an untransformed parent: their matrices are driven as world matrices, so a transformed ancestor offsets them and is warned about once.
  • With new Scene({ debug: true }), the views the adapter copies from are checked Proxy objects (see the Scene API section for their cost); the mirrors it uploads are always plain typed arrays.

Textures

Add a mesh with { uvs } (2 floats per vertex, read back zero-copy with scene.views(mesh).uvs()) and pass the texture to bindMesh:

const mesh = scene.addMesh(positions, indices, { uvs });
const bound = bindMesh(scene, mesh, { map: texture });

importGLTF returns uvs from TEXCOORD_0 and exportGLTF writes them. Every mesh operation keeps UVs aligned or drops them, undoably: weld keeps seams apart, simplify, subdivide, remesh, fillHoles and cutByPlane interpolate, and boolean drops them. See docs/uv-channel.md for the table.

Fragment shading

Vertex colours interpolate across each triangle, so the edge between coloured and base-coloured vertices blurs over a triangle's width. bindMesh(scene, mesh, { shading: "fragment", edgeSoftness }) colours every pixel from the interpolated field value through the colouring's rule instead (a lookup texture and a shader chunk added with onBeforeCompile): range edges and colour steps, such as a contact heatmap's tolerance band, are crisp iso-lines, and at a vertex the colour equals its vertex colour. Far (Infinity) and NaN values never tint their neighbours.

  • edgeSoftness (distance units, default 0 for a hard edge) widens those edges into a blend; bound.setEdgeSoftness(width) changes it with no recompile. Both throw INVALID_ARGUMENT unless the width is a finite number >= 0. <FastMesh> draws a bad width as 0, with a warning.
  • bound.shading says what is in use. It is "vertex" (the default) unless you ask for "fragment" and it can be done. Fragment shading is WebGLRenderer only: with a material three does not build from its own shader chunks (a node material for WebGPURenderer, say) or a scene host without coloring() and views(mesh).values, the binding falls back to "vertex" with a console warning.
  • The binding patches the material, so each fragment-shaded binding needs its own (material.clone()); binding one that another live fragment-shaded binding uses throws INVALID_ARGUMENT. While patched, the material's vertexColors is off. It calls the material's dispose() when it binds and again when the binding is disposed and puts the material back (handler, cache key and vertexColors), so its compiled program is rebuilt with the shading and then without it.
  • It reads views(mesh).values(): the values the colours are made from, one f32 per vertex slot, changing only at the colours' dirty slots. On a WorkerScene that mirror is opt-in: it turns on at the first values() read and fills with the next result (until then results carry no value bytes, so vertex shading pays nothing).
  • It reads scene.coloring(mesh) to follow the rule: on Scene the rule last set, on WorkerScene the rule applied (a new one shows with the batch that applies it, a rejected one never). Either way it is a frozen copy of the rule given to setColoring, the same object until the rule changes, or null without colouring.

A renderer-neutral version of the same loop, with raw WebGPU and no 3D library, is in examples/webgpu.

Scene API

For interactive work with several meshes, the Scene class keeps the meshes in WebAssembly memory and does the heavy parts in Rust: placements, a BVH per mesh, queries, signed distance fields with colour maps, planar sections, hole repair and undo/redo. Your renderer reads the results through zero-copy typed-array views, and you upload only the ranges that changed.

import { init, type MeshHandle, Scene, type Quat, type Vec3 } from "fast-mesh-wasm";

await init();
const scene = new Scene();

// Provided by your app (not part of this package): mesh data and the
// renderer's upload functions.
declare const upperPositions: Float64Array, upperIndices: Uint32Array;
declare const lowerPositions: Float64Array, lowerIndices: Uint32Array;
declare function uploadMatrix(mesh: MeshHandle, matrix: Float64Array): void;
declare function uploadColors(mesh: MeshHandle, startVertex: number, rgba: Uint8Array): void;

// Add meshes. Positions are in world space; the scene stores them relative to
// the mesh's bounding-box centre so single precision stays accurate.
const upper = scene.addMesh(upperPositions, upperIndices);
const lower = scene.addMesh(lowerPositions, lowerIndices);

// Colour the upper mesh by its signed distance to the lower one.
const gap = scene.addDistanceField({ name: "gap", on: upper, to: lower, maxDistance: 2 });
const colour = (field: typeof gap) =>
  scene.setColoring(upper, { field, map: "turbo", range: [-0.5, 1] });
colour(gap);

// Drag the lower mesh. The whole drag is one undo step. Each `move`
// recomputes the eager fields, so coalesce pointer events to one `move` per
// animation frame (`scene.drag(mesh, { coalesce: true })` does that for you).
const drag = scene.drag(lower);
let pending: { rotation: Quat; translation: Vec3 } | undefined;
function onPointerMove(rotation: Quat, translation: Vec3) {
  pending = { rotation, translation }; // keep only the latest pose
}
function onPointerUp() {
  if (pending !== undefined) drag.move(pending);
  pending = undefined;
  drag.end();
}

// Once per animation frame: take what changed and upload only that.
function frame() {
  if (pending !== undefined) {
    drag.move(pending); // fields and colours follow
    pending = undefined;
  }
  for (const change of scene.commitFrame()) {
    const views = scene.views(change.mesh); // fresh views; do not keep them
    if (change.placementChanged) {
      uploadMatrix(change.mesh, scene.matrix4(change.mesh)); // Float64Array(16)
    }
    const colors = views.colors();
    if (colors !== null && change.colors !== "all") {
      for (const [start, count] of change.colors) {
        uploadColors(change.mesh, start, colors.subarray(4 * start, 4 * (start + count)));
      }
    } // "all" (a new mesh, or buffers that grew): re-upload the whole buffer
    // positions, normals and indices work the same way
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

// Toggle the colouring (display settings are not part of the undo history).
scene.setColoring(upper, null);
colour(gap);

// Undo and redo placements and edits.
if (scene.canUndo()) scene.undo();
scene.redo();

// Queries are in world space.
const hit = scene.raycast([0, 0, 50], [0, 0, -1]); // nearest hit over all meshes, or null
const closest = scene.meshDistance(upper, lower); // { distance, a, b, ... }

scene.free(); // or `using scene = new Scene()`

Errors are FastMeshErrors with a code (UNKNOWN_MESH, INVALID_ARGUMENT, STALE_BVH, ...). After a trap inside the WebAssembly module every later call throws INSTANCE_POISONED. A view is only valid until the next scene call that may allocate or move buffers: a kept view may read other data or be empty, so re-acquire views after any scene call. new Scene({ debug: true }) makes views warn when they are read after such a call. Mesh edits (scene.edit), analyze, measure, repair, register (rigid ICP alignment of two meshes), sections (addSection, section, sectionGap) and findContacts are documented in the types.

Contact groups: fields to several meshes

A distance field can measure to several meshes at once. Give to a list, and each vertex holds the smallest signed distance over them: the nearest surface, or the deepest penetration. So a mesh touching several others shows one colour scale, whichever partner it touches.

// a, b, c, d: MeshHandles. Contacts a-b, b-c, a-c and a-d: one field per mesh, to its partners.
const fa = scene.addDistanceField({ name: "contact", on: a, to: [b, c, d], maxDistance: 1 });
const fb = scene.addDistanceField({ name: "contact", on: b, to: [a, c], maxDistance: 1 });
scene.fieldTargets(fa); // [b, c, d]
scene.fieldNearest(fa); // Uint32Array: per vertex, the index in [b, c, d] it came from (0xffffffff: none)
  • Exact. The values are bit for bit the minimum of separate single-target fields, ties going to the earliest target, with the parallel feature on or off.
  • Only related fields recompute. Moving d recomputes a's field (d is one of its targets) and d's own. Moving a recomputes every field that has a, and b's field to [a, c] is left alone when only d moves. Within a field, only the vertices near the moved target, or whose value came from it, are recomputed.
  • The best value so far bounds the next target's search where that stays exact. Once a vertex has a negative value (a penetration), a later target can only win by being deeper, so its search stops at that depth. In "winding" sign mode a positive value bounds the search too, with a winding-number test for a deeper inside. A positive value cannot bound a pseudonormal target, since a vertex inside it is negative however far its surface is. Either way a field to three targets is one field to keep, one colour pass and one value per vertex.
  • Removing a target mesh removes it from the field; removing the last one removes the field (and its colouring). fieldStatus(field).sign sums up the targets (closed only if every target is closed); fieldTargetStatus(field) gives each one.
  • Rust. fast_mesh_scene::DistanceField::to is now a Vec<MeshId>; DistanceField::new(name, on, to, max_distance, sign) builds the single-target form.

On a WorkerScene the same to: [...] works; read the indices with await scene.fetchFieldNearest(field) and the per-target status with await scene.fieldTargetStatus(field). With the store, see setContactGroup below.

Worker scene

fast-mesh-wasm/worker runs the same engine in a Web Worker, so adding a large mesh, building fields or recolouring never blocks the page. createWorkerScene() starts the Worker and resolves to a WorkerScene with the Scene methods. Your renderer keeps reading synchronous views: every result carries the bytes of its dirty ranges, which are copied into main-thread mirrors.

import * as THREE from "three";
import type { DragSession, PlacementInput } from "fast-mesh-wasm";
import { createWorkerScene } from "fast-mesh-wasm/worker";
import { bindMesh } from "fast-mesh-wasm/three"; // optional: the three.js adapter works on a WorkerScene too

// Provided by your app (not part of this package).
declare const upperPositions: Float64Array, upperIndices: Uint32Array;
declare const lowerPositions: Float64Array, lowerIndices: Uint32Array;
declare const renderer: THREE.WebGLRenderer, threeScene: THREE.Scene, camera: THREE.Camera;
declare const button: HTMLButtonElement, undoButton: HTMLButtonElement;

const scene = await createWorkerScene(); // no init() needed: the Worker loads the wasm

// Handles come back at once; the work runs in the Worker.
const upper = scene.addMesh(upperPositions, upperIndices);
const lower = scene.addMesh(lowerPositions, lowerIndices);
const gap = scene.addDistanceField({ name: "gap", on: upper, to: lower, maxDistance: 2 });
scene.setColoring(upper, { field: gap, map: "turbo", range: [-0.5, 1] });
const boundUpper = bindMesh(scene, upper);
threeScene.add(boundUpper.object);

// Drag the lower mesh: one drag per gesture, the latest pose kept until the next frame.
let drag: DragSession | undefined;
let pending: PlacementInput | undefined;
function onPointerDown() {
  drag = scene.drag(lower); // the whole drag is one undo step
}
function onPointerMove(pose: PlacementInput) {
  pending = pose; // keep only the latest pose
}
function onPointerUp() {
  if (pending !== undefined) drag?.move(pending);
  pending = undefined;
  drag?.end(); // returns at once
  drag = undefined;
}

// Once per animation frame: send this frame's requests, receive the results so far.
function frame() {
  if (pending !== undefined) drag?.move(pending); // at most one move per frame
  pending = undefined;
  scene.commitFrame(); // the adapters' listeners apply the changes here, as with Scene
  renderer.render(threeScene, camera);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

// Status of each piece of state: "idle" | "computing" | "ready" | "error".
scene.onStatus(() => {
  const coloring = scene.status(`display:${scene.idOf(upper)}`);
  button.classList.toggle("busy", coloring.status === "computing");
  undoButton.disabled = !scene.canUndo; // canUndo / canRedo are getters here
});

// Queries are async.
const hit = await scene.raycast([0, 0, 50], [0, 0, -1]);
const distance = await scene.meshDistance(upper, lower);
const values = await scene.fetchFieldValues(gap); // `fieldValues` on Scene
  • The frame loop. commitFrame() sends everything queued since the last frame as one batch and returns the change records of the results received meanwhile (already applied to the mirrors, so views, matrix4, placement, section and sectionPlane read the new state). Results arrive between frames; nothing changes in the mirrors outside a message.
  • Requests run in order. A newer colouring, section plane/meshes/fill or drag move drops the older one if it has not started yet, so fast toggling settles on the last state without doing the steps in between. Edits, repairs, setPlacement, adds, removals and undo/redo are never dropped. Each setPlacement is an undo step: for pointer-rate updates use a drag. Long work runs in steps: the Worker yields every few milliseconds during a field build, a field update or a large add, so newer batches are received (and coalesced) meanwhile.
  • Drags are optimistic. scene.drag(mesh).move(pose) moves the placement mirror at once, so the object follows the pointer; colours and sections catch up when the Worker's result arrives. In 9a a key's status covers only requests made under that key: a drag, setPlacement or edit puts its own key (placement:<id>, edit:<id>) into "computing", not the display:<id> or section:<id>:* keys of the state that catches up after it. end() and cancel() return at once; cancel() restores the start pose and records nothing.
  • Wide-range colouring during a drag. After each batch the Worker recomputes the fields the batch made due in small steps, and drops the recompute when a newer move, setPlacement, edit, repair, compact, undo/redo or removal of either mesh is already queued; other requests wait for it. A pose's colours are applied all at once, never partly. So while the pointer keeps moving and one recompute takes longer than a frame, newer moves drop the recompute, until 200 ms have passed since the last colours were applied: then the recompute under way finishes and is shown, and the newest pose follows. During a sustained drag the colours therefore update about every 200 ms plus one recompute, each update exactly one pose, and when the drag ends (end()) the last pose's colours arrive within about one recompute (measured: one at 1M, but about 1.5 at 10M when most of the mesh is in range): the end drops a recompute kept for an older pose. A pointer that only pauses, without ending the drag, may wait up to two recomputes. Edits, undo/redo, removals and cancels always drop a stale recompute. fetchFieldValues() and fieldStatus() first bring every due field up to date in one go, which cannot be dropped, so do not poll them during a drag. On two touching 1M-triangle meshes with maxDistance 8, a recompute takes from about 0.07 s to 0.7 s depending on how much of the mesh is in range, and about ten times that at 10M; the Worker is never blocked for long, since a recompute runs in steps: about 12 ms at a time at 1M, and 14 to 18 ms for field builds and edits at 10M (see docs/benchmarks.md). These are single-threaded figures; on a cross-origin isolated page the threaded engine makes a recompute 1.4 to 4.5 times faster (see Multithreading (COOP/COEP)). A contact range (a small maxDistance) keeps up with the pointer.
  • Queries and anything that returns data (raycast, closestPoint, meshDistance, intersects, findContacts, analyze, measure, register, edit, repair, fetchFieldValues, fieldStatus, sectionGap) return promises. Once a frame loop runs they go out with the next commitFrame() (or after 100 ms if no frame comes); before the first commitFrame() they are sent on a microtask. For latency-critical picking, keep a light picking copy in a main-thread Scene.
  • Errors. A failed state change shows on its key (status(key).error, a FastMeshError with the same code as on Scene); a failed promise rejects with it. After a trap inside the Worker every call fails with INSTANCE_POISONED, also when it happens on one of the multithreaded engine's pool threads (a panic or a trap in work split across threads); a few kinds of pool-thread failure still cannot be detected (see Multithreading (COOP/COEP)). await scene.restart() starts a new Worker and replays the scene from the main thread's mirrors: meshes (compacted: slot ids change, handles do not), placements, fields, colourings and sections. History is cleared, and edits or queries that had not finished fail and must be re-issued.
  • Not in the Worker scene: user-opened steps (beginStep/endStep/cancelStep), Lazy and Manual modes (it is always eager) and takeChanges (use onChanges or the return value of commitFrame()). terminate() stops the Worker.
  • Large meshes. addMesh copies its inputs on the main thread (they are then transferred to the Worker) and, without an origin option, scans the positions for the bounding-box centre. That takes about 9 ms for 1M triangles and about 63 ms for 10M (see docs/benchmarks.md); passing origin skips the scan.
  • Large meshes are added in the background. A mesh of 65,536 triangles or more is built in steps inside the Worker, its upload, store build, normals and the copy of its buffers for the main thread included (about 1.1 s at 1M and 12 s at 10M, single-threaded) while the requests sent after it that do not need it run: toggles, edits, drags and queries of other meshes are answered within milliseconds instead of waiting. A request that names the new mesh (or a field not built yet that is measured on or to it), adds or removes a mesh, or queries every mesh (raycast, closestPoint without a mesh, findContacts without pairs) waits for it and finishes it first (in one call on a threaded engine, in steps that still yield on a single-threaded one), so the engine's state is the same as running the requests in order. Replies can come in another order, though: until the mesh lands, status("mesh:<id>") reads "computing" and its views are empty; its buffers arrive with that status turning "ready", so a reply to an unrelated request sent after the add can arrive first. createWorkerScene({ backgroundAddTriangles }) sets the threshold; Infinity turns background adds off (every add then comes back with its own batch's result).

Bundlers. The Worker script is a separate file, worker-entry.js, loaded with new Worker(new URL("./worker-entry.js", import.meta.url), { type: "module" }). Vite and other bundlers that understand this pattern pick it up and emit it with the .wasm it loads: examples/webgpu uses the default with Vite, in both vite build and vite dev (open it with ?worker). If your setup does not handle the pattern, serve the package's dist/ folder (the Worker script loads the files next to it) and pass the script's URL, or create the Worker yourself:

const scene = await createWorkerScene({ workerUrl: "/fast-mesh-wasm/worker-entry.js" });
// or
const scene = await createWorkerScene({ createWorker: () => new Worker(myUrl, { type: "module" }) });

Node.js. fast-mesh-wasm/worker has no zero-config Node entry: createWorkerScene() with no arguments creates a browser Worker. In Node use the synchronous Scene instead — each call blocks until it returns, which is fine for a batch pipeline with no frame budget (repair, distance checks):

import { Scene } from "fast-mesh-wasm"; // Node entry: the .wasm loads from disk on import

const scene = new Scene(); // no init() needed, as in browsers
const upper = scene.addMesh(upperPositions, upperIndices);
const lower = scene.addMesh(lowerPositions, lowerIndices);
scene.repair(upper, { kind: "auto" }); // weld, remove degenerate, orient, remove small pieces, fill holes
const gap = scene.addDistanceField({ name: "gap", on: upper, to: lower, maxDistance: 2 });
scene.setColoring(upper, { field: gap, map: "turbo", range: [-0.5, 1] });
const distance = scene.meshDistance(upper, lower)?.distance ?? Infinity;
const colors = scene.views(upper).colors(); // RGBA8, 4 bytes per vertex slot (null when uncoloured)

WorkerScene still runs in Node through the createWorker option with a small node:worker_threads adapter. It is always single-threaded there: Node reports no crossOriginIsolated, so threads is ignored and scene.threads reads 1. The bench and test harnesses do this (packages/fast-mesh-wasm/bench/worker-harness.mjs, test/harness/node-worker.mjs); the shape is:

import { Worker } from "node:worker_threads";
import { createWorkerScene } from "fast-mesh-wasm/worker";

const scene = await createWorkerScene({
  createWorker: () => {
    const worker = new Worker(workerUrl, { workerData: { serve: true } });
    const listeners: ((e: { data: unknown }) => void)[] = [];
    worker.on("message", (data) => {
      for (const cb of listeners) cb({ data });
    });
    return {
      postMessage: (msg: unknown, transfer?: Transferable[]) => worker.postMessage(msg, transfer),
      addEventListener: (type: string, cb: (e: { data: unknown }) => void) => {
        if (type === "message") listeners.push(cb);
      },
      terminate: () => worker.terminate(),
    };
  },
});

where the worker script serves the engine on parentPort (see bench/worker-harness.mjs). Prefer the synchronous Scene above unless a headless pipeline needs WorkerScene parity (status keys, background adds).

Multithreading (COOP/COEP)

On a cross-origin isolated page the Worker scene runs a multithreaded engine: its WebAssembly runs on a pool of Workers that share its memory, and the work that the Rust crates' parallel feature splits (BVH builds, sign data, distance and closest-point queries, near-triangle searches, see "Parallel" in docs/benchmarks.md) is split across them. Results are bit-identical to the single-threaded engine. On two touching 1M-triangle meshes with a wide distance field, 8 threads made a drag's recompute about 1.6 to 4.5 times faster and halved the longest Worker block (headless Chrome; see "Threaded Worker" in docs/benchmarks.md). Nothing changes in your code: the same createWorkerScene() picks the engine.

A page is cross-origin isolated (crossOriginIsolated === true) when its document is served with these two headers:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Send them on every response, including 304 (Not Modified) responses: Safari refuses to start a Worker whose script it revalidated with a 304 that lacks them.

Vite (dev and preview). Use a small plugin rather than server.headers/preview.headers, which Vite leaves off its 304 responses (examples/webgpu/vite.config.ts does this):

import { defineConfig, type Plugin } from "vite";

function crossOriginIsolation(): Plugin {
  const isolate = (_req: unknown, res: { setHeader(name: string, value: string): void }, next: () => void) => {
    res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
    res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
    next();
  };
  return {
    name: "cross-origin-isolation",
    configureServer: (server) => void server.middlewares.use(isolate),
    configurePreviewServer: (server) => void server.middlewares.use(isolate),
  };
}

export default defineConfig({ plugins: [crossOriginIsolation()] });

webpack-dev-server (a middleware ahead of the others, so every response gets the headers):

// webpack.config.js
module.exports = {
  devServer: {
    setupMiddlewares: (middlewares) => {
      middlewares.unshift((req, res, next) => {
        res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
        res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
        next();
      });
      return middlewares;
    },
  },
};

nginx (add_header already covers 200, 304 and the other success and redirect statuses; always adds the 4xx and 5xx ones too):

add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;

A location (or other block) with any add_header of its own, such as a Cache-Control for assets, inherits none of the server-level ones: repeat the two headers in every such block, or keep them in a file you include in each.

Express (before express.static):

app.use((req, res, next) => {
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
  res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
  next();
});
app.use(express.static("dist"));

What the headers change for the rest of the page. With COEP require-corp, the page and its Workers may only load cross-origin resources (images and textures, scripts, fonts, models, fetch from a CDN or API) that opt in: load them with CORS (crossorigin on <img>/<script>/<link>, img.crossOrigin = "anonymous" or three.js's TextureLoader, whose default is anonymous) from a server that sends Access-Control-Allow-Origin, or have that server send Cross-Origin-Resource-Policy: cross-origin (meant for public assets only: it lets any site embed them). Anything else is blocked, so check third-party embeds before turning isolation on. COOP same-origin also cuts the link between your page and cross-origin popups it opens (window.opener), which some sign-in flows rely on. Same-origin assets, including this package's files, need nothing. Cross-Origin-Embedder-Policy: credentialless also makes a page isolated and loads cross-origin resources without credentials instead of blocking those that do not opt in; Chrome and Firefox support it, Safari does not (the page is then not isolated there, and the scene runs single-threaded).

The threads option.

const scene = await createWorkerScene(); // default: navigator.hardwareConcurrency, at most 8
const fixed = await createWorkerScene({ threads: 4 }); // clamped to 1..64; a non-integer rejects with INVALID_ARGUMENT
const single = await createWorkerScene({ threads: 1 }); // the single-threaded engine; the threaded files are never fetched
console.log(scene.threads); // the threads the engine computes with: 1 when single-threaded (and until the Worker is ready)

Fallback. The scene always starts. On a page that is not cross-origin isolated it runs single-threaded and never fetches the threaded files. On an isolated page, if the threaded engine cannot start (its import or instantiation fails, the shared memory cannot be allocated, or the thread pool rejects or has not started within 10 s, for example because its nested Workers cannot load), the scene runs single-threaded. When the failure comes after the threaded engine was instantiated, its Worker is replaced by a fresh single-threaded one, and requests made meanwhile are kept and run there. A scene that fell back this way stays single-threaded across restart(), so a restart does not wait 10 s again. scene.threads tells which engine runs.

Older WebKit. WebKit 26 (as in Playwright 1.56's build; Safari 26 may be affected) cannot share a growing WebAssembly memory between threads. After one thread grows the shared memory, another thread's memory.fill and memory.copy still check against the old size and trap, though plain loads and stores see the new size. The thread pool grows the memory as it starts, so its start traps there, and any later growth on a pool thread would crash the engine too. So while it imports the threaded glue, the Worker runs a probe that takes about 10 ms: a nested Worker grows a tiny shared memory, then the Worker fills a byte of the new page and copies one into it. When either traps, the scene runs single-threaded right away, with the development warning, instead of waiting out the 10 s pool timeout. The verdict is kept for the page: that scene's restarts and later scenes on the page start single-threaded without probing again (in development each scene still logs why, once). If the probe cannot run or gets no answer within 1 s (no nested Workers, or a Content Security Policy that blocks blob: Workers), the threaded engine is tried as before. WebKit 27.2 passes the probe and runs threaded.

Crashes on a pool thread. With the single-threaded engine, a trap in the WebAssembly module poisons the scene (every call fails with INSTANCE_POISONED). With the multithreaded engine, a crash on a pool thread ends only that thread, and the engine's Worker keeps waiting for it. Each pool thread therefore reports its own crash to the scene on the main thread, through a BroadcastChannel with a name unique to that Worker. The scene then fails every pending call with INSTANCE_POISONED and terminates the Worker and its pool, as it does after a Worker error; restart() recovers with a new pool. This covers Rust panics (the panic hook reports the message before the abort) and WebAssembly traps on a pool thread. Panics and the unreachable trap are tested in Chrome, Firefox and WebKit. Other traps are expected to be detected through the same path as unreachable, but are not tested: out-of-bounds access, the abort after a failed allocation, and the JavaScript call stack running out. It costs nothing until a thread crashes: no polling, just one idle channel per threaded Worker. The following still cannot be detected, and leave the scene waiting (pending promises never settle, statuses stay "computing"), with restart() and terminate() still working:

  • an overflow of a pool thread's stack in WebAssembly memory. WebAssembly has no guard page, so this is not a trap: it overwrites other memory, and is only noticed if that later traps;
  • a nested Worker that the browser ends without an exception (for example when the tab's process runs out of memory);
  • a pool thread that loops or deadlocks instead of crashing;
  • a browser with no BroadcastChannel in Workers.

Applications that cannot accept these cases should pass threads: 1.

The development warning. In development builds (import.meta.env.DEV, as Vite sets it) or with the debug option, the scene logs once (per scene, also across restarts) why it runs single-threaded when more threads were asked for: the page is not isolated, or the threaded engine failed to start, with the cause. Production builds are silent.

Bundlers. No setting beyond the headers is needed: Vite (dev and build) and webpack 5 work as installed from npm, and so does a page without a bundler that serves the package's dist/ folder as is (checked by scripts/bundler-check.mjs). Bundlers emit the threaded engine into your build output next to the single-threaded one: a chunk, its .wasm (about 988 KB, 372 KB gzipped, against 772 KB and 307 KB) and the thread pool's Worker helper. Pages that run single-threaded never download them.

Without header control (some static hosts), a service worker such as coi-serviceworker can make a page cross-origin isolated by adding the headers itself. This package does not ship or test one.

Browsers. Current Chrome and Edge, Firefox and Safari support it. Checked: headless Chrome 155, Firefox 157 and Playwright's WebKit 27.2 on Windows (bit-identical results in all three). Known issue: Playwright's Windows WebKit build crashed its web process in 2 of about 20 development-server runs of the check that crashes a threaded Worker on purpose and restarts it; the cause is unknown, and real Safari on macOS has not been checked.

The threaded engine adds memory: its shared memory starts at about 1 MB (18 pages) and may grow to 4 GiB (wasm32's limit), and each pool Worker has its own stack. A 10M-triangle wide-drag scene used about 2.4 GB threaded against 2.1 GB single-threaded.

React

Three subpaths connect a scene to UI code:

  • fast-mesh-wasm/store: SceneStore, an observable store over a Scene or a WorkerScene. It has no dependencies, so it also works without React.
  • fast-mesh-wasm/react: hooks over the store. Needs react (optional peer, >=18; tested with React 19).
  • fast-mesh-wasm/r3f: <FastMesh> for React Three Fiber. Needs @react-three/fiber (optional peer, >=9) and three.
import { createSceneStore } from "fast-mesh-wasm/store";
import { createWorkerScene } from "fast-mesh-wasm/worker";
import { SceneProvider, useHistory, useMeshDisplay } from "fast-mesh-wasm/react";
import { FastMesh } from "fast-mesh-wasm/r3f";
import { Canvas } from "@react-three/fiber";
import type { MeshHandle } from "fast-mesh-wasm";

// Provided by your app (not part of this package).
declare const upperPositions: Float64Array, upperIndices: Uint32Array;
declare const lowerPositions: Float64Array, lowerIndices: Uint32Array;

// One store per scene. With the synchronous Scene: `await init(); createSceneStore(new Scene())`.
const store = createSceneStore(await createWorkerScene());
const upper = store.addMesh(upperPositions, upperIndices);
const lower = store.addMesh(lowerPositions, lowerIndices);

function ColourButton({ mesh, to }: { mesh: MeshHandle; to: MeshHandle }) {
  const [state, setDisplay] = useMeshDisplay(mesh);
  const pressed = state.desired.coloring !== null; // what was requested
  const busy = state.status === "computing"; // not shown yet
  return (
    <button
      aria-pressed={pressed}
      aria-busy={busy}
      onClick={() =>
        setDisplay({ coloring: pressed ? null : { distanceTo: to, maxDistance: 2, map: "turbo", range: [0, 2] } })
      }
    >
      Distance colours
    </button>
  );
}

function UndoButton() {
  const { canUndo, undo } = useHistory();
  return <button disabled={!canUndo} onClick={undo}>Undo</button>;
}

export function App() {
  return (
    <SceneProvider store={store}>
      <Canvas>
        <ambientLight />
        <FastMesh mesh={upper} />
        <FastMesh mesh={lower} />
      </Canvas>
      <ColourButton mesh={upper} to={lower} />
      <UndoButton />
    </SceneProvider>
  );
}

The store. store.getSnapshot() returns a frozen SceneSnapshot (meshes, sections, history, selection and an overall status), replaced on every change, with unchanged parts shared between versions. store.subscribe(listener) notifies after each new snapshot: at once for an action, and once per task for the scene's events (on a microtask; getSnapshot() is up to date before then). An update re-derives only the meshes and sections it touches. What still grows with the scene is one copy of the snapshot's mesh map per change: a native Map copy, about 45 µs at 1500 meshes. Actions mirror the scene: addMesh, removeMesh, setPlacement, drag, edit, repair, undo, redo, addSection, removeSection and restart (Worker only). The declarative ones are new:

  • setDisplay(mesh, { coloring?, opacity?, visible? }). A coloring colours the mesh by its distance to distanceTo: one mesh, or a list of them (the smallest signed distance over them; see Contact groups). The store owns the distance field behind it: one per coloured mesh, named fast-mesh-store:<n>, kept while the colouring is off, reused when it comes back with the same distanceTo (as a set: the order does not matter), maxDistance and sign, and replaced only when one of them changes. opacity (in [0, 1], default 1) and visible (default true) live only in the store; the engine never sees them. Display changes never enter the undo history.

  • setContactGroup(id, { meshes, pairs?, coloring } | null) colours a group of meshes by contact. Each member gets coloring with its partners as distanceTo: the members it is paired with in pairs (either way round), or every other member with pairs: "all" (the default). coloring is a DistanceColoring without distanceTo, so any preset spreads into it. Moving a member recomputes only its own field and its partners'. Setting the group again updates the members; a member dropped from it, or all of them with null, is uncoloured and its store-owned field removed. A member the app has coloured itself since (with setDisplay) is left alone by those changes. A removed mesh leaves its groups: the others keep their remaining partners, and a member left with none is uncoloured. The colouring is checked when the group is set (INVALID_ARGUMENT). Groups are not in the snapshot: the members' display and desired colourings show them.

    store.setContactGroup("parts", {
      meshes: [a, b, c, d],
      pairs: [[a, b], [b, c], [a, c], [a, d]],
      coloring: { maxDistance: 1, map: "coolwarm", range: [-0.5, 0.5], center: 0, above: "base" },
    });
  • setSection(section, { plane?, meshes?, fill? }) changes a section.

  • select(mesh | null) sets the selection. It is UI state only, and it clears when the selected mesh is removed.

Contact heatmap. contactHeatmap({ depth, gap, tolerance?, exactDepth?, sign?, colors? }) builds a colouring (everything but distanceTo) that shows how two meshes touch. Spread it into setDisplay, colouring each mesh against the other:

import { contactHeatmap } from "fast-mesh-wasm/store";

const heat = contactHeatmap({ depth: 0.5, gap: 1, tolerance: 0.05 });
store.setDisplay(upper, { coloring: { distanceTo: lower, ...heat } });
store.setDisplay(lower, { coloring: { distanceTo: upper, ...heat } });
  • The range is [-depth, gap]. Penetration runs from deep (#7f0000) at -depth to shallow (#ff6b6b) at the contact band, which is contact (#ffffff) across [-tolerance, +tolerance] (one stop at 0 when the tolerance is 0, the default). The gap runs from near (#4caf50) to far (#1e3a8a) at gap. Deeper than depth clamps to deep; farther than gap shows the base colour. gap: 0 colours penetration only: the range is then [-depth, tolerance], with no gap ramp, and anything beyond the contact band shows the base colour. colors overrides any of the five.
  • exactDepth: false (the default) bounds the field at 2 · max(depth, gap), past the range ends, so the coloured area's outer edge is a value boundary: the fast path for contact, but a vertex deeper than the bound is beyond the field and shows the base colour. exactDepth: true computes every vertex's distance (maxDistance: Infinity), so any penetration shows, at the cost of a full recompute on every update.
  • sign (default "auto") is passed through. On an open or inconsistently oriented target, the penetration side follows its surface normals (fieldStatus tells you which).
  • Changing only tolerance or colors keeps maxDistance and sign, so the store reuses its field and only recolours.
  • It throws INVALID_ARGUMENT unless depth > 0, gap >= 0 (both finite) and 0 <= tolerance < min(depth, gap) (< depth when gap is 0).

store.fetchColoringValues(mesh) resolves to a copy of the values of the field behind the mesh's colouring (one per vertex slot), or null when the mesh has no colouring: for readouts such as the deepest penetration, -min(values). It uses fetchFieldValues on a Worker scene and copies fieldValues on a synchronous one. It works the same for a member of a contact group (setContactGroup): the values are those of its field measured to all of its partners, each vertex holding the smallest over them; the field's nearest buffer (fieldNearest, fetchFieldNearest on a Worker scene, through scene.coloring(mesh).field) tells which partner each came from. Its indices follow store.coloringTargets(mesh), the targets of the field behind the colouring in the field's order (ascending scene id, not the order of distanceTo or of the group's members; on a Scene, scene.fieldTargets(field) gives the same), frozen, or null without a colouring. It rejects with UNKNOWN_MESH for a mesh not in the store.

Declarative changes update desired at once and reach the scene at the next store.commitFrame(), the last value per mesh or section in a frame winning (on both scenes, so rapid toggling never queues work). Without a frame loop they apply on a microtask. Call store.commitFrame() once per animation frame instead of scene.commitFrame(); <FastMesh> does it for you. The first store.commitFrame() switches the store to frame flushing: from then on, declarative changes wait for a frame. When the last <FastMesh> (or useFrameDriver) of a store unmounts, the store switches back to flushing on a microtask, so changes made with no canvas mounted still apply.

Meshes must be added through the store (store.addMesh). A mesh added straight to store.scene is not in the store: its hooks read it as removed, and <FastMesh> renders nothing for it.

store.dispose() removes the distance fields the store owns (and so their colourings) and stops listening to the scene; the rest of the scene is left as it is. On a Worker scene the removals go with the scene's next commitFrame().

Reading state in components.

| Hook | Returns | | --- | --- | | useScene() | The store of the nearest <SceneProvider store> | | useMeshDisplay(mesh) | [state, setDisplay]: the mesh's applied coloring, opacity and visible, plus desired, status and error | | useSection(section) | [state, setSection]: the applied plane, meshes and fill, plus desired, status and error | | useHistory() | { canUndo, canRedo, undo, redo } | | useSceneStatus() | { status, error? } for the whole scene: "error" if anything failed, else "computing" while anything is pending, else "idle" | | useSelection() | [mesh \| null, select] | | useStoreSelector(selector, isEqual?) | Any slice of the snapshot; re-renders only when it changes |

  • Bind controlled inputs (sliders, toggles, checkboxes) to state.desired.*. That is what the user last asked for, so the input follows them at once. The top-level state.coloring, state.opacity and state.visible (and a section's top-level plane, meshes and fill) are the applied values: what is on screen.
  • status === "computing" covers the pending window of colourings and sections: the time between a request (the button is pressed) and the scene showing it, whether the change is waiting for the next frame or a colouring is being computed in the Worker. Opacity and visibility never read as pending: they live in the store and apply at the next flush. Show it as busy; the button never reads "on" while the mesh shows colours off outside this window. A mesh is also "computing" while something it depends on is pending: its placement or an edit, those of the mesh it is coloured by distance to, or an undo/redo. A section depends the same way on its meshes. Only a state's own requests can make it "error"; a failed edit rejects its own promise instead.
  • On the synchronous Scene every status is "ready" right after the frame that applied it, or "error" if the call threw. store.restart() rejects with INVALID_ARGUMENT there.
  • A removed mesh or section reads as "idle" with the default display (or no meshes) and its setter throws UNKNOWN_MESH or UNKNOWN_SECTION, so a component still mounted for it renders without throwing. Removing the mesh another mesh is coloured by distance to (alone) leaves that mesh uncoloured, its rule kept in desired and its status "error" (UNKNOWN_MESH) until its colouring changes; a colouring to several meshes keeps the others. A rule the engine rejected is not re-sent until it changes.
  • SSR. On the server the hooks render an empty snapshot (EMPTY_SNAPSHOT) and never throw for missing data.

<FastMesh>. <FastMesh mesh store? material? color? shading? edgeSoftness? onBind? onClick?… /> binds a scene mesh (added through the store) with bindMesh on mount, follows the applied opacity and visibility, and disposes the binding on unmount or when the mesh is removed (it then renders nothing). Opacity and visibility apply inside the frame's store.commitFrame(), so they show in the same frame as a colouring flushed with them. R3F pointer events are passed to the mesh. Adding or removing the store prop remounts it (and rebinds the mesh). Give each <FastMesh> its own material, and dispose it yourself. Every <FastMesh> calls useFrameDriver(store), which calls store.commitFrame() once per rendered frame however many are mounted (do not call commitFrame() yourself as well), and invalidates the canvas when the store or the scene changes, so <Canvas frameloop="demand"> renders every change. Use useFrameDriver() on its own when you draw the scene without <FastMesh>.

  • shading and edgeSoftness pass through to bindMesh (see Fragment shading). A new shading rebinds; a new edgeSoftness is set on the binding without one, and NaN or a negative width draws as 0 with a warning.
  • onBind(bound) delivers the BoundMesh once the mesh is bound, and null when that binding is disposed (unmount, removal, or a rebind after a material or color change, which then delivers the new one). Pass it to bindSection or attachTransformControls (fast-mesh-wasm/three), which take any MeshBinding (Pick<BoundMesh, "mesh" | "object">). bindSection clips the materials it sees when it is called, so bind the section again when onBind delivers a new binding. An inline callback is fine: a new callback never rebinds. examples/react keeps the bindings in a map for its section and its gizmo.
  • One store across two <Canvas> roots. The first mounted driver for a store does the committing; every canvas is invalidated on change. If the committing canvas does not render at all (frameloop="never", say) while another one runs, the store stops committing. Drive such a store from one canvas only, or call useFrameDriver in the canvas that always renders.

examples/react is a complete app on the Worker store (?local for the synchronous one): colouring toggles, opacity and visibility, a section, undo/redo, the scene status with Restart, dragging with three's TransformControls and STL drop.

Showcase. examples/showcase is a small mesh tool on the same stack: load STL, OBJ and PLY files (parsed in a Worker), move meshes with a gizmo, analyse and repair them (weld, remove degenerate triangles, orient, remove small components, fill holes; each one undo step), and see how two meshes touch as a contact heatmap (contactHeatmap) with live readouts of the deepest penetration, the gap and the contacts.

Functions

| Function | Returns | Description | | --- | --- | --- | | init(input?) | Promise<void> | Loads the WebAssembly module. Required once in browsers; resolves immediately in Node.js and Bun. Safe to call repeatedly. input optionally overrides where the .wasm file comes from (InitInput). | | computeVertexNormals(positions, indices) | Float32Array | Smooth, area-weighted per-vertex normals. Same length as positions. | | computeBounds(positions) | Bounds | Plain object { min, max, center, size }, each a [x, y, z] array. |

All functions throw an Error with a readable message when a buffer length is not a multiple of 3 or an index is out of range, and when called before init() has finished in the browser.

Command line

fast-mesh is a native binary for batch jobs and CI steps. It calls the Rust engine crates directly, with no WebAssembly or Node.js, and is not part of the npm package:

cargo install --path crates/fast-mesh-cli           # add --features parallel for multithreading

fast-mesh repair in.stl -o out.stl --auto           # weld, remove degenerate, orient, remove small components, fill holes
fast-mesh measure in.stl --format json              # counts, area, volume when watertight, defects
fast-mesh boolean a.stl b.stl --op union -o out.stl # union, intersection or difference (a minus b)
fast-mesh convert in.ply -o out.glb

Files are .stl (binary or ASCII), .obj, .ply (ASCII or binary little-endian) and .glb, chosen by extension. --ascii writes the text form of .stl and .ply. repair takes --weld-tolerance <d>, --min-triangles <n>, --max-edges <n|any> and --no-fair, with the defaults of AutoRepairOptions. fast-mesh <command> --help lists a command's options.

| Exit code | Meaning | | --- | --- | | 0 | Success. | | 1 | The operation or a file failed: malformed or truncated input, I/O, inputs a boolean refuses, an empty result, an invalid boolean result. | | 2 | Usage error: bad arguments, an unsupported extension, or an output that names an input without --force. |

Reports go to stdout and diagnostics to stderr. With --format json, stdout holds one JSON object on success and nothing on failure, so a script can check the exit code and then parse. A hole repair leaves open is not a failure: it exits 0, counts the hole in the report (skipped, failed) and warns on stderr.

The output appears whole or not at all: it is written beside the target and renamed over it when complete, so a failed run leaves any existing file untouched.

boolean refuses what the engine cannot resolve with invalid input: boolean intersection too complex (exit 1); at present that includes many overlaps of curved surfaces, so check it on your data.

boolean also checks its result before writing. One that is open, non-manifold or has orientation conflicts fails with the engine produced an invalid result and its defect counts (exit 1), leaving any existing output untouched, even with --force. Today's engine produces such results for some inputs, including overlapping cubes depending on how their faces are triangulated (#165). The check is structural: it does not compare the result against the shape it should have.

Limits: a .glb becomes one mesh, with its node placements baked and its primitives merged. Draco and meshopt compression, GPU instancing, sparse accessors and buffers outside the GLB body are refused with an error, as is a file whose nodes reuse their meshes for more than 1,048,576 node-primitive pairs (or file size / 16, if larger). JSON .gltf, big-endian PLY and UVs are not supported. Other attributes (normals, colors, materials) are not carried across a conversion.

Supported environments

| Environment | Entry | Notes | | --- | --- | --- | | Browsers via a bundler | dist/web.js | Vite, webpack 5, Next.js and others copy the .wasm asset automatically | | Node.js 22+ | dist/node.js | The module is ready on import, also inside webpack server bundles (e.g. Next.js) | | Bun | dist/node.js | Same as Node.js |

The right entry is picked automatically through the exports field of package.json. The package is ESM only.

Contributing

Contributions are welcome. See CONTRIBUTING.md for setup, the build pipeline and the release process. Please follow the Code of Conduct.

License

Licensed under either of

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.