Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
assets/
.claude/
node_modules/
package.json
package-lock.json
debug_out/
*.stl
*.3mf
Expand Down
41 changes: 32 additions & 9 deletions js/exporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ function triggerDownload(buffer, filename, mime = 'application/octet-stream') {
}

/**
* Fast binary STL exporter — writes directly from BufferGeometry arrays.
* Fast binary STL byte-builder — writes directly from BufferGeometry arrays.
* Pure: no DOM, no download. Returns the raw binary STL bytes.
*
* Eliminates Three.js STLExporter overhead:
* - No Mesh/Material creation
Expand All @@ -30,9 +31,9 @@ function triggerDownload(buffer, filename, mime = 'application/octet-stream') {
* - Bulk Uint8Array.set() instead of per-float DataView calls
*
* @param {THREE.BufferGeometry} geometry – non-indexed with position + normal
* @param {string} [filename]
* @returns {Uint8Array}
*/
export function exportSTL(geometry, filename = 'textured.stl') {
export function buildSTLBytes(geometry) {
const posArr = geometry.attributes.position.array;
const norArr = geometry.attributes.normal
? geometry.attributes.normal.array
Expand Down Expand Up @@ -79,21 +80,32 @@ export function exportSTL(geometry, filename = 'textured.stl') {
// Attribute byte count: 0 (already zero-filled)
}

triggerDownload(buffer, filename);
return bytes;
}

/**
* Fast binary STL exporter — builds the bytes then triggers a browser download.
*
* @param {THREE.BufferGeometry} geometry – non-indexed with position + normal
* @param {string} [filename]
*/
export function exportSTL(geometry, filename = 'textured.stl') {
const bytes = buildSTLBytes(geometry);
triggerDownload(bytes.buffer, filename);
}

/**
* 3MF exporter — builds a ZIP-packaged XML mesh in the Microsoft 3D
* Manufacturing core format (2015/02).
* 3MF byte-builder — builds a ZIP-packaged XML mesh in the Microsoft 3D
* Manufacturing core format (2015/02). Pure: no DOM, no download.
*
* Vertices are deduplicated (positions quantized to 4 decimals, i.e. 0.0001 mm
* tolerance) so the output is both smaller than binary STL and round-trippable
* by this project's own 3MF loader.
*
* @param {THREE.BufferGeometry} geometry – non-indexed with position attribute
* @param {string} [filename]
* @returns {Uint8Array}
*/
export function export3MF(geometry, filename = 'textured.3mf') {
export function build3MFBytes(geometry) {
const posArr = geometry.attributes.position.array;
const triCount = (posArr.length / 9) | 0;

Expand Down Expand Up @@ -216,13 +228,24 @@ export function export3MF(geometry, filename = 'textured.3mf') {
'Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>\n' +
'</Relationships>\n';

// ── Zip and download ─────────────────────────────────────────────────────
// ── Zip ───────────────────────────────────────────────────────────────────
const zipped = zipSync({
'[Content_Types].xml': strToU8(contentTypesXml),
'_rels/.rels': strToU8(relsXml),
'3D/3dmodel.model': modelBytes,
}, { level: 6 });

return zipped;
}

/**
* 3MF exporter — builds the zip bytes then triggers a browser download.
*
* @param {THREE.BufferGeometry} geometry – non-indexed with position attribute
* @param {string} [filename]
*/
export function export3MF(geometry, filename = 'textured.3mf') {
const zipped = build3MFBytes(geometry);
triggerDownload(
zipped,
filename,
Expand Down
45 changes: 31 additions & 14 deletions js/stlLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
const stlLoader = new STLLoader();
const objLoader = new OBJLoader();

/**
* Parse an already-read model buffer into { geometry, bounds, nanCount,
* degenerateCount, originOffset }. Pure — no File/FileReader/DOM dependency,
* so it runs headlessly (Node, workers) as well as in the browser.
*
* @param {ArrayBuffer} arrayBuffer raw file bytes
* @param {string} ext lowercase extension without dot: 'stl' | 'obj' | '3mf'
* (anything else falls back to STL, matching loadModelFile)
* @returns {{ geometry: THREE.BufferGeometry, bounds: object, nanCount: number,
* degenerateCount: number, originOffset: THREE.Vector3 }}
*/
export function parseModelBuffer(arrayBuffer, ext) {
let geometry;
if (ext === 'obj') {
const text = new TextDecoder().decode(arrayBuffer);
const group = objLoader.parse(text);
geometry = mergeGroupGeometries(group);
} else if (ext === '3mf') {
geometry = parse3MF(new Uint8Array(arrayBuffer));
} else {
geometry = stlLoader.parse(arrayBuffer);
}
const { nanCount, degenerateCount, originOffset } = setupGeometry(geometry);
const bounds = computeBounds(geometry);
return { geometry, bounds, nanCount, degenerateCount, originOffset };
}

/**
* Load an STL from a File object.
* Returns { geometry, bounds } where bounds = { min, max, center, size } (THREE.Vector3).
Expand All @@ -23,10 +50,7 @@ export function loadSTLFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const geometry = stlLoader.parse(e.target.result);
const { nanCount, degenerateCount, originOffset } = setupGeometry(geometry);
const bounds = computeBounds(geometry);
resolve({ geometry, bounds, nanCount, degenerateCount, originOffset });
resolve(parseModelBuffer(e.target.result, 'stl'));
} catch (err) {
reject(err);
}
Expand Down Expand Up @@ -201,17 +225,13 @@ export function loadOBJFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const group = objLoader.parse(e.target.result);
const geometry = mergeGroupGeometries(group);
const { nanCount, degenerateCount, originOffset } = setupGeometry(geometry);
const bounds = computeBounds(geometry);
resolve({ geometry, bounds, nanCount, degenerateCount, originOffset });
resolve(parseModelBuffer(e.target.result, 'obj'));
} catch (err) {
reject(err);
}
};
reader.onerror = () => reject(new Error('Could not read file'));
reader.readAsText(file);
reader.readAsArrayBuffer(file);
});
}

Expand All @@ -232,10 +252,7 @@ export function load3MFFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const geometry = parse3MF(new Uint8Array(e.target.result));
const { nanCount, degenerateCount, originOffset } = setupGeometry(geometry);
const bounds = computeBounds(geometry);
resolve({ geometry, bounds, nanCount, degenerateCount, originOffset });
resolve(parseModelBuffer(e.target.result, '3mf'));
} catch (err) {
reject(err);
}
Expand Down
148 changes: 148 additions & 0 deletions mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# BumpMesh MCP Server

An [MCP](https://modelcontextprotocol.io) server that exposes BumpMesh's headless
mesh-texturizing pipeline — adaptive subdivision, UV-projected displacement,
QEM decimation, and watertight repair — as tools an AI agent can call directly
on STL/OBJ/3MF files. No browser, no GPU, no upload: it imports `../js/*.js`
straight off disk, so it can never drift from the app it ships next to.

## Install

This repo is an npm **workspace**: `mcp/` is a workspace of the root package.
Run a single install **at the repo root** — it installs and hoists both the
MCP server's dependencies and the `three`/`fflate` that the shared `js/*.js`
modules import:

```bash
cd /path/to/stlTexturizer # repo root, NOT mcp/
npm install
```

Requires Node.js 20+. (One install at the root is all that's needed — do not
run a separate `npm install` inside `mcp/`.)

## Run

```bash
node mcp/server.mjs # from the repo root
```

The server speaks MCP over stdio. All logging goes to **stderr** (stdout is
reserved for the protocol).

## Client configuration

### Claude Desktop / Claude Code

Add to your MCP client config (e.g. `claude_desktop_config.json`, or via
`claude mcp add` for Claude Code):

```json
{
"mcpServers": {
"bumpmesh": {
"command": "node",
"args": ["/absolute/path/to/stlTexturizer/mcp/server.mjs"]
}
}
}
```

Use an absolute path to `server.mjs` — relative paths are resolved against the
client's own working directory, not this repo.

## Tools

| Tool | Purpose |
|---|---|
| `bumpmesh_list_textures` | List the 24 built-in texture presets (name, category, description, default UV scale). |
| `bumpmesh_inspect_mesh` | Triangle count, bounding box, surface area, watertightness, shell count. |
| `bumpmesh_texturize` | Apply a displacement texture to a mesh: subdivide → displace → decimate → repair. Writes STL or 3MF. |
| `bumpmesh_subdivide` | Adaptively subdivide a mesh to a target edge length. |
| `bumpmesh_decimate` | QEM-decimate a mesh to a target triangle count. |
| `bumpmesh_validate_mesh` | Open edges, non-manifold edges, shells, degenerate slivers. |
| `bumpmesh_place_on_bed` | Reorient a mesh so a chosen face sits flat on Z=0. |

All file-writing tools write to a temp path and rename on success, so a
failed run never leaves a partial output file. Paths are otherwise
unrestricted (this is a local, single-user tool).

### Note on `amplitude`

`bumpmesh_texturize`'s `amplitude` parameter is in **millimeters** (it maps
directly onto the app's "amplitude"/"texture height" slider, which ranges
0–2mm in the UI), not a 0..1 fraction — the pipeline adds it straight to
vertex positions. Negative values invert the bump direction. When
`|amplitude|` exceeds 10% of the model's smallest bounding-box dimension, the
response includes an `overlapWarning` (mirrors the app's own amplitude
warning).

### Strict inputs & texture source

Tool inputs are validated against a **strict** schema: an unknown or
misspelled parameter is rejected with a message naming the bad key and listing
the allowed parameters (never silently dropped) — both at the MCP protocol
layer and when a handler is called directly. `bumpmesh_texturize` requires
**exactly one** texture source: either `texture` (a preset name/filename, or a
literal image path) **or** `customImagePath` (an explicit image path). Supplying
both, or neither, is a clear error.

## Run the tests

```bash
npm test --workspace mcp # from the repo root
# or: cd mcp && npm test
```

Runs Node's built-in test runner (`node --test`) against `test/*.test.mjs`.
Tests generate a small binary-STL cube fixture in-memory (no bundled test
assets) and round-trip it through `bumpmesh_texturize` with a real built-in
preset, asserting the output re-parses, is watertight, and its STL byte
length matches `84 + 50 * triangleCount`. Coverage also includes a `.3mf`
write/read round-trip (via the headless DOMParser shim) and strict-input /
texture-source-validation cases.

## How it works

```
input file
-> parseModelBuffer(arrayBuffer, ext) js/stlLoader.js (THREE loader + cleanup + bounds)
-> decode texture -> {data,width,height} mcp/lib/imageData.mjs, mcp/lib/textures.mjs
-> buildSettings(params) mcp/lib/settings.mjs
-> runExportPipeline({...}) js/exportPipeline.js (unchanged upstream pipeline)
-> buildSTLBytes / build3MFBytes js/exporter.js
-> write to a temp path, then rename mcp/lib/pipeline.mjs
```

`js/stlLoader.js` `parse3MF()` uses the browser `DOMParser` global; Node has
none, so `mcp/lib/bootstrap.mjs` installs the pure-JS `@xmldom/xmldom`
implementation onto `globalThis` before any `js/` module runs. This keeps
`.3mf` input working headlessly without modifying `js/`.

Two small, behavior-preserving refactors were made upstream in `../js/` to
make this possible headlessly (both keep every existing browser call site
and its signature unchanged):

- **`js/exporter.js`** — extracted `buildSTLBytes(geometry): Uint8Array` and
`build3MFBytes(geometry): Uint8Array` as pure byte-builders. `exportSTL`/
`export3MF` now call the builder, then do the same Blob/`<a download>`
browser download as before.
- **`js/stlLoader.js`** — extracted `parseModelBuffer(arrayBuffer, ext):
{geometry, bounds, nanCount, degenerateCount, originOffset}`. `loadSTLFile`/
`loadOBJFile`/`load3MFFile` now call it after `FileReader` yields the
`ArrayBuffer` (OBJ's `FileReader` mode changed from `readAsText` to
`readAsArrayBuffer`, decoding to the same UTF-8 string internally — an
equivalent, not observably different, code path for real OBJ files).

## Why there's a root `package.json`

`js/threeCompat.js` resolves the bare specifier `three` (and `js/*.js`
resolves `fflate`) via Node's normal `node_modules` upward search starting at
`js/`'s own directory. The committed **root** `package.json` declares those
deps and an npm **workspace** for `mcp/`, so a single `npm install` at the
root installs everything and hoists it to the root `node_modules` — where both
`js/*.js` (three/fflate) and `mcp/server.mjs` (its own deps, resolved by
walking up from `mcp/`) can see it. The same root install also makes the
repo's pre-existing `bench-*.mjs` / `diag-*.mjs` scripts reproducible. The
browser app itself needs none of this — it loads `three` from the CDN import
map in `index.html` and has no build step.
20 changes: 20 additions & 0 deletions mcp/lib/bootstrap.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* bootstrap.mjs — headless polyfills that MUST be installed before any
* ../../js/*.js module is used at runtime.
*
* js/stlLoader.js `parse3MF()` constructs `new DOMParser()` (a browser global)
* to parse 3MF's XML. Node has no DOMParser, so without this shim every .3mf
* input would throw "DOMParser is not defined". We install the pure-JS
* @xmldom/xmldom implementation onto globalThis. This is import-side-effect
* only — importing this module first (see lib/pipeline.mjs and server.mjs)
* guarantees the global is set before parse3MF ever runs.
*
* We deliberately do NOT modify js/ (the upstream browser code stays as-is);
* the shim lives entirely in the MCP layer.
*/

import { DOMParser } from '@xmldom/xmldom';

if (typeof globalThis.DOMParser === 'undefined') {
globalThis.DOMParser = DOMParser;
}
Loading