diff --git a/.gitignore b/.gitignore index 3ef9df9..2a986a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ assets/ .claude/ node_modules/ -package.json -package-lock.json debug_out/ *.stl *.3mf diff --git a/js/exporter.js b/js/exporter.js index 88ccb7e..c788327 100644 --- a/js/exporter.js +++ b/js/exporter.js @@ -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 @@ -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 @@ -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; @@ -216,13 +228,24 @@ export function export3MF(geometry, filename = 'textured.3mf') { 'Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>\n' + '\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, diff --git a/js/stlLoader.js b/js/stlLoader.js index 6dac897..807456f 100644 --- a/js/stlLoader.js +++ b/js/stlLoader.js @@ -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). @@ -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); } @@ -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); }); } @@ -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); } diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..649cc52 --- /dev/null +++ b/mcp/README.md @@ -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/`` + 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. diff --git a/mcp/lib/bootstrap.mjs b/mcp/lib/bootstrap.mjs new file mode 100644 index 0000000..705f0dd --- /dev/null +++ b/mcp/lib/bootstrap.mjs @@ -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; +} diff --git a/mcp/lib/defineTool.mjs b/mcp/lib/defineTool.mjs new file mode 100644 index 0000000..e3402e1 --- /dev/null +++ b/mcp/lib/defineTool.mjs @@ -0,0 +1,83 @@ +/** + * defineTool.mjs — DRY factory for MCP tools. + * + * Every tool shares the same contract: + * - inputs validated against a STRICT zod object (unknown/misspelled params + * are rejected with an actionable message, never silently dropped), + * - the success payload wrapped in the MCP { content, structuredContent } + * envelope, + * - thrown errors converted to { isError:true, content:[...] }. + * + * Strictness is enforced in two places that must agree: + * 1. `config.inputSchema` is the strict ZodObject itself, so the MCP SDK + * rejects unknown keys at the protocol layer (McpError InvalidParams) + * before the handler runs, and still emits a full JSON-Schema listing + * (with all `.describe()` text) for clients. + * 2. `handler` re-validates against the same strict schema, so calling the + * handler DIRECTLY (as the tests do, bypassing the SDK) yields identical + * unknown-key rejection. + */ + +import { z } from 'zod'; + +function errorResult(message) { + return { isError: true, content: [{ type: 'text', text: `Error: ${message}` }] }; +} + +function formatZodError(error, allowedKeys) { + const issues = error.issues || []; + const unknown = issues.find((i) => i.code === 'unrecognized_keys'); + if (unknown) { + return ( + `unknown parameter${unknown.keys.length > 1 ? 's' : ''}: ${unknown.keys.join(', ')}. ` + + `Allowed parameters: ${allowedKeys.join(', ')}.` + ); + } + return issues + .map((i) => `${i.path.length ? i.path.join('.') : '(input)'}: ${i.message}`) + .join('; '); +} + +/** + * @param {object} def + * @param {string} def.name snake_case tool name (bumpmesh_*) + * @param {string} def.title human-readable title + * @param {string} def.description tool description for the model + * @param {object} [def.inputShape] zod raw shape ({ key: z.string()... }) + * @param {object} def.annotations MCP tool annotations + * @param {(params:object)=>Promise|object} def.run + * Business logic. Receives validated params; returns the plain output + * object (wrapped into the envelope) or throws an Error (→ isError). + * @returns {{name, config, handler}} + */ +export function defineTool({ name, title, description, inputShape = {}, annotations, run }) { + const schema = z.object(inputShape).strict(); + const allowedKeys = Object.keys(inputShape); + + async function handler(args = {}) { + const parsed = schema.safeParse(args ?? {}); + if (!parsed.success) { + return errorResult(formatZodError(parsed.error, allowedKeys)); + } + try { + const out = await run(parsed.data); + return { + content: [{ type: 'text', text: JSON.stringify(out, null, 2) }], + structuredContent: out, + }; + } catch (err) { + return errorResult(err.message); + } + } + + return { + name, + config: { + title, + description, + inputSchema: schema, + annotations, + }, + handler, + }; +} diff --git a/mcp/lib/imageData.mjs b/mcp/lib/imageData.mjs new file mode 100644 index 0000000..e65b728 --- /dev/null +++ b/mcp/lib/imageData.mjs @@ -0,0 +1,150 @@ +/** + * imageData.mjs — headless PNG/JPG decoding for displacement maps. + * + * Produces plain { data: Uint8ClampedArray (RGBA), width, height } objects — + * exactly the shape js/displacement.js expects (it only reads the RED + * channel, per the app's greyscale-map convention: R === G === B). + */ + +import { readFile } from 'node:fs/promises'; +import { PNG } from 'pngjs'; +import jpeg from 'jpeg-js'; + +const MAX_SIDE = 512; // matches js/presetTextures.js `fitDimensions` (SIZE = 512) + +/** + * Decode a PNG or JPEG file from disk into a raw RGBA image. + * @param {string} filePath + * @returns {Promise<{data: Uint8ClampedArray, width: number, height: number}>} + */ +export async function decodeImageFile(filePath) { + const buf = await readFile(filePath); + const lower = filePath.toLowerCase(); + + if (lower.endsWith('.png')) { + const png = PNG.sync.read(buf); + return { + data: new Uint8ClampedArray(png.data.buffer, png.data.byteOffset, png.data.byteLength), + width: png.width, + height: png.height, + }; + } + + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) { + const decoded = jpeg.decode(buf, { useTArray: true, formatAsRGBA: true }); + return { + data: new Uint8ClampedArray(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength), + width: decoded.width, + height: decoded.height, + }; + } + + throw new Error(`Unsupported image format for "${filePath}". Only .png, .jpg, and .jpeg are supported.`); +} + +/** + * Convert an RGBA image to greyscale luminance, writing the result into all + * of R/G/B (displacement.js's sampleBilinear only reads R, but writing all + * three keeps the buffer visually sane and matches the contract's guidance). + */ +export function toLuminance(imageData) { + const { data, width, height } = imageData; + const out = new Uint8ClampedArray(data.length); + for (let i = 0; i < data.length; i += 4) { + const lum = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]); + out[i] = lum; + out[i + 1] = lum; + out[i + 2] = lum; + out[i + 3] = data[i + 3]; + } + return { data: out, width, height }; +} + +/** + * Downscale so the longest side is at most `maxSide`, preserving aspect + * ratio. Never upscales (mirrors presetTextures.js `fitDimensions`). + */ +export function capLongestSide(imageData, maxSide = MAX_SIDE) { + const { width, height } = imageData; + const scale = Math.min(maxSide / width, maxSide / height, 1); + if (scale >= 1) return imageData; + const newW = Math.max(1, Math.round(width * scale)); + const newH = Math.max(1, Math.round(height * scale)); + return resizeBilinear(imageData, newW, newH); +} + +function resizeBilinear(imageData, newW, newH) { + const { data, width, height } = imageData; + const out = new Uint8ClampedArray(newW * newH * 4); + for (let y = 0; y < newH; y++) { + const srcY = ((y + 0.5) * height) / newH - 0.5; + const y0c = Math.floor(srcY); + const y0 = Math.max(0, Math.min(height - 1, y0c)); + const y1 = Math.max(0, Math.min(height - 1, y0c + 1)); + const ty = Math.min(1, Math.max(0, srcY - y0c)); + for (let x = 0; x < newW; x++) { + const srcX = ((x + 0.5) * width) / newW - 0.5; + const x0c = Math.floor(srcX); + const x0 = Math.max(0, Math.min(width - 1, x0c)); + const x1 = Math.max(0, Math.min(width - 1, x0c + 1)); + const tx = Math.min(1, Math.max(0, srcX - x0c)); + for (let c = 0; c < 4; c++) { + const v00 = data[(y0 * width + x0) * 4 + c]; + const v10 = data[(y0 * width + x1) * 4 + c]; + const v01 = data[(y1 * width + x0) * 4 + c]; + const v11 = data[(y1 * width + x1) * 4 + c]; + const v = v00 * (1 - tx) * (1 - ty) + v10 * tx * (1 - ty) + v01 * (1 - tx) * ty + v11 * tx * ty; + out[(y * newW + x) * 4 + c] = v; + } + } + } + return { data: out, width: newW, height: newH }; +} + +// Separable box blur — mirrors js/main.js `_boxBlurH`/`_boxBlurV` exactly. +function boxBlurH(src, dst, w, h, r) { + const iarr = 1 / (2 * r + 1); + for (let y = 0; y < h; y++) { + const row = y * w; + for (let ch = 0; ch < 4; ch++) { + let val = 0; + for (let x = -r; x <= r; x++) val += src[(row + Math.max(0, Math.min(x, w - 1))) * 4 + ch]; + for (let x = 0; x < w; x++) { + val += src[(row + Math.min(x + r, w - 1)) * 4 + ch] - src[(row + Math.max(x - r - 1, 0)) * 4 + ch]; + dst[(row + x) * 4 + ch] = Math.round(val * iarr); + } + } + } +} + +function boxBlurV(src, dst, w, h, r) { + const iarr = 1 / (2 * r + 1); + for (let x = 0; x < w; x++) { + for (let ch = 0; ch < 4; ch++) { + let val = 0; + for (let y = -r; y <= r; y++) val += src[(Math.max(0, Math.min(y, h - 1)) * w + x) * 4 + ch]; + for (let y = 0; y < h; y++) { + val += src[(Math.min(y + r, h - 1) * w + x) * 4 + ch] - src[(Math.max(y - r - 1, 0) * w + x) * 4 + ch]; + dst[(y * w + x) * 4 + ch] = Math.round(val * iarr); + } + } + } +} + +/** + * Apply an approximate Gaussian blur (sigma in px) via 3 passes of separable + * box blur — the same WebKit-fallback algorithm js/main.js `blurCanvas` uses + * when the CSS `filter` blur isn't available. sigma <= 0 is a no-op. + */ +export function applySmoothing(imageData, sigma) { + if (!sigma || sigma <= 0) return imageData; + const { width: w, height: h } = imageData; + const r = Math.max(1, Math.round((Math.sqrt(4 * sigma * sigma + 1) - 1) / 2)); + let a = Uint8ClampedArray.from(imageData.data); + const b = new Uint8ClampedArray(a.length); + for (let pass = 0; pass < 3; pass++) { + boxBlurH(a, b, w, h, r); + boxBlurV(b, a, w, h, r); + } + return { data: a, width: w, height: h }; +} diff --git a/mcp/lib/pipeline.mjs b/mcp/lib/pipeline.mjs new file mode 100644 index 0000000..78397c9 --- /dev/null +++ b/mcp/lib/pipeline.mjs @@ -0,0 +1,348 @@ +/** + * pipeline.mjs — headless glue between MCP tools and the real BumpMesh + * pipeline modules in ../../js/*.js. No DOM, no WebGL: file -> geometry, + * texture -> ImageData, run the pipeline, geometry -> file bytes. + */ + +import './bootstrap.mjs'; // installs globalThis.DOMParser — MUST precede js/ imports + +import { readFile, writeFile, rename, unlink } from 'node:fs/promises'; +import path from 'node:path'; + +import { THREE } from '../../js/threeCompat.js'; +import { parseModelBuffer, getTriangleCount, computeSurfaceArea } from '../../js/stlLoader.js'; +import { buildAdjacency } from '../../js/exclusion.js'; +import { runFastDiagnostics } from '../../js/meshValidation.js'; +import { countAreaSlivers } from '../../js/meshRepair.js'; +import { subdivide } from '../../js/subdivision.js'; +import { decimate } from '../../js/decimation.js'; +import { runExportPipeline } from '../../js/exportPipeline.js'; +import { buildSTLBytes, build3MFBytes } from '../../js/exporter.js'; + +import { buildSettings, buildRegularizeOpts } from './settings.mjs'; +import { resolveTexture, loadTextureImageData, validTextureNames } from './textures.mjs'; + +/** Load a mesh file (.stl/.obj/.3mf) into {geometry, bounds, ...}. */ +export async function loadModel(filePath) { + const buf = await readFile(filePath); + const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + const ext = path.extname(filePath).slice(1).toLowerCase(); + return parseModelBuffer(arrayBuffer, ext); +} + +/** Build a non-indexed THREE.BufferGeometry from raw position/normal arrays. */ +export function geometryFromArrays(positions, normals) { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + if (normals) geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); + return geometry; +} + +export function inferFormat(outputPath, explicitFormat) { + if (explicitFormat) return explicitFormat; + const ext = path.extname(outputPath).slice(1).toLowerCase(); + return ext === '3mf' ? '3mf' : 'stl'; +} + +export function bytesForFormat(geometry, format) { + return format === '3mf' ? build3MFBytes(geometry) : buildSTLBytes(geometry); +} + +/** Write bytes to a temp path in the same directory, then rename — never + * leaves a partial file at `outputPath` on failure. */ +export async function writeMeshFile(outputPath, bytes) { + const dir = path.dirname(outputPath); + const tmp = path.join(dir, `.${path.basename(outputPath)}.tmp-${process.pid}-${Date.now()}`); + await writeFile(tmp, bytes); + try { + await rename(tmp, outputPath); + } catch (err) { + await unlink(tmp).catch(() => {}); + throw err; + } +} + +function boundsToPlain(bounds) { + return { + min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z }, + max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z }, + size: { x: bounds.size.x, y: bounds.size.y, z: bounds.size.z }, + }; +} + +export async function inspectMeshAt(filePath) { + const { geometry, bounds, nanCount, degenerateCount } = await loadModel(filePath); + const triangles = getTriangleCount(geometry); + const surfaceArea = computeSurfaceArea(geometry); + const adjData = buildAdjacency(geometry); + const fast = runFastDiagnostics(adjData, triangles); + const warnings = []; + if (nanCount > 0) warnings.push(`${nanCount} triangle(s) with non-finite coordinates were removed on load.`); + if (degenerateCount > 0) warnings.push(`${degenerateCount} degenerate (near-zero-area) triangle(s) were removed on load.`); + if (fast.shellCount > 1) warnings.push(`Mesh has ${fast.shellCount} disconnected shells.`); + if (fast.openEdges > 0) warnings.push(`Mesh has ${fast.openEdges} open edge(s) — not watertight.`); + if (fast.nonManifoldEdges > 0) warnings.push(`Mesh has ${fast.nonManifoldEdges} non-manifold edge(s).`); + + return { + triangles, + boundingBox: boundsToPlain(bounds), + surfaceArea, + watertight: fast.openEdges === 0 && fast.nonManifoldEdges === 0, + shells: fast.shellCount, + warnings, + }; +} + +export async function validateMeshAt(filePath) { + const { geometry } = await loadModel(filePath); + const triangles = getTriangleCount(geometry); + const adjData = buildAdjacency(geometry); + const fast = runFastDiagnostics(adjData, triangles); + const slivers = countAreaSlivers(geometry); + return { + openEdges: fast.openEdges, + nonManifoldEdges: fast.nonManifoldEdges, + shells: fast.shellCount, + slivers, + watertight: fast.openEdges === 0 && fast.nonManifoldEdges === 0, + }; +} + +/** + * Resolve the texture source to an absolute image path. EXACTLY ONE of + * `texture` (preset name/filename, or a literal image path) or + * `customImagePath` (explicit image path) must be provided. + * + * @returns {{ path: string, source: 'customImagePath'|'preset'|'texture-path', label: string }} + */ +function resolveTextureSource(params) { + const hasTexture = params.texture !== undefined && params.texture !== null && params.texture !== ''; + const hasCustom = + params.customImagePath !== undefined && params.customImagePath !== null && params.customImagePath !== ''; + + if (hasTexture && hasCustom) { + throw new Error( + 'Provide exactly one texture source: either `texture` (preset name/filename) OR ' + + '`customImagePath` (image path), not both.' + ); + } + if (!hasTexture && !hasCustom) { + throw new Error( + 'No texture source provided. Set `texture` to a built-in preset name ' + + `(${validTextureNames().join(', ')}) or an image path, or set \`customImagePath\` to an image path.` + ); + } + + if (hasCustom) { + return { path: params.customImagePath, source: 'customImagePath', label: `customImagePath "${params.customImagePath}"` }; + } + const preset = resolveTexture(params.texture); + if (preset) { + return { path: preset, source: 'preset', label: `preset "${params.texture}"` }; + } + // Not a known preset — treat the string as a literal image path. + return { path: params.texture, source: 'texture-path', label: `texture path "${params.texture}"` }; +} + +export async function runTexturize(params) { + const { geometry, bounds } = await loadModel(params.input); + const positions = geometry.attributes.position.array; + + const src = resolveTextureSource(params); + let imageData; + try { + imageData = await loadTextureImageData(src.path, params.textureSmoothing ?? 0); + } catch (err) { + const hint = + src.source === 'preset' + ? '' + : ` Valid built-in preset names: ${validTextureNames().join(', ')}.`; + throw new Error(`Could not load ${src.label} (resolved to "${src.path}"): ${err.message}.${hint}`); + } + + const settings = buildSettings(params); + const regularizeOpts = buildRegularizeOpts(settings); + + const result = await runExportPipeline( + { + positions, + faceWeights: null, + imageData, + imgWidth: imageData.width, + imgHeight: imageData.height, + settings, + bounds, + regularizeOpts, + mode: 'export', + }, + () => {}, + () => false + ); + + if (!result) throw new Error('Pipeline aborted unexpectedly.'); + + const warnings = []; + if (result.safetyCapHit) { + warnings.push( + 'Subdivision hit the internal safety cap on triangle count; texture detail may be limited. Consider a larger refineLength.' + ); + } + if (result.repairStats && (result.repairStats.open > 0 || result.repairStats.nonManifold > 0)) { + warnings.push( + `Watertight repair left ${result.repairStats.open} open edge(s) and ${result.repairStats.nonManifold} non-manifold edge(s).` + ); + } + + const minDim = Math.min(bounds.size.x, bounds.size.y, bounds.size.z); + let overlapWarning; + if (Math.abs(settings.amplitude) > minDim * 0.1) { + overlapWarning = + `Amplitude (${settings.amplitude} mm) exceeds 10% of the model's smallest bounding-box ` + + `dimension (${minDim.toFixed(3)} mm); the texture may self-intersect or punch through thin walls.`; + warnings.push(overlapWarning); + } + + const outGeometry = geometryFromArrays(result.positions, result.normals); + const format = inferFormat(params.output, params.format); + const bytes = bytesForFormat(outGeometry, format); + await writeMeshFile(params.output, bytes); + + const triangles = result.positions.length / 9; + return { + outputPath: path.resolve(params.output), + triangles, + bytes: bytes.byteLength, + warnings, + ...(overlapWarning ? { overlapWarning } : {}), + }; +} + +export async function runSubdivide(params) { + const { geometry } = await loadModel(params.input); + if (!geometry.attributes.normal) geometry.computeVertexNormals(); + const { geometry: outGeom, safetyCapHit } = await subdivide(geometry, params.refineLength, () => {}, null); + const format = inferFormat(params.output); + const bytes = bytesForFormat(outGeom, format); + await writeMeshFile(params.output, bytes); + return { + outputPath: path.resolve(params.output), + triangles: getTriangleCount(outGeom), + safetyCapHit, + }; +} + +export async function runDecimate(params) { + const { geometry } = await loadModel(params.input); + if (!geometry.attributes.normal) geometry.computeVertexNormals(); + const outGeom = await decimate(geometry, params.targetTriangles, () => {}, true, 0.005); + const format = inferFormat(params.output); + const bytes = bytesForFormat(outGeom, format); + await writeMeshFile(params.output, bytes); + return { + outputPath: path.resolve(params.output), + triangles: getTriangleCount(outGeom), + }; +} + +function faceNormalAndArea(pos, t) { + const b = t * 9; + const ax = pos[b], ay = pos[b + 1], az = pos[b + 2]; + const bx = pos[b + 3], by = pos[b + 4], bz = pos[b + 5]; + const cx = pos[b + 6], cy = pos[b + 7], cz = pos[b + 8]; + const ux = bx - ax, uy = by - ay, uz = bz - az; + const vx = cx - ax, vy = cy - ay, vz = cz - az; + const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; + const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1; + return { nx: nx / len, ny: ny / len, nz: nz / len, area: len * 0.5, cz: (az + bz + cz) / 3 }; +} + +/** + * Orient a mesh so a chosen face sits flat on the print bed (Z=0). + * + * No reusable pure helper exists upstream for this, so this is a simple, + * self-contained implementation: pick a face normal, rotate the whole mesh + * so that normal points to -Z, then translate the new minimum Z to 0. + * + * - 'auto': groups triangles by (quantized) face-normal direction and + * picks the direction with the largest total triangle area — + * the biggest flat facet becomes the base (best print stability). + * - 'lowest': picks whichever triangle's centroid currently has the + * smallest Z (already closest to the bed) and flattens onto it. + * - : an explicit 0-based triangle index (non-indexed geometry, so + * index i's triangle spans positions[i*9 .. i*9+9)). + */ +export async function runPlaceOnBed(params) { + const { geometry } = await loadModel(params.input); + const pos = geometry.attributes.position.array; + const triCount = pos.length / 9; + if (triCount === 0) throw new Error('Mesh has no triangles.'); + + const faceParam = params.face ?? 'auto'; + let chosenNormal; + let chosenFaceIndex = null; + + if (typeof faceParam === 'number') { + const idx = Math.trunc(faceParam); + if (idx < 0 || idx >= triCount) { + throw new Error(`face index ${idx} out of range [0, ${triCount - 1}].`); + } + chosenFaceIndex = idx; + const fa = faceNormalAndArea(pos, idx); + chosenNormal = { x: fa.nx, y: fa.ny, z: fa.nz }; + } else if (faceParam === 'lowest') { + let bestT = 0, bestCz = Infinity; + for (let t = 0; t < triCount; t++) { + const cz = (pos[t * 9 + 2] + pos[t * 9 + 5] + pos[t * 9 + 8]) / 3; + if (cz < bestCz) { bestCz = cz; bestT = t; } + } + chosenFaceIndex = bestT; + const fa = faceNormalAndArea(pos, bestT); + chosenNormal = { x: fa.nx, y: fa.ny, z: fa.nz }; + } else if (faceParam === 'auto') { + const buckets = new Map(); + const QN = 200; // direction-quantization steps + for (let t = 0; t < triCount; t++) { + const fa = faceNormalAndArea(pos, t); + const key = `${Math.round(fa.nx * QN)}_${Math.round(fa.ny * QN)}_${Math.round(fa.nz * QN)}`; + const entry = buckets.get(key) || { area: 0, nx: 0, ny: 0, nz: 0, n: 0 }; + entry.area += fa.area; + entry.nx += fa.nx; entry.ny += fa.ny; entry.nz += fa.nz; entry.n++; + buckets.set(key, entry); + } + let best = null; + for (const entry of buckets.values()) { + if (!best || entry.area > best.area) best = entry; + } + chosenNormal = { x: best.nx / best.n, y: best.ny / best.n, z: best.nz / best.n }; + } else { + throw new Error(`Invalid face "${faceParam}". Use 'auto', 'lowest', or a triangle index.`); + } + + const len = Math.hypot(chosenNormal.x, chosenNormal.y, chosenNormal.z) || 1; + const from = new THREE.Vector3(chosenNormal.x / len, chosenNormal.y / len, chosenNormal.z / len); + const target = new THREE.Vector3(0, 0, -1); + const quat = new THREE.Quaternion().setFromUnitVectors(from, target); + geometry.applyQuaternion(quat); + + geometry.computeBoundingBox(); + const minZ = geometry.boundingBox.min.z; + geometry.translate(0, 0, -minZ); + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + + const bb = geometry.boundingBox; + const format = inferFormat(params.output); + const bytes = bytesForFormat(geometry, format); + await writeMeshFile(params.output, bytes); + + return { + outputPath: path.resolve(params.output), + face: faceParam, + chosenFaceIndex, + triangles: getTriangleCount(geometry), + boundingBox: { + min: { x: bb.min.x, y: bb.min.y, z: bb.min.z }, + max: { x: bb.max.x, y: bb.max.y, z: bb.max.z }, + }, + }; +} diff --git a/mcp/lib/settings.mjs b/mcp/lib/settings.mjs new file mode 100644 index 0000000..f988d4e --- /dev/null +++ b/mcp/lib/settings.mjs @@ -0,0 +1,91 @@ +/** + * settings.mjs — maps MCP tool params onto the exact `settings` object + * js/exportPipeline.js expects. Defaults copied verbatim from + * PIPELINE_CONTRACT.md (main.js line 76-127). + */ + +export const PROJECTION_MODES = { + planar_xy: 0, + planar_xz: 1, + planar_yz: 2, + cylindrical: 3, + spherical: 4, + triplanar: 5, + cubic: 6, +}; + +export function projectionNameToMode(name) { + const key = String(name).trim().toLowerCase(); + if (!(key in PROJECTION_MODES)) { + throw new Error( + `Unknown projection "${name}". Valid options: ${Object.keys(PROJECTION_MODES).join(', ')}.` + ); + } + return PROJECTION_MODES[key]; +} + +const DEFAULT_SETTINGS = { + mappingMode: 5, // triplanar + scaleU: 0.5, scaleV: 0.5, + amplitude: 0.5, + textureHeight: 0.5, invertDisplacement: false, + offsetU: 0.0, offsetV: 0.0, rotation: 0, + refineLength: 1.0, + maxTriangles: 750000, + lockScale: true, + bottomAngleLimit: 5, topAngleLimit: 0, + mappingBlend: 1, seamBandWidth: 0.5, textureSmoothing: 0, + blendNormalSmoothing: 32, capAngle: 20, boundaryFalloff: 0, + symmetricDisplacement: false, noDownwardZ: false, + smoothBottom: true, harvestFlatFaces: true, harvestTol: 0.005, + useDisplacement: false, + snapSeamlessWrap: true, cylinderCenterX: null, cylinderCenterY: null, cylinderRadius: null, + regularizeEnabled: true, regularizeAspectThreshold: 5, regularizeSlack: 3.0, + regularizeAggressiveSlack: 8.0, regularizeExtremeAspect: 8, regularizeNormalDeg: 15, + regularizeAggressiveNormalDeg: 25, regularizeSecondPassMul: 1.1, +}; + +/** + * Build the full pipeline settings object, overriding CONTRACT defaults from + * tool params. NOTE (discrepancy vs the design doc's "amplitude(0..1)"): + * the real app's `amplitude`/`textureHeight` slider is in MILLIMETERS + * (index.html: ``), added + * directly to vertex positions in displacement.js — not a 0..1 fraction. We + * follow the real code: `params.amplitude` is mm and may be negative to + * invert the bump direction (equivalent to the app's invertDisplacement). + */ +export function buildSettings(params = {}) { + const settings = { ...DEFAULT_SETTINGS }; + + if (params.projection !== undefined) settings.mappingMode = projectionNameToMode(params.projection); + if (params.scaleU !== undefined) settings.scaleU = params.scaleU; + if (params.scaleV !== undefined) settings.scaleV = params.scaleV; + if (params.offsetU !== undefined) settings.offsetU = params.offsetU; + if (params.offsetV !== undefined) settings.offsetV = params.offsetV; + if (params.rotation !== undefined) settings.rotation = params.rotation; + if (params.amplitude !== undefined) { + settings.amplitude = params.amplitude; + settings.textureHeight = Math.abs(params.amplitude); + settings.invertDisplacement = params.amplitude < 0; + } + if (params.symmetric !== undefined) settings.symmetricDisplacement = params.symmetric; + if (params.maskTopAngle !== undefined) settings.topAngleLimit = params.maskTopAngle; + if (params.maskBottomAngle !== undefined) settings.bottomAngleLimit = params.maskBottomAngle; + if (params.refineLength !== undefined) settings.refineLength = params.refineLength; + if (params.decimateTo !== undefined) settings.maxTriangles = params.decimateTo; + if (params.textureSmoothing !== undefined) settings.textureSmoothing = params.textureSmoothing; + + return settings; +} + +/** Mirrors main.js `_regularizeOpts` exactly — see PIPELINE_CONTRACT.md. */ +export function buildRegularizeOpts(settings) { + return { + aspectThreshold: settings.regularizeAspectThreshold, + slack: settings.regularizeSlack, + aggressiveSlack: settings.regularizeAggressiveSlack, + extremeSliverAspect: settings.regularizeExtremeAspect, + maxNormalDeltaCos: Math.cos((settings.regularizeNormalDeg * Math.PI) / 180), + aggressiveNormalDeltaCos: Math.cos((settings.regularizeAggressiveNormalDeg * Math.PI) / 180), + }; +} diff --git a/mcp/lib/textures.mjs b/mcp/lib/textures.mjs new file mode 100644 index 0000000..81ebdcf --- /dev/null +++ b/mcp/lib/textures.mjs @@ -0,0 +1,92 @@ +/** + * textures.mjs — headless replacement for js/presetTextures.js (which needs + * TextureLoader/Canvas2D/DOM). Reads the same 24 files from ../textures + * directly off disk and decodes them via lib/imageData.mjs. + * + * Catalog data (name, url, defaultScale) is copied verbatim from + * js/presetTextures.js `IMAGE_PRESETS` — see PIPELINE_CONTRACT.md. `category` + * and `description` are new, MCP-only fields (not present upstream) added so + * an agent can browse presets without opening every image. + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { decodeImageFile, toLuminance, capLongestSide, applySmoothing } from './imageData.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TEXTURES_DIR = path.resolve(__dirname, '..', '..', 'textures'); + +export const TEXTURE_CATALOG = [ + { name: 'Basket', file: 'basket.png', category: 'Weave', defaultScale: 0.5, description: 'Woven basket-weave lattice.' }, + { name: 'Brick', file: 'brick.png', category: 'Masonry', defaultScale: 0.5, description: 'Running-bond brick courses.' }, + { name: 'Bubble', file: 'bubble.png', category: 'Organic', defaultScale: 0.5, description: 'Randomly packed circular bubbles.' }, + { name: 'Carbon Fiber', file: 'carbonFiber.jpg', category: 'Industrial', defaultScale: 0.5, description: 'Twill carbon-fiber weave.' }, + { name: 'Crystal', file: 'crystal.png', category: 'Geometric', defaultScale: 0.5, description: 'Faceted crystalline shards.' }, + { name: 'Dots', file: 'dots.png', category: 'Geometric', defaultScale: 0.1, description: 'Regular grid of round dots.' }, + { name: 'Grid', file: 'grid.png', category: 'Geometric', defaultScale: 1.0, description: 'Square grid lines.' }, + { name: 'Grip Surface', file: 'gripSurface.jpg', category: 'Functional', defaultScale: 0.5, description: 'Raised anti-slip grip bumps.' }, + { name: 'Hexagon', file: 'hexagon.jpg', category: 'Geometric', defaultScale: 0.5, description: 'Single-scale hexagon honeycomb.' }, + { name: 'Hexagons', file: 'hexagons.jpg', category: 'Geometric', defaultScale: 1.0, description: 'Dense hexagon honeycomb tiling.' }, + { name: 'Isogrid', file: 'isogrid.png', category: 'Industrial', defaultScale: 0.5, description: 'Triangular isogrid stiffener pattern.' }, + { name: 'Knitting', file: 'knitting.png', category: 'Weave', defaultScale: 0.25, description: 'Knitted-fabric loop stitches.' }, + { name: 'Knurling', file: 'knurling.jpg', category: 'Functional', defaultScale: 0.15, description: 'Diamond knurl grip pattern.' }, + { name: 'Leather 2', file: 'leather2.png', category: 'Organic', defaultScale: 0.5, description: 'Pebbled leather grain.' }, + { name: 'Noise', file: 'noise.jpg', category: 'Organic', defaultScale: 0.3, description: 'Fine random surface noise.' }, + { name: 'Stripes 1', file: 'stripes.png', category: 'Geometric', defaultScale: 0.5, description: 'Parallel straight stripes.' }, + { name: 'Stripes 2', file: 'stripes_02.png', category: 'Geometric', defaultScale: 1.0, description: 'Alternate-width parallel stripes.' }, + { name: 'Voronoi', file: 'voronoi.jpg', category: 'Organic', defaultScale: 0.5, description: 'Voronoi cell fracture pattern.' }, + { name: 'Weave 1', file: 'weave.png', category: 'Weave', defaultScale: 0.5, description: 'Plain over-under fabric weave.' }, + { name: 'Weave 2', file: 'weave_02.jpg', category: 'Weave', defaultScale: 0.5, description: 'Coarse basket-style fabric weave.' }, + { name: 'Weave 3', file: 'weave_03.jpg', category: 'Weave', defaultScale: 0.5, description: 'Fine twill fabric weave.' }, + { name: 'Wood 1', file: 'wood.jpg', category: 'Organic', defaultScale: 0.5, description: 'Straight wood grain.' }, + { name: 'Wood 2', file: 'woodgrain_02.jpg', category: 'Organic', defaultScale: 1.0, description: 'Wavy wood grain with knots.' }, + { name: 'Wood 3', file: 'woodgrain_03.jpg', category: 'Organic', defaultScale: 1.0, description: 'Coarse plank wood grain.' }, +]; + +/** [{name, category, description, defaultScale}] for the list_textures tool. */ +export function listTextures() { + return TEXTURE_CATALOG.map(({ name, category, description, defaultScale }) => ({ + name, + category, + description, + defaultScale, + })); +} + +export function validTextureNames() { + return TEXTURE_CATALOG.map((t) => t.name); +} + +function normalize(s) { + return String(s).trim().toLowerCase(); +} + +/** + * Resolve a texture parameter to an absolute file path under ../textures. + * Accepts a preset name (case-insensitive, e.g. "hexagon") or the preset's + * bare filename (e.g. "hexagon.jpg"). Returns null if it doesn't match any + * built-in preset (the caller may then treat the string as a literal path). + */ +export function resolveTexture(nameOrFilename) { + if (!nameOrFilename) return null; + const key = normalize(nameOrFilename); + const byName = TEXTURE_CATALOG.find((t) => normalize(t.name) === key); + if (byName) return path.join(TEXTURES_DIR, byName.file); + const byFile = TEXTURE_CATALOG.find( + (t) => normalize(t.file) === key || normalize(path.basename(t.file)) === key + ); + if (byFile) return path.join(TEXTURES_DIR, byFile.file); + return null; +} + +/** + * Load a texture (preset or custom path) as a displacement-ready image: + * decode -> greyscale luminance -> cap longest side to 512px -> optional blur. + */ +export async function loadTextureImageData(filePath, smoothing = 0) { + let img = await decodeImageFile(filePath); + img = toLuminance(img); + img = capLongestSide(img, 512); + img = applySmoothing(img, smoothing); + return img; +} diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 0000000..a0ff830 --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,24 @@ +{ + "name": "bumpmesh-mcp-server", + "version": "0.1.0", + "description": "MCP server exposing BumpMesh's headless mesh-texturizing pipeline (subdivide, regularize, displace, decimate) as tools for an AI agent.", + "type": "module", + "private": true, + "engines": { + "node": ">=20" + }, + "main": "server.mjs", + "scripts": { + "start": "node server.mjs", + "test": "node --test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "@xmldom/xmldom": "^0.9.6", + "three": "0.170.0", + "fflate": "^0.8.2", + "jpeg-js": "^0.4.4", + "pngjs": "^7.0.0", + "zod": "^3.24.1" + } +} diff --git a/mcp/server.mjs b/mcp/server.mjs new file mode 100644 index 0000000..81a5946 --- /dev/null +++ b/mcp/server.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * server.mjs — BumpMesh MCP server entry point (stdio transport). + * + * Exposes the real BumpMesh headless mesh-texturizing pipeline (js/*.js, + * imported directly so this server never drifts from upstream) as MCP tools. + * + * IMPORTANT: stdio is the transport, so stdout is reserved for the MCP + * protocol. All diagnostic/log output MUST go to stderr — never console.log. + */ + +import './lib/bootstrap.mjs'; // installs globalThis.DOMParser before any js/ module runs + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +import * as listTextures from './tools/listTextures.mjs'; +import * as inspectMesh from './tools/inspectMesh.mjs'; +import * as texturize from './tools/texturize.mjs'; +import * as subdivide from './tools/subdivide.mjs'; +import * as decimate from './tools/decimate.mjs'; +import * as validateMesh from './tools/validateMesh.mjs'; +import * as placeOnBed from './tools/placeOnBed.mjs'; + +const TOOLS = [listTextures, inspectMesh, texturize, subdivide, decimate, validateMesh, placeOnBed]; + +const server = new McpServer( + { name: 'bumpmesh-mcp-server', version: '0.1.0' }, + { capabilities: { tools: {} } } +); + +for (const tool of TOOLS) { + server.registerTool(tool.name, tool.config, tool.handler); +} + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('[bumpmesh-mcp] server started (stdio), tools:', TOOLS.map((t) => t.name).join(', ')); +} + +main().catch((err) => { + console.error('[bumpmesh-mcp] fatal error:', err); + process.exit(1); +}); diff --git a/mcp/test/exporterBytes.test.mjs b/mcp/test/exporterBytes.test.mjs new file mode 100644 index 0000000..930eb3d --- /dev/null +++ b/mcp/test/exporterBytes.test.mjs @@ -0,0 +1,31 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { THREE } from '../../js/threeCompat.js'; +import { buildSTLBytes } from '../../js/exporter.js'; +import { buildCubeSTLBuffer } from './fixtures/makeCube.mjs'; +import { parseModelBuffer } from '../../js/stlLoader.js'; + +test('buildSTLBytes output length == 84 + 50 * triangleCount', () => { + const cubeBuffer = buildCubeSTLBuffer(20); + const arrayBuffer = cubeBuffer.buffer.slice(cubeBuffer.byteOffset, cubeBuffer.byteOffset + cubeBuffer.byteLength); + const { geometry } = parseModelBuffer(arrayBuffer, 'stl'); + + const bytes = buildSTLBytes(geometry); + const triCount = geometry.attributes.position.count / 3; + assert.equal(triCount, 12); + assert.equal(bytes.length, 84 + 50 * triCount); + + // Header triangle count field matches. + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + assert.equal(view.getUint32(80, true), triCount); +}); + +test('buildSTLBytes on an arbitrary non-indexed geometry also satisfies the formula', () => { + // A single triangle. + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const bytes = buildSTLBytes(geometry); + assert.equal(bytes.length, 84 + 50 * 1); +}); diff --git a/mcp/test/fixtures/makeCube.mjs b/mcp/test/fixtures/makeCube.mjs new file mode 100644 index 0000000..c82b973 --- /dev/null +++ b/mcp/test/fixtures/makeCube.mjs @@ -0,0 +1,48 @@ +/** + * makeCube.mjs — programmatically builds a tiny binary-STL cube fixture. + * No external assets needed; every test that needs a mesh calls + * `writeCubeFixture()` to materialize it under a temp path. + */ + +import { writeFile } from 'node:fs/promises'; + +/** 12-triangle cube, outward-facing CCW winding, spanning [0,size]^3. */ +export function buildCubeSTLBuffer(size = 20) { + const s = size; + const v = { + '000': [0, 0, 0], '100': [s, 0, 0], '110': [s, s, 0], '010': [0, s, 0], + '001': [0, 0, s], '101': [s, 0, s], '111': [s, s, s], '011': [0, s, s], + }; + const tris = [ + [v['001'], v['101'], v['111']], [v['001'], v['111'], v['011']], // top (+Z) + [v['000'], v['110'], v['100']], [v['000'], v['010'], v['110']], // bottom (-Z) + [v['000'], v['100'], v['101']], [v['000'], v['101'], v['001']], // front (-Y) + [v['010'], v['011'], v['111']], [v['010'], v['111'], v['110']], // back (+Y) + [v['000'], v['001'], v['011']], [v['000'], v['011'], v['010']], // left (-X) + [v['100'], v['110'], v['111']], [v['100'], v['111'], v['101']], // right (+X) + ]; + + const triCount = tris.length; + const buf = Buffer.alloc(84 + 50 * triCount); + buf.writeUInt32LE(triCount, 80); + let o = 84; + for (const [a, b, c] of tris) { + const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2]; + const wx = c[0] - a[0], wy = c[1] - a[1], wz = c[2] - a[2]; + const nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx; + const len = Math.hypot(nx, ny, nz) || 1; + buf.writeFloatLE(nx / len, o); buf.writeFloatLE(ny / len, o + 4); buf.writeFloatLE(nz / len, o + 8); + o += 12; + for (const p of [a, b, c]) { + buf.writeFloatLE(p[0], o); buf.writeFloatLE(p[1], o + 4); buf.writeFloatLE(p[2], o + 8); + o += 12; + } + o += 2; // attribute byte count + } + return buf; +} + +export async function writeCubeFixture(path, size = 20) { + await writeFile(path, buildCubeSTLBuffer(size)); + return path; +} diff --git a/mcp/test/inspectAndValidate.test.mjs b/mcp/test/inspectAndValidate.test.mjs new file mode 100644 index 0000000..e18826b --- /dev/null +++ b/mcp/test/inspectAndValidate.test.mjs @@ -0,0 +1,57 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; + +import { writeCubeFixture } from './fixtures/makeCube.mjs'; +import { inspectMeshAt, validateMeshAt } from '../lib/pipeline.mjs'; +import * as inspectMeshTool from '../tools/inspectMesh.mjs'; + +test('inspect_mesh on a 20mm cube reports 12 triangles, watertight, 1 shell', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + await writeCubeFixture(cubePath, 20); + + const summary = await inspectMeshAt(cubePath); + assert.equal(summary.triangles, 12); + assert.equal(summary.watertight, true); + assert.equal(summary.shells, 1); + assert.ok(summary.surfaceArea > 0); + assert.ok(Number.isFinite(summary.boundingBox.size.x)); + // Cube edge is 20mm on every axis. + assert.ok(Math.abs(summary.boundingBox.size.x - 20) < 1e-3); + assert.ok(Math.abs(summary.boundingBox.size.y - 20) < 1e-3); + assert.ok(Math.abs(summary.boundingBox.size.z - 20) < 1e-3); +}); + +test('validate_mesh on the cube reports zero defects', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + await writeCubeFixture(cubePath, 20); + + const summary = await validateMeshAt(cubePath); + assert.equal(summary.openEdges, 0); + assert.equal(summary.nonManifoldEdges, 0); + assert.equal(summary.shells, 1); + assert.equal(summary.slivers, 0); + assert.equal(summary.watertight, true); +}); + +test('bumpmesh_inspect_mesh tool handler returns the MCP content envelope', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + await writeCubeFixture(cubePath, 20); + + const result = await inspectMeshTool.handler({ path: cubePath }); + assert.equal(result.structuredContent.triangles, 12); + assert.equal(result.content[0].type, 'text'); +}); + +test('bumpmesh_inspect_mesh tool handler reports isError on a missing file', async () => { + const result = await inspectMeshTool.handler({ path: path.join(tmpdir(), 'does-not-exist-bumpmesh.stl') }); + assert.equal(result.isError, true); +}); diff --git a/mcp/test/listTextures.test.mjs b/mcp/test/listTextures.test.mjs new file mode 100644 index 0000000..5eddf70 --- /dev/null +++ b/mcp/test/listTextures.test.mjs @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { listTextures, TEXTURE_CATALOG, resolveTexture } from '../lib/textures.mjs'; +import * as listTexturesTool from '../tools/listTextures.mjs'; + +test('listTextures() returns exactly 24 presets with non-empty fields', () => { + const textures = listTextures(); + assert.equal(textures.length, 24); + for (const t of textures) { + assert.ok(t.name && t.name.length > 0, 'name must be non-empty'); + assert.ok(t.description && t.description.length > 0, 'description must be non-empty'); + assert.ok(t.category && t.category.length > 0, 'category must be non-empty'); + assert.equal(typeof t.defaultScale, 'number'); + } +}); + +test('TEXTURE_CATALOG matches the exact preset names from js/presetTextures.js', () => { + const expected = [ + 'Basket', 'Brick', 'Bubble', 'Carbon Fiber', 'Crystal', 'Dots', 'Grid', 'Grip Surface', + 'Hexagon', 'Hexagons', 'Isogrid', 'Knitting', 'Knurling', 'Leather 2', 'Noise', + 'Stripes 1', 'Stripes 2', 'Voronoi', 'Weave 1', 'Weave 2', 'Weave 3', 'Wood 1', 'Wood 2', 'Wood 3', + ]; + assert.deepEqual(TEXTURE_CATALOG.map((t) => t.name), expected); +}); + +test('resolveTexture is case-insensitive and resolves both name and filename', () => { + const byName = resolveTexture('hexagon'); + const byExactName = resolveTexture('Hexagon'); + const byFile = resolveTexture('hexagon.jpg'); + assert.ok(byName.endsWith('hexagon.jpg')); + assert.equal(byName, byExactName); + assert.equal(byName, byFile); +}); + +test('resolveTexture returns null for an unknown name', () => { + assert.equal(resolveTexture('not-a-real-texture'), null); +}); + +test('bumpmesh_list_textures tool handler returns the MCP content envelope', async () => { + const result = await listTexturesTool.handler(); + assert.equal(result.structuredContent.count, 24); + assert.equal(result.structuredContent.textures.length, 24); + assert.equal(result.content[0].type, 'text'); + const parsed = JSON.parse(result.content[0].text); + assert.equal(parsed.count, 24); +}); diff --git a/mcp/test/strictValidation.test.mjs b/mcp/test/strictValidation.test.mjs new file mode 100644 index 0000000..d604ef3 --- /dev/null +++ b/mcp/test/strictValidation.test.mjs @@ -0,0 +1,93 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; + +import { writeCubeFixture } from './fixtures/makeCube.mjs'; +import * as inspectMeshTool from '../tools/inspectMesh.mjs'; +import * as texturizeTool from '../tools/texturize.mjs'; +import { resolveTexture } from '../lib/textures.mjs'; + +test('unknown/misspelled parameter is rejected with an actionable message', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-strict-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + await writeCubeFixture(cubePath, 20); + + // "pat" is a typo for "path". + const result = await inspectMeshTool.handler({ pat: cubePath }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /unknown parameter/i); + assert.match(result.content[0].text, /pat/); + // Names the allowed parameter(s). + assert.match(result.content[0].text, /path/); +}); + +test('texturize rejects an unknown parameter naming it', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-strict-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + await writeCubeFixture(cubePath, 20); + + const result = await texturizeTool.handler({ + input: cubePath, + output: path.join(dir, 'out.stl'), + texture: 'Dots', + amplitudeMm: 0.5, // misspelled — real param is `amplitude` + }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /amplitudeMm/); +}); + +test('texturize requires exactly one of texture / customImagePath — both is an error', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-strict-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + await writeCubeFixture(cubePath, 20); + + const result = await texturizeTool.handler({ + input: cubePath, + output: path.join(dir, 'out.stl'), + texture: 'Dots', + customImagePath: path.join(dir, 'whatever.png'), + }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /exactly one/i); +}); + +test('texturize requires exactly one of texture / customImagePath — neither is an error', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-strict-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + await writeCubeFixture(cubePath, 20); + + const result = await texturizeTool.handler({ + input: cubePath, + output: path.join(dir, 'out.stl'), + }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /No texture source/i); +}); + +test('texturize accepts a customImagePath pointing at a real preset file', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-strict-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + const outPath = path.join(dir, 'out.stl'); + await writeCubeFixture(cubePath, 20); + + // Point customImagePath at a real texture file on disk (absolute path), + // resolved via the library so it doesn't depend on the test's cwd. + const imgPath = resolveTexture('Dots'); + + const result = await texturizeTool.handler({ + input: cubePath, + output: outPath, + customImagePath: imgPath, + refineLength: 2.5, + amplitude: 0.3, + }); + assert.equal(result.isError, undefined, `texturize errored: ${result.content?.[0]?.text}`); + assert.ok(result.structuredContent.triangles > 12); +}); diff --git a/mcp/test/subdivideDecimatePlaceOnBed.test.mjs b/mcp/test/subdivideDecimatePlaceOnBed.test.mjs new file mode 100644 index 0000000..5383196 --- /dev/null +++ b/mcp/test/subdivideDecimatePlaceOnBed.test.mjs @@ -0,0 +1,69 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { mkdtemp, rm, stat } from 'node:fs/promises'; + +import { writeCubeFixture } from './fixtures/makeCube.mjs'; +import * as subdivideTool from '../tools/subdivide.mjs'; +import * as decimateTool from '../tools/decimate.mjs'; +import * as placeOnBedTool from '../tools/placeOnBed.mjs'; +import * as validateMeshTool from '../tools/validateMesh.mjs'; + +test('bumpmesh_subdivide increases triangle count and writes a valid file', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + const outPath = path.join(dir, 'sub.stl'); + await writeCubeFixture(cubePath, 20); + + const result = await subdivideTool.handler({ input: cubePath, output: outPath, refineLength: 3 }); + assert.equal(result.isError, undefined); + assert.ok(result.structuredContent.triangles > 12); + assert.equal(typeof result.structuredContent.safetyCapHit, 'boolean'); + const st = await stat(outPath); + assert.ok(st.size > 0); +}); + +test('bumpmesh_decimate reduces a subdivided mesh toward the target count', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + const subPath = path.join(dir, 'sub.stl'); + const decPath = path.join(dir, 'dec.stl'); + await writeCubeFixture(cubePath, 20); + await subdivideTool.handler({ input: cubePath, output: subPath, refineLength: 1.5 }); + + const result = await decimateTool.handler({ input: subPath, output: decPath, targetTriangles: 20 }); + assert.equal(result.isError, undefined); + assert.ok(result.structuredContent.triangles > 0); + const st = await stat(decPath); + assert.ok(st.size > 0); +}); + +test('bumpmesh_place_on_bed reorients the cube and reports a valid bounding box', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + const outPath = path.join(dir, 'placed.stl'); + await writeCubeFixture(cubePath, 20); + + const result = await placeOnBedTool.handler({ input: cubePath, output: outPath, face: 'auto' }); + assert.equal(result.isError, undefined); + assert.ok(Math.abs(result.structuredContent.boundingBox.min.z) < 1e-3, 'placed mesh should rest on Z=0'); + + const validated = await validateMeshTool.handler({ path: outPath }); + assert.equal(validated.structuredContent.watertight, true); +}); + +test('bumpmesh_place_on_bed accepts an explicit triangle index', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + const outPath = path.join(dir, 'placed-idx.stl'); + await writeCubeFixture(cubePath, 20); + + const result = await placeOnBedTool.handler({ input: cubePath, output: outPath, face: 0 }); + assert.equal(result.isError, undefined); + assert.equal(result.structuredContent.chosenFaceIndex, 0); +}); diff --git a/mcp/test/texturizeRoundTrip.test.mjs b/mcp/test/texturizeRoundTrip.test.mjs new file mode 100644 index 0000000..421db40 --- /dev/null +++ b/mcp/test/texturizeRoundTrip.test.mjs @@ -0,0 +1,96 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { mkdtemp, rm, stat, readFile } from 'node:fs/promises'; + +import { writeCubeFixture } from './fixtures/makeCube.mjs'; +import * as texturizeTool from '../tools/texturize.mjs'; +import { parseModelBuffer } from '../../js/stlLoader.js'; + +test('bumpmesh_texturize round-trips a cube with the Hexagon preset', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + const outPath = path.join(dir, 'cube-textured.stl'); + await writeCubeFixture(cubePath, 20); + + const result = await texturizeTool.handler({ + input: cubePath, + output: outPath, + texture: 'Hexagon', + projection: 'triplanar', + scaleU: 0.5, + scaleV: 0.5, + offsetU: 0, + offsetV: 0, + rotation: 0, + amplitude: 0.5, + symmetric: false, + maskTopAngle: 0, + maskBottomAngle: 5, + refineLength: 2.0, + decimateTo: 750000, + textureSmoothing: 0, + }); + + assert.equal(result.isError, undefined, `handler reported an error: ${result.content?.[0]?.text}`); + const summary = result.structuredContent; + + // 1. Output file exists. + const st = await stat(outPath); + assert.ok(st.isFile()); + assert.equal(st.size, summary.bytes); + + // 2. Re-parses via parseModelBuffer. + const raw = await readFile(outPath); + const arrayBuffer = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength); + const parsed = parseModelBuffer(arrayBuffer, 'stl'); + assert.ok(parsed.geometry.attributes.position.count > 0); + + // 3. Triangle count is finite and > 0, and grew from the 12-triangle cube + // (subdivision + displacement always increases triangle count on a cube). + assert.ok(Number.isFinite(summary.triangles)); + assert.ok(summary.triangles > 12); + + // 4. STL byte length formula: 84 + 50 * triCount. + assert.equal(st.size, 84 + 50 * summary.triangles); + + // warnings is always an array (possibly empty). + assert.ok(Array.isArray(summary.warnings)); +}); + +test('bumpmesh_texturize rejects an unknown texture name with an actionable error', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + await writeCubeFixture(cubePath, 20); + + const result = await texturizeTool.handler({ + input: cubePath, + output: path.join(dir, 'out.stl'), + texture: 'DefinitelyNotARealTexture', + }); + + assert.equal(result.isError, true); + assert.match(result.content[0].text, /DefinitelyNotARealTexture/); +}); + +test('bumpmesh_texturize surfaces the amplitude-overlap warning on a thin model', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + const outPath = path.join(dir, 'out.stl'); + // A 2mm cube with a 0.5mm (default) amplitude: 0.5 > 10% of 2mm (0.2mm) → warning. + await writeCubeFixture(cubePath, 2); + + const result = await texturizeTool.handler({ + input: cubePath, + output: outPath, + texture: 'Dots', + refineLength: 0.3, + }); + + assert.equal(result.isError, undefined, `handler reported an error: ${result.content?.[0]?.text}`); + assert.ok(result.structuredContent.overlapWarning, 'expected an overlapWarning for a thin model'); +}); diff --git a/mcp/test/threeMFRoundTrip.test.mjs b/mcp/test/threeMFRoundTrip.test.mjs new file mode 100644 index 0000000..8ddaa2c --- /dev/null +++ b/mcp/test/threeMFRoundTrip.test.mjs @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { mkdtemp, rm, stat, readFile } from 'node:fs/promises'; + +import { writeCubeFixture } from './fixtures/makeCube.mjs'; +import * as texturizeTool from '../tools/texturize.mjs'; +import * as inspectMeshTool from '../tools/inspectMesh.mjs'; +import { parseModelBuffer } from '../../js/stlLoader.js'; + +// Exercises the DOMParser shim (lib/bootstrap.mjs installs @xmldom/xmldom): +// js/stlLoader.js parse3MF() uses `new DOMParser()`, which is undefined in +// Node without the shim. Every step here touches .3mf output+input. + +test('bumpmesh_texturize writes a .3mf and it re-parses to a watertight mesh (DOMParser shim)', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-3mf-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + const outPath = path.join(dir, 'cube-textured.3mf'); + await writeCubeFixture(cubePath, 20); + + const result = await texturizeTool.handler({ + input: cubePath, + output: outPath, + texture: 'Dots', + projection: 'triplanar', + amplitude: 0.4, + refineLength: 2.0, + format: '3mf', + }); + assert.equal(result.isError, undefined, `texturize errored: ${result.content?.[0]?.text}`); + + // 3MF file exists and its byte length matches the reported summary. + const st = await stat(outPath); + assert.ok(st.isFile()); + assert.equal(st.size, result.structuredContent.bytes); + + // Re-parse the .3mf directly via parseModelBuffer (exercises parse3MF + xmldom). + const raw = await readFile(outPath); + const ab = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength); + const parsed = parseModelBuffer(ab, '3mf'); + assert.ok(parsed.geometry.attributes.position.count > 0); + + // And through the inspect_mesh tool: valid watertight mesh, 1 shell. + const inspected = await inspectMeshTool.handler({ path: outPath }); + assert.equal(inspected.isError, undefined, `inspect errored: ${inspected.content?.[0]?.text}`); + assert.ok(inspected.structuredContent.triangles > 0); + assert.equal(inspected.structuredContent.watertight, true); + assert.equal(inspected.structuredContent.shells, 1); +}); + +test('inspect_mesh reads a .3mf produced from the cube (build3MFBytes round-trip)', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'bumpmesh-mcp-3mf-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cubePath = path.join(dir, 'cube.stl'); + const outPath = path.join(dir, 'cube-passthrough.3mf'); + await writeCubeFixture(cubePath, 20); + + // A texturize with a large refineLength barely subdivides, but still writes + // a valid 3MF we can round-trip. (Separate from the detailed case above.) + const result = await texturizeTool.handler({ + input: cubePath, + output: outPath, + texture: 'Grid', + refineLength: 5, + amplitude: 0.2, + format: '3mf', + }); + assert.equal(result.isError, undefined, `texturize errored: ${result.content?.[0]?.text}`); + + const inspected = await inspectMeshTool.handler({ path: outPath }); + assert.equal(inspected.structuredContent.watertight, true); +}); diff --git a/mcp/tools/decimate.mjs b/mcp/tools/decimate.mjs new file mode 100644 index 0000000..22b96db --- /dev/null +++ b/mcp/tools/decimate.mjs @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { defineTool } from '../lib/defineTool.mjs'; +import { runDecimate } from '../lib/pipeline.mjs'; + +const tool = defineTool({ + name: 'bumpmesh_decimate', + title: 'Decimate a mesh', + description: + 'Reduce a mesh to (approximately) a target triangle count using quadric-error-metric ' + + '(QEM) decimation with hole/spike/non-manifold safety guards — the same decimator ' + + 'bumpmesh_texturize uses after displacement.', + inputShape: { + input: z.string().min(1).describe('Path to the source mesh file (.stl, .obj, or .3mf).'), + output: z.string().min(1).describe('Path to write the decimated mesh to.'), + targetTriangles: z.number().int().positive().describe('Desired output triangle count.'), + }, + annotations: { + title: 'Decimate a mesh', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + run: (params) => runDecimate(params), +}); + +export const { name, config, handler } = tool; diff --git a/mcp/tools/inspectMesh.mjs b/mcp/tools/inspectMesh.mjs new file mode 100644 index 0000000..032d281 --- /dev/null +++ b/mcp/tools/inspectMesh.mjs @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { defineTool } from '../lib/defineTool.mjs'; +import { inspectMeshAt } from '../lib/pipeline.mjs'; + +const tool = defineTool({ + name: 'bumpmesh_inspect_mesh', + title: 'Inspect a mesh', + description: + 'Load an STL/OBJ/3MF file and report its triangle count, bounding box, surface area, ' + + 'and basic watertightness (open edges, shell count) without modifying it.', + inputShape: { + path: z.string().min(1).describe('Path to the mesh file (.stl, .obj, or .3mf).'), + }, + annotations: { + title: 'Inspect a mesh', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + run: (params) => inspectMeshAt(params.path), +}); + +export const { name, config, handler } = tool; diff --git a/mcp/tools/listTextures.mjs b/mcp/tools/listTextures.mjs new file mode 100644 index 0000000..2892eb2 --- /dev/null +++ b/mcp/tools/listTextures.mjs @@ -0,0 +1,25 @@ +import { defineTool } from '../lib/defineTool.mjs'; +import { listTextures } from '../lib/textures.mjs'; + +const tool = defineTool({ + name: 'bumpmesh_list_textures', + title: 'List built-in textures', + description: + 'List the 24 built-in displacement-map texture presets bundled with BumpMesh, ' + + 'each with a category, a short description, and its recommended default UV scale. ' + + 'Use a returned `name` as the `texture` parameter of bumpmesh_texturize.', + inputShape: {}, + annotations: { + title: 'List built-in textures', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + run: () => { + const textures = listTextures(); + return { textures, count: textures.length }; + }, +}); + +export const { name, config, handler } = tool; diff --git a/mcp/tools/placeOnBed.mjs b/mcp/tools/placeOnBed.mjs new file mode 100644 index 0000000..94731ac --- /dev/null +++ b/mcp/tools/placeOnBed.mjs @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { defineTool } from '../lib/defineTool.mjs'; +import { runPlaceOnBed } from '../lib/pipeline.mjs'; + +const tool = defineTool({ + name: 'bumpmesh_place_on_bed', + title: 'Place a mesh on the print bed', + description: + 'Reorient a mesh so a chosen face sits flat on the print bed (Z=0). `face: "auto"` picks ' + + 'the largest flat facet (best print stability); `"lowest"` keeps whichever face is already ' + + 'closest to the bed; a numeric triangle index orients that specific face down.', + inputShape: { + input: z.string().min(1).describe('Path to the source mesh file (.stl, .obj, or .3mf).'), + output: z.string().min(1).describe('Path to write the reoriented mesh to.'), + face: z + .union([z.enum(['auto', 'lowest']), z.number().int().nonnegative()]) + .default('auto') + .describe('"auto" (largest flat face), "lowest" (already-lowest face), or a 0-based triangle index.'), + }, + annotations: { + title: 'Place a mesh on the print bed', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + run: (params) => runPlaceOnBed(params), +}); + +export const { name, config, handler } = tool; diff --git a/mcp/tools/subdivide.mjs b/mcp/tools/subdivide.mjs new file mode 100644 index 0000000..71265f9 --- /dev/null +++ b/mcp/tools/subdivide.mjs @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { defineTool } from '../lib/defineTool.mjs'; +import { runSubdivide } from '../lib/pipeline.mjs'; + +const tool = defineTool({ + name: 'bumpmesh_subdivide', + title: 'Subdivide a mesh', + description: + 'Adaptively subdivide a mesh so every edge is at most `refineLength` long — the same ' + + 'pre-pass bumpmesh_texturize runs before displacement. Useful on its own to prep a mesh ' + + 'for later fine detail work.', + inputShape: { + input: z.string().min(1).describe('Path to the source mesh file (.stl, .obj, or .3mf).'), + output: z.string().min(1).describe('Path to write the subdivided mesh to.'), + refineLength: z.number().positive().default(1.0).describe('Maximum edge length in millimeters after subdivision.'), + }, + annotations: { + title: 'Subdivide a mesh', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + run: (params) => runSubdivide(params), +}); + +export const { name, config, handler } = tool; diff --git a/mcp/tools/texturize.mjs b/mcp/tools/texturize.mjs new file mode 100644 index 0000000..b85ac1d --- /dev/null +++ b/mcp/tools/texturize.mjs @@ -0,0 +1,92 @@ +import { z } from 'zod'; +import { defineTool } from '../lib/defineTool.mjs'; +import { runTexturize } from '../lib/pipeline.mjs'; +import { validTextureNames } from '../lib/textures.mjs'; + +const PROJECTIONS = ['triplanar', 'cubic', 'cylindrical', 'spherical', 'planar_xy', 'planar_xz', 'planar_yz']; + +const tool = defineTool({ + name: 'bumpmesh_texturize', + title: 'Texturize a mesh', + description: + 'Apply a displacement texture to an STL/OBJ/3MF mesh: adaptive subdivision, UV-projected ' + + 'bump displacement, decimation back to a triangle budget, and a watertight repair pass. ' + + 'Writes the textured mesh to `output` as STL or 3MF. Provide EXACTLY ONE texture source: ' + + 'either `texture` (a built-in preset name — see bumpmesh_list_textures — or an image path) ' + + 'OR `customImagePath` (an explicit PNG/JPG path).', + inputShape: { + input: z.string().min(1).describe('Path to the source mesh file (.stl, .obj, or .3mf).'), + output: z.string().min(1).describe('Path to write the textured mesh to.'), + texture: z + .string() + .min(1) + .optional() + .describe( + `Built-in preset name (case-insensitive) or an image file path. ` + + `Presets: ${validTextureNames().join(', ')}. ` + + `Provide this OR customImagePath, not both.` + ), + customImagePath: z + .string() + .min(1) + .optional() + .describe('Explicit path to a custom PNG/JPG displacement image. Provide this OR texture, not both.'), + projection: z.enum(PROJECTIONS).default('triplanar').describe('UV projection mode.'), + scaleU: z.number().positive().default(0.5).describe('Texture tiling scale along U.'), + scaleV: z.number().positive().default(0.5).describe('Texture tiling scale along V.'), + offsetU: z.number().default(0).describe('Texture UV offset along U (0..1).'), + offsetV: z.number().default(0).describe('Texture UV offset along V (0..1).'), + rotation: z.number().default(0).describe('Texture rotation in degrees.'), + amplitude: z + .number() + .default(0.5) + .describe( + 'Displacement height in millimeters (matches the app\'s "amplitude"/"texture height" ' + + 'slider, mm — NOT a 0..1 fraction). Negative inverts the bump direction.' + ), + symmetric: z + .boolean() + .default(false) + .describe('Center displacement around the original surface (bumps in and out) instead of purely outward.'), + maskTopAngle: z + .number() + .min(0) + .default(0) + .describe('Degrees from horizontal; upward-facing faces within this angle are excluded from displacement.'), + maskBottomAngle: z + .number() + .min(0) + .default(5) + .describe('Degrees from horizontal; downward (bed-facing) faces within this angle are excluded from displacement.'), + refineLength: z + .number() + .positive() + .default(1.0) + .describe('Target subdivided edge length in millimeters (smaller = finer texture detail, more triangles).'), + decimateTo: z + .number() + .int() + .positive() + .default(750000) + .describe('Target triangle count for post-displacement decimation.'), + textureSmoothing: z + .number() + .min(0) + .default(0) + .describe('Blur radius (px) applied to the displacement map before sampling. 0 = off.'), + format: z + .enum(['stl', '3mf']) + .optional() + .describe('Output format; inferred from the `output` file extension when omitted.'), + }, + annotations: { + title: 'Texturize a mesh', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + run: (params) => runTexturize(params), +}); + +export const { name, config, handler } = tool; diff --git a/mcp/tools/validateMesh.mjs b/mcp/tools/validateMesh.mjs new file mode 100644 index 0000000..fe7740a --- /dev/null +++ b/mcp/tools/validateMesh.mjs @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { defineTool } from '../lib/defineTool.mjs'; +import { validateMeshAt } from '../lib/pipeline.mjs'; + +const tool = defineTool({ + name: 'bumpmesh_validate_mesh', + title: 'Validate a mesh', + description: + 'Run mesh-quality diagnostics on an STL/OBJ/3MF file: open edges, non-manifold edges, ' + + 'disconnected shells, and degenerate (zero-area) slivers. Use before/after texturizing ' + + 'to confirm the output is watertight and print-ready.', + inputShape: { + path: z.string().min(1).describe('Path to the mesh file (.stl, .obj, or .3mf).'), + }, + annotations: { + title: 'Validate a mesh', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + run: (params) => validateMeshAt(params.path), +}); + +export const { name, config, handler } = tool; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..68914bb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1231 @@ +{ + "name": "stltexturizer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stltexturizer", + "workspaces": [ + "mcp" + ], + "dependencies": { + "fflate": "^0.8.2", + "three": "0.170.0" + } + }, + "mcp": { + "name": "bumpmesh-mcp-server", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "@xmldom/xmldom": "^0.9.6", + "fflate": "^0.8.2", + "jpeg-js": "^0.4.4", + "pngjs": "^7.0.0", + "three": "0.170.0", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bumpmesh-mcp-server": { + "resolved": "mcp", + "link": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.30", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", + "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..76ff6cd --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "stltexturizer", + "private": true, + "type": "module", + "workspaces": [ + "mcp" + ], + "description": "BumpMesh / stlTexturizer. Root manifest exists so the shared js/*.js mesh-processing modules (which import 'three' and 'fflate') resolve those deps from the root node_modules when run headlessly by Node — the MCP server (mcp/) and the bench-*.mjs / diag-*.mjs scripts. The browser app itself uses the CDN import map in index.html and needs no build step.", + "dependencies": { + "three": "0.170.0", + "fflate": "^0.8.2" + } +}