manifold-3d
Version:
Geometry library for topological robustness
319 lines • 13.4 kB
JavaScript
// Copyright 2025 The Manifold Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* The bundler resolves files and modules at load time, bundles them, and allows
* the manifoldCAD runtime to provide objects (like manifold itself) and
* properties (such as `import.meta.url`).
* @packageDocumentation
* @group ManifoldCAD
* @category Core
*/
import resolve from '@jridgewell/resolve-uri';
import * as esbuild from 'esbuild-wasm';
import MagicString from 'magic-string';
import { BundlerError, FetchError } from "./error.js";
import { fetchWithRetry, isNode } from "./util.js";
let esbuildWasmUrl = null;
let esbuildHasOwnWorker = false;
export const setWasmUrl = (url) => {
esbuildWasmUrl = url;
};
export const setHasOwnWorker = (x) => {
esbuildHasOwnWorker = x;
};
/**
* These content delivery networks provide NPM modules as ES modules,
* whether they were published that way or not.
*/
export const cdnUrlHelpers = {
'esm.sh': (specifier) => `https://esm.sh/${specifier}`,
'jsDelivr': (specifier) => `https://cdn.jsdelivr.net/npm/${specifier}/+esm`,
'skypack': (specifier) => `https://cdn.skypack.dev/${specifier}`
};
const cdnUrl = (specifier, jsCDN) => {
if (!jsCDN)
return specifier;
const helper = cdnUrlHelpers[jsCDN];
return helper ? helper(specifier) : `${jsCDN}${specifier}`;
};
const insertMetaData = (text, sourceUrl) => {
if (sourceUrl) {
const st = new MagicString(text);
st.prepend(`const _import_meta_url=_manifold_runtime_url ?? '${sourceUrl}';\n`);
const map = st.generateMap({ hires: true });
return `${st.toString()}\n//# sourceMappingURL=${map.toUrl()}`;
}
;
return text;
};
/**
* This is a plugin for esbuild that has three functions:
* * It resolves NPM packages to urls served by various CDNs.
* * It fetches imports from http/https urls.
* * It provides evaluation context to npm packages.
*
* ManifoldCAD libraries can include `manifold-3d/manifoldCAD` to get access to
* the evaluator context. Outside of the evaluator, this involves importing a
* module. Inside the evaluator, imports are in the global namespace. The
* imported module will not be able to see the actual evaluator context. Here,
* we will swap it out for an object we will inject at evaluation time.
*/
export const esbuildManifoldPlugin = (options = {}) => ({
name: 'esbuild-manifold-plugin',
async setup(build) {
let manifoldCADExportPath = null;
const manifoldCADExportURLMatch = /manifold-3d(@[0-9.]+)?\/manifoldCAD/;
const ManifoldCADExportMatch = /^manifold-3d\/manifoldCAD(.ts|.js)?$/;
const manifoldCADExportNames = [
// Manifold classes.
'Mesh', 'Manifold', 'CrossSection',
// Manifold methods.
'triangulate',
// Scene builder exports.
'show', 'only', 'setMaterial',
// GLTFNode and utilities.
'GLTFMaterial', 'GLTFNode', 'getGLTFNodes', 'VisualizationGLTFNode',
'CrossSectionGLTFNode',
// Import
'importModel', 'importManifold',
// Getters for global properties
'getCircularSegments', 'getMinCircularAngle', 'getMinCircularEdgeLength',
'getAnimationDuration', 'getAnimationFPS', 'getAnimationMode',
// Setters for global properties.
// These will only be defined for top level scripts
'setMinCircularAngle', 'setMinCircularEdgeLength', 'setCircularSegments',
'resetToCircularDefaults', 'setMorphStart', 'setMorphEnd',
'setAnimationDuration', 'setAnimationFPS', 'setAnimationMode',
'resetGLTFNodes',
// ManifoldCAD specific exports.
'isManifoldCAD'
];
if (isNode()) {
// We only need to check against the local manifoldCAD context on disk if
// we happen to be running in node. Truthfully, this is really only
// necessary in development. End users will almost always import
// `manifold-3d/manifoldCAD` instead of `some/path/to/manifoldCAD`.
(async () => {
const { resolve, dirname } = await import('node:path');
const { fileURLToPath } = await import('node:url');
const dir = ('string' == typeof __dirname && __dirname) ||
('string' == typeof import.meta?.dirname && import.meta.dirname) ||
dirname(fileURLToPath(import.meta.url));
manifoldCADExportPath = resolve(dir, './manifoldCAD.ts');
})();
}
let entrypoint = null;
// Try to resolve local files. If we can't, blindly resolve them to a CDN.
build.onResolve({ filter: /.*/ }, async (args) => {
// Avoid loops.
if (args.pluginData !== undefined)
return null;
// Skip a few cases handled elsewhere.
if (args.namespace === 'http-url')
return null;
if (args.path.match(/^https?:\/\//))
return null;
if (!entrypoint && args.kind === 'entry-point') {
entrypoint = resolve(args.path, args.resolveDir + '/');
}
// Is this a manifoldCAD context import?
// FIXME resolve path here too.
const pluginData = {
toplevel: args.importer === entrypoint ||
args.importer === options.filename || args.importer === '<stdin>',
};
if (args.path.match(ManifoldCADExportMatch)) {
return { namespace: 'manifold-cad-globals', path: args.path, pluginData };
}
// Is this a virtual file?
// FIXME Resolve paths!
if (options.files && Object.keys(options.files).includes(args.path)) {
return { namespace: 'virtual-file', path: args.path };
}
// Try esbuilds' resolver first.
const result = await build.resolve(args.path, {
resolveDir: args.resolveDir,
kind: 'import-statement',
pluginData: { resolveDir: options.resolveDir }
});
// We found a local file!
if (result.errors.length === 0) {
if (manifoldCADExportPath && manifoldCADExportPath === result.path) {
// It resolved to our local manifoldCAD context.
return {
namespace: 'manifold-cad-globals',
path: args.path,
pluginData
};
}
result.pluginData = {
resolveDir: args.resolveDir.endsWith('/') ? args.resolveDir :
`${args.resolveDir}/`
};
return result;
}
// Built in resolver failed. Are we fetching remote packages?
if (options.fetchRemotePackages !== false && options.jsCDN) {
return {
path: cdnUrl(args.path, options.jsCDN),
namespace: 'http-url',
};
}
// Okay fine. I give up.
return null;
});
// Instead of loading manifoldCAD.ts, insert an instantiated copy.
// The global variables enabling this are set by the worker.
build.onLoad({ filter: /.*/, namespace: 'manifold-cad-globals' }, (args) => {
// This is a string replace.
const globals = args.pluginData?.toplevel ?
'_manifold_cad_top_level' :
'_manifold_cad_library';
return {
// Type hinting isn't necessary. Only esbuild will see the swap,
// and it doesn't do type validation.
contents: `export const {${manifoldCADExportNames}} = ${globals};`,
};
});
// Unless disabled, handle HTTP/HTTPs urls.
if (options.fetchRemotePackages !== false) {
// Resolve absolute urls.
build.onResolve({ filter: /^https?:\/\// }, args => {
return { path: args.path, namespace: 'http-url' };
});
// Resolve relative http urls into absolute urls.
build.onResolve({ filter: /.*/, namespace: 'http-url' }, args => {
const path = new URL(args.path, args.importer).toString();
// Is this a manifoldCAD context import from a remote package?
// e.g.: `/npm/manifold-3d/manifoldCAD/+esm`
if (path.match(manifoldCADExportURLMatch)) {
const response = {
path,
namespace: 'manifold-cad-globals',
pluginData: { toplevel: false }
};
return response;
}
return { path, namespace: 'http-url' };
});
// Fetch urls.
build.onLoad({ filter: /.*/, namespace: 'http-url' }, async (args) => {
try {
const response = await fetchWithRetry(args.path);
return { contents: await response.text() };
}
catch (err) {
if (err instanceof FetchError) {
return { errors: [{ text: err.message }] };
}
throw err;
}
});
}
// Virtual files.
build.onLoad({ filter: /.*/, namespace: 'virtual-file' }, (args) => {
const text = (options.files)[args.path];
const contents = insertMetaData(text, `file://${args.path}`);
const loader = (args.path.match(/\.js$/)) ? 'js' : 'ts';
return { contents, loader };
});
// Finally, local files.
build.onLoad({ filter: /.(ts|js)$/ }, async (args) => {
const fs = await import('node:fs/promises');
const text = await fs.readFile(args.path, 'utf8');
const contents = insertMetaData(text, `file://${args.path}`);
const loader = (args.path.match(/\.js$/)) ? 'js' : 'ts';
return { contents, loader };
});
},
});
let esbuild_initialized = false;
const getEsbuildConfig = async (options = {}) => {
if (!esbuild_initialized) {
const esbuildOptions = {};
if (typeof esbuildWasmUrl === 'string' && esbuildWasmUrl) {
esbuildOptions.wasmURL = esbuildWasmUrl;
esbuildOptions.worker = esbuildHasOwnWorker === true;
}
await esbuild.initialize(esbuildOptions);
esbuild_initialized = true;
}
return {
// Create a bundle in memory.
bundle: true,
write: false,
platform: 'neutral',
treeShaking: false,
sourcemap: 'inline',
sourcesContent: false, // We have the source handy already.
format: 'cjs',
logLevel: 'silent',
plugins: [
esbuildManifoldPlugin(options),
],
// Some CDN imports will check import.meta.env. This is only present when
// generating an ESM bundle. In other cases, it generates log noise, so
// let's drop it down a log level.
logOverride: { 'empty-import-meta': 'info' },
// Define some paths so we can find resources by relative URL.
define: {
'import.meta.url': '_import_meta_url',
}
};
};
export const bundleFile = async (entrypoint, options = {}) => {
try {
const built = await esbuild.build({
...(await getEsbuildConfig({ ...options, filename: entrypoint })),
entryPoints: [entrypoint],
});
return built.outputFiles[0].text;
}
catch (error) {
if (error.errors?.length) {
throw new BundlerError(error);
}
else {
throw error;
}
}
};
export const bundleCode = async (code, options = {}) => {
try {
let resolveDir;
if (isNode() && options.filename) {
const { dirname } = await import('node:path');
resolveDir = options.resolveDir ?? dirname(options.filename);
}
const built = await esbuild.build({
...(await getEsbuildConfig(options)),
stdin: {
contents: insertMetaData(code, `file://${options.filename}`),
sourcefile: options.filename,
resolveDir,
loader: 'ts',
}
});
return built.outputFiles[0].text;
}
catch (error) {
if (error.errors?.length) {
throw new BundlerError(error);
}
else {
throw error;
}
}
};
//# sourceMappingURL=bundler.js.map